Merge pull request #2384 from davotoula/feat/adaptive-video-playback
Add YouTube-style video quality picker for adaptive videos
This commit is contained in:
+9
@@ -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,
|
||||
|
||||
+34
@@ -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
|
||||
}
|
||||
+227
@@ -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()
|
||||
}
|
||||
+8
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user