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..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 @@ -103,6 +103,12 @@ fun RenderTopButtons( it() } }, + qualityButton = { + VideoQualityButton( + player = controllerState.controller, + controllerVisible = controllerVisible, + ) + }, modifier = modifier, accountViewModel = accountViewModel, ) @@ -121,6 +127,7 @@ fun RenderTopButtons( onZoomClick: (() -> Unit)?, modifier: Modifier, accountViewModel: AccountViewModel, + qualityButton: @Composable () -> Unit = {}, ) { val shareDialogVisible = remember { mutableStateOf(false) } val saveAction = @@ -142,6 +149,8 @@ fun RenderTopButtons( toggle = onMuteClick, ) + qualityButton() + 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..ff9be88bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt @@ -0,0 +1,34 @@ +/* + * 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 + +fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.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 new file mode 100644 index 000000000..3d59c0070 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt @@ -0,0 +1,227 @@ +/* + * 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.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 +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.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 + +@Composable +fun VideoQualityButton( + player: Player, + controllerVisible: MutableState, + modifier: Modifier = Modifier, +) { + 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) { + tracks = newTracks + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + + val videoGroup = getVideoTrackGroup(tracks) ?: return + if (videoGroup.length <= 1) return + + AnimatedVisibility( + visible = controllerVisible.value, + 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 = { openDialog = true }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringRes(id = R.string.call_settings_video_quality), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size20Modifier, + ) + } + } + } + + 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/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..b43403e23 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 + Auto Pack Actions List Actions Bookmark Actions