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()
|
it()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
qualityButton = {
|
||||||
|
VideoQualityButton(
|
||||||
|
player = controllerState.controller,
|
||||||
|
controllerVisible = controllerVisible,
|
||||||
|
)
|
||||||
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
)
|
)
|
||||||
@@ -121,6 +127,7 @@ fun RenderTopButtons(
|
|||||||
onZoomClick: (() -> Unit)?,
|
onZoomClick: (() -> Unit)?,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
qualityButton: @Composable () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val shareDialogVisible = remember { mutableStateOf(false) }
|
val shareDialogVisible = remember { mutableStateOf(false) }
|
||||||
val saveAction =
|
val saveAction =
|
||||||
@@ -142,6 +149,8 @@ fun RenderTopButtons(
|
|||||||
toggle = onMuteClick,
|
toggle = onMuteClick,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
qualityButton()
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
AnimatedOverflowMenuButton(
|
AnimatedOverflowMenuButton(
|
||||||
controllerVisible = controllerVisible,
|
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 android.content.Context
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
@@ -82,6 +83,13 @@ class ExoPlayerPool(
|
|||||||
player.clearVideoSurface()
|
player.clearVideoSurface()
|
||||||
player.clearMediaItems()
|
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.size < poolSize) {
|
||||||
if (!playerPool.contains(player)) {
|
if (!playerPool.contains(player)) {
|
||||||
playerPool.add(player)
|
playerPool.add(player)
|
||||||
|
|||||||
@@ -2076,6 +2076,7 @@
|
|||||||
<string name="profile_actions_dialog_title">Profile Actions</string>
|
<string name="profile_actions_dialog_title">Profile Actions</string>
|
||||||
<string name="media_actions_dialog_title">Media Actions</string>
|
<string name="media_actions_dialog_title">Media Actions</string>
|
||||||
<string name="playback_actions_dialog_title">Playback</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="pack_actions_dialog_title">Pack Actions</string>
|
||||||
<string name="list_actions_dialog_title">List Actions</string>
|
<string name="list_actions_dialog_title">List Actions</string>
|
||||||
<string name="bookmark_item_actions_dialog_title">Bookmark Actions</string>
|
<string name="bookmark_item_actions_dialog_title">Bookmark Actions</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user