Merge pull request #2384 from davotoula/feat/adaptive-video-playback

Add YouTube-style video quality picker for adaptive videos
This commit is contained in:
Vitor Pamplona
2026-04-14 07:54:52 -04:00
committed by GitHub
5 changed files with 279 additions and 0 deletions
@@ -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,
@@ -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
}
@@ -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<Boolean>,
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<QualityChoice> = 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<QualityChoice> {
val choices = mutableListOf<QualityChoice>()
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()
}
@@ -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)
+1
View File
@@ -2076,6 +2076,7 @@
<string name="profile_actions_dialog_title">Profile Actions</string>
<string name="media_actions_dialog_title">Media Actions</string>
<string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string>
<string name="pack_actions_dialog_title">Pack Actions</string>
<string name="list_actions_dialog_title">List Actions</string>
<string name="bookmark_item_actions_dialog_title">Bookmark Actions</string>