diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index f85cfe64c..248a3318f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.media3.common.Player import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING @@ -84,6 +85,7 @@ fun RenderTopButtons( RenderTopButtons( mediaData = mediaData, + player = controllerState.controller, controllerVisible = controllerVisible, startingMuteState = controllerState.controller.volume < 0.001, isLive = isLive, @@ -112,6 +114,7 @@ fun RenderTopButtons( @Composable fun RenderTopButtons( mediaData: MediaItemData, + player: Player? = null, controllerVisible: MutableState, startingMuteState: Boolean, isLive: Boolean, @@ -142,6 +145,13 @@ fun RenderTopButtons( toggle = onMuteClick, ) + if (player != null) { + VideoQualityButton( + player = player, + controllerVisible = controllerVisible, + ) + } + Box { AnimatedOverflowMenuButton( controllerVisible = controllerVisible, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt new file mode 100644 index 000000000..8553bee67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.media3.common.C +import androidx.media3.common.Tracks + +/** + * Checks if the current tracks contain multiple video renditions (HLS/DASH adaptive streams). + * Returns false for single-rendition MP4s to avoid showing a useless quality menu. + */ +fun hasMultipleRenditions(tracks: Tracks): Boolean { + val videoGroup = getVideoTrackGroup(tracks) ?: return false + return videoGroup.length > 1 +} + +/** + * Returns the first video track group from the tracks, or null if none exists. + */ +fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = + tracks.groups.firstOrNull { group -> + group.type == C.TRACK_TYPE_VIDEO && group.length > 0 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt new file mode 100644 index 000000000..1c46f1567 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.media3.common.Player +import androidx.media3.common.Tracks +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier + +/** + * A gear icon button that opens the video quality picker. + * Only visible when the video has multiple renditions (HLS/DASH adaptive streams). + */ +@Composable +fun VideoQualityButton( + player: Player, + controllerVisible: MutableState, + modifier: Modifier = Modifier, +) { + var tracks by remember { mutableStateOf(player.currentTracks) } + var showSheet by remember { mutableStateOf(false) } + + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onTracksChanged(newTracks: Tracks) { + tracks = newTracks + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + + val hasMultiple = hasMultipleRenditions(tracks) + + AnimatedVisibility( + visible = controllerVisible.value && hasMultiple, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = { showSheet = true }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringRes(id = R.string.video_quality), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size20Modifier, + ) + } + } + } + + if (showSheet) { + VideoQualitySheet( + player = player, + onDismiss = { showSheet = false }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt new file mode 100644 index 000000000..d709dea9a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.Tracks + +/** + * Represents a quality option in the picker. + */ +sealed class QualityOption { + data object Auto : QualityOption() + + data class Specific( + val trackIndex: Int, + val height: Int, + val bitrate: Int, + val codecs: String?, + ) : QualityOption() +} + +/** + * Modal bottom sheet for selecting video quality (rendition). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoQualitySheet( + player: Player, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var tracks by remember { mutableStateOf(player.currentTracks) } + var isAutoSelected by remember { mutableStateOf(!hasVideoOverride(player)) } + var currentlyPlayingHeight by remember { mutableStateOf(getCurrentPlayingHeight(player)) } + + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onTracksChanged(newTracks: Tracks) { + tracks = newTracks + currentlyPlayingHeight = getCurrentPlayingHeight(player) + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + + val videoGroup = getVideoTrackGroup(tracks) + if (videoGroup == null) { + onDismiss() + return + } + + val options = buildQualityOptions(videoGroup) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = "Quality", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + // Auto option + QualityRow( + label = "Auto", + sublabel = currentlyPlayingHeight?.let { "${it}p" }, + isSelected = isAutoSelected, + onClick = { + clearVideoOverride(player) + isAutoSelected = true + onDismiss() + }, + ) + + // Specific quality options + options.forEach { option -> + if (option is QualityOption.Specific) { + QualityRow( + label = "${option.height}p", + sublabel = formatBitrate(option.bitrate) + (option.codecs?.let { " $it" } ?: ""), + isSelected = !isAutoSelected && currentlyPlayingHeight == option.height, + onClick = { + selectVideoTrack(player, videoGroup, option.trackIndex) + isAutoSelected = false + onDismiss() + }, + ) + } + } + } + } +} + +@Composable +private fun QualityRow( + label: String, + sublabel: String?, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } else { + Spacer(Modifier.size(24.dp)) + } + + Spacer(Modifier.width(16.dp)) + + Column { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (sublabel != null) { + Text( + text = sublabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun buildQualityOptions(group: Tracks.Group): List { + val options = mutableListOf() + + for (i in 0 until group.length) { + val format = group.getTrackFormat(i) + if (format.height > 0) { + options.add( + QualityOption.Specific( + trackIndex = i, + height = format.height, + bitrate = format.bitrate, + codecs = format.codecs, + ), + ) + } + } + + // Sort by height descending + return options.sortedByDescending { (it as QualityOption.Specific).height } +} + +private fun formatBitrate(bitrate: Int): String = + if (bitrate > 0) { + val mbps = bitrate / 1_000_000.0 + if (mbps >= 1.0) { + String.format("%.1f Mbps", mbps) + } else { + String.format("%.0f kbps", bitrate / 1000.0) + } + } else { + "" + } + +private fun hasVideoOverride(player: Player): Boolean = + player.trackSelectionParameters.overrides.any { (key, _) -> + key.type == C.TRACK_TYPE_VIDEO + } + +private fun getCurrentPlayingHeight(player: Player): Int? { + val videoGroup = getVideoTrackGroup(player.currentTracks) ?: return null + for (i in 0 until videoGroup.length) { + if (videoGroup.isTrackSelected(i)) { + return videoGroup.getTrackFormat(i).height + } + } + return null +} + +private fun clearVideoOverride(player: Player) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() +} + +private fun selectVideoTrack( + player: Player, + group: Tracks.Group, + trackIndex: Int, +) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .setOverrideForType( + TrackSelectionOverride(group.mediaTrackGroup, trackIndex), + ).build() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index 4f1398cf2..4a32d8066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.playback.playerPool import android.content.Context import androidx.annotation.OptIn +import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.vitorpamplona.quartz.utils.Log @@ -82,6 +83,13 @@ class ExoPlayerPool( player.clearVideoSurface() player.clearMediaItems() + // Clear any video quality overrides so the next video starts with Auto + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() + if (playerPool.size < poolSize) { if (!playerPool.contains(player)) { playerPool.add(player) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 48c2a623a..463d73610 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2076,6 +2076,7 @@ Profile Actions Media Actions Playback + Video quality Pack Actions List Actions Bookmark Actions