From 4fc739ad7e5d9d3d189f25dee97a487b7392d302 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 10:04:37 +0200 Subject: [PATCH 1/2] feat: add YouTube-style video quality picker Adds a manual override for HLS/DASH adaptive streams via a gear icon that opens a ModalBottomSheet with Auto + per-rendition options. The button is hidden for single-rendition videos. Overrides reset when players return to the pool so each video starts in Auto. Co-Authored-By: Claude Opus 4.5 --- .../composable/controls/RenderTopButtons.kt | 10 + .../controls/VideoQualityAvailability.kt | 41 +++ .../composable/controls/VideoQualityButton.kt | 114 ++++++++ .../composable/controls/VideoQualitySheet.kt | 269 ++++++++++++++++++ .../playback/playerPool/ExoPlayerPool.kt | 8 + amethyst/src/main/res/values/strings.xml | 1 + 6 files changed, 443 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt 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 From 606d976b5772f4b867151693d2abc07ff858a03c Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 14 Apr 2026 10:11:41 +0200 Subject: [PATCH 2/2] code review fixes: Close the listener race window by re-snapshotting tracks inside the DisposableEffect, hoist the openDialog remember above the early returns so the hook count stays stable when the player loses its video group, return ImmutableList from buildQualityChoices for Compose stability, and remember the TextButton ButtonColors copy. Consolidate VideoQualitySheet into VideoQualityButton using the existing PlaybackSpeedPopUpButton Popup pattern. Derives selection state from tracks instead of duplicating it, drops the dead QualityOption.Auto case, gates listener-driven UI behind early returns, and reuses the existing call_settings_video_quality string. Net -168 lines. --- .../composable/controls/RenderTopButtons.kt | 17 +- .../controls/VideoQualityAvailability.kt | 21 +- .../composable/controls/VideoQualityButton.kt | 143 +++++++++- .../composable/controls/VideoQualitySheet.kt | 269 ------------------ amethyst/src/main/res/values/strings.xml | 2 +- 5 files changed, 144 insertions(+), 308 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt 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 248a3318f..8658384f2 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,7 +31,6 @@ 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 @@ -85,7 +84,6 @@ fun RenderTopButtons( RenderTopButtons( mediaData = mediaData, - player = controllerState.controller, controllerVisible = controllerVisible, startingMuteState = controllerState.controller.volume < 0.001, isLive = isLive, @@ -105,6 +103,12 @@ fun RenderTopButtons( it() } }, + qualityButton = { + VideoQualityButton( + player = controllerState.controller, + controllerVisible = controllerVisible, + ) + }, modifier = modifier, accountViewModel = accountViewModel, ) @@ -114,7 +118,6 @@ fun RenderTopButtons( @Composable fun RenderTopButtons( mediaData: MediaItemData, - player: Player? = null, controllerVisible: MutableState, startingMuteState: Boolean, isLive: Boolean, @@ -124,6 +127,7 @@ fun RenderTopButtons( onZoomClick: (() -> Unit)?, modifier: Modifier, accountViewModel: AccountViewModel, + qualityButton: @Composable () -> Unit = {}, ) { val shareDialogVisible = remember { mutableStateOf(false) } val saveAction = @@ -145,12 +149,7 @@ fun RenderTopButtons( toggle = onMuteClick, ) - if (player != null) { - VideoQualityButton( - player = player, - controllerVisible = controllerVisible, - ) - } + qualityButton() Box { AnimatedOverflowMenuButton( 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 index 8553bee67..ff9be88bd 100644 --- 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 @@ -23,19 +23,12 @@ 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 -} +fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } -/** - * 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 +fun getCurrentPlayingHeight(tracks: Tracks): Int? { + val group = getVideoTrackGroup(tracks) ?: return null + for (i in 0 until group.length) { + if (group.isTrackSelected(i)) return group.getTrackFormat(i).height } + return null +} 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 index 1c46f1567..3d59c0070 100644 --- 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 @@ -24,14 +24,19 @@ 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.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -42,28 +47,33 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride 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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import java.util.Locale -/** - * 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) } + var tracks by remember(player) { mutableStateOf(player.currentTracks) } + var openDialog by remember { mutableStateOf(false) } DisposableEffect(player) { + tracks = player.currentTracks val listener = object : Player.Listener { override fun onTracksChanged(newTracks: Tracks) { @@ -74,10 +84,11 @@ fun VideoQualityButton( onDispose { player.removeListener(listener) } } - val hasMultiple = hasMultipleRenditions(tracks) + val videoGroup = getVideoTrackGroup(tracks) ?: return + if (videoGroup.length <= 1) return AnimatedVisibility( - visible = controllerVisible.value && hasMultiple, + visible = controllerVisible.value, modifier = modifier, enter = remember { fadeIn() }, exit = remember { fadeOut() }, @@ -92,12 +103,12 @@ fun VideoQualityButton( ) IconButton( - onClick = { showSheet = true }, + onClick = { openDialog = true }, modifier = Size50Modifier, ) { Icon( imageVector = Icons.Default.Settings, - contentDescription = stringRes(id = R.string.video_quality), + contentDescription = stringRes(id = R.string.call_settings_video_quality), tint = MaterialTheme.colorScheme.onBackground, modifier = Size20Modifier, ) @@ -105,10 +116,112 @@ fun VideoQualityButton( } } - if (showSheet) { - VideoQualitySheet( - player = player, - onDismiss = { showSheet = false }, - ) + if (openDialog) { + Popup( + alignment = Alignment.BottomCenter, + onDismissRequest = { openDialog = false }, + properties = PopupProperties(focusable = true), + ) { + VideoQualityChoices( + videoGroup = videoGroup, + currentHeight = getCurrentPlayingHeight(tracks), + isAuto = !hasVideoOverride(player), + onSelectAuto = { + clearVideoOverride(player) + openDialog = false + }, + onSelectTrack = { trackIndex -> + selectVideoTrack(player, videoGroup, trackIndex) + openDialog = false + }, + ) + } } } + +@Composable +private fun VideoQualityChoices( + videoGroup: Tracks.Group, + currentHeight: Int?, + isAuto: Boolean, + onSelectAuto: () -> Unit, + onSelectTrack: (Int) -> Unit, +) { + val baseColors = ButtonDefaults.textButtonColors() + val contentColor = MaterialTheme.colorScheme.onBackground + val colors = + remember(baseColors, contentColor) { + baseColors.copy(contentColor = contentColor) + } + + val choices: ImmutableList = remember(videoGroup) { buildQualityChoices(videoGroup) } + + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TextButton(colors = colors, onClick = onSelectAuto) { + val suffix = currentHeight?.let { " (${it}p)" } ?: "" + Text( + stringRes(R.string.video_quality_auto) + suffix, + fontWeight = if (isAuto) FontWeight(1000) else FontWeight(400), + ) + } + + choices.forEach { choice -> + TextButton(colors = colors, onClick = { onSelectTrack(choice.trackIndex) }) { + Text( + "${choice.height}p ${formatBitrate(choice.bitrate)}", + fontWeight = if (!isAuto && currentHeight == choice.height) FontWeight(1000) else FontWeight(400), + ) + } + } + } +} + +private data class QualityChoice( + val trackIndex: Int, + val height: Int, + val bitrate: Int, +) + +private fun buildQualityChoices(group: Tracks.Group): ImmutableList { + val choices = mutableListOf() + for (i in 0 until group.length) { + val format = group.getTrackFormat(i) + if (format.height > 0) { + choices.add(QualityChoice(i, format.height, format.bitrate)) + } + } + return choices.sortedByDescending { it.height }.toImmutableList() +} + +private fun formatBitrate(bitrate: Int): String = + when { + bitrate <= 0 -> "" + bitrate >= 1_000_000 -> String.format(Locale.US, "%.1f Mbps", bitrate / 1_000_000.0) + else -> String.format(Locale.US, "%.0f kbps", bitrate / 1_000.0) + } + +private fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } + +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/composable/controls/VideoQualitySheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt deleted file mode 100644 index d709dea9a..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualitySheet.kt +++ /dev/null @@ -1,269 +0,0 @@ -/* - * 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 463d73610..b43403e23 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2076,7 +2076,7 @@ Profile Actions Media Actions Playback - Video quality + Auto Pack Actions List Actions Bookmark Actions