From 830e410a7929d65feafc693617f0c0ca9f2f1138 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 20 Apr 2026 20:45:13 +0200 Subject: [PATCH 1/2] feat(video): default feed/PiP to lowest HLS resolution, fullscreen to auto --- .../playback/composable/VideoViewInner.kt | 6 ++ .../controls/InitialVideoQualitySelector.kt | 96 +++++++++++++++++++ .../controls/VideoQualityAvailability.kt | 50 ++++++++-- .../composable/controls/VideoQualityButton.kt | 41 +++----- .../service/playback/pip/PipVideoActivity.kt | 7 ++ .../ui/components/ZoomableContentDialog.kt | 2 + 6 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 99f0a5556..c8b1d009b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -50,6 +51,7 @@ fun VideoViewInner( controllerVisible: MutableState = mutableStateOf(false), onZoom: (() -> Unit)? = null, hasBlurhash: Boolean = false, + isFullscreen: Boolean = false, accountViewModel: AccountViewModel, ) { // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes @@ -71,6 +73,10 @@ fun VideoViewInner( mediaItem = mediaItem, muted = muted, ) { controller -> + ApplyInitialVideoQuality( + player = controller.controller, + isFullscreen = isFullscreen, + ) VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) RenderVideoPlayer( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt new file mode 100644 index 000000000..994d029f5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -0,0 +1,96 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.media3.common.Player +import androidx.media3.common.Tracks + +/** + * Applies a default video quality when tracks become available on the given player. + * + * - In feed context (`isFullscreen = false`): locks to the lowest-resolution rendition to save + * bandwidth. The user can still manually pick any quality (or "Auto") via the quality button. + * - In fullscreen context (`isFullscreen = true`): clears any video override so the player uses + * adaptive bitrate selection (Auto). + * + * The initial selection is applied once per media item. If the user later changes the quality + * manually, or swaps to a different media item, the new choice wins — we don't reapply for the + * same media id. Selections intentionally don't persist across composable lifecycles, so opening + * a feed video in fullscreen starts with Auto and returning to the feed starts with lowest again. + */ +@Composable +fun ApplyInitialVideoQuality( + player: Player, + isFullscreen: Boolean, +) { + // Tracks the media id we've already initialized so we don't fight user overrides after the + // first application. Scoped to this composable instance so fullscreen <-> feed transitions + // reset the choice as required (they're separate VideoViewInner instances with separate + // players, so isFullscreen never flips on a given instance). + val appliedForMediaId = remember(player) { arrayOf(null) } + + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + applyInitialQuality(player, tracks, isFullscreen, appliedForMediaId) + } + } + + // Tracks might already be available by the time we attach the listener. + applyInitialQuality(player, player.currentTracks, isFullscreen, appliedForMediaId) + player.addListener(listener) + onDispose { player.removeListener(listener) } + } +} + +private fun applyInitialQuality( + player: Player, + tracks: Tracks, + isFullscreen: Boolean, + appliedForMediaId: Array, +) { + val mediaId = player.currentMediaItem?.mediaId ?: return + if (appliedForMediaId[0] == mediaId) return + + val videoGroup = getVideoTrackGroup(tracks) ?: return + // No point forcing a choice when there's only one rendition. + if (videoGroup.length <= 1) { + appliedForMediaId[0] = mediaId + return + } + + if (isFullscreen) { + // Ensure adaptive selection is active by removing any pre-existing video override. + if (hasVideoOverride(player)) { + clearVideoOverride(player) + } + } else { + val lowestIndex = findLowestResolutionTrackIndex(videoGroup) + if (lowestIndex != null) { + selectVideoTrack(player, videoGroup, lowestIndex) + } + } + appliedForMediaId[0] = mediaId +} 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 25581490c..28e2bf2a8 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 @@ -20,22 +20,52 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import androidx.annotation.OptIn import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks +import androidx.media3.common.util.UnstableApi fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } -// Returns the "Xp" value for the currently selected video track. Uses min(width, height) so -// that a portrait video's renditions get the same "360p / 540p / 720p" labels as a landscape -// source — the streaming convention is to label by the short side, not format.height which is -// the long side for portrait content. -fun getCurrentPlayingShortSide(tracks: Tracks): Int? { - val group = getVideoTrackGroup(tracks) ?: return null +// Finds the track with the smallest short side (min(width, height)) in the given video group. +// Returns null if no track has a positive short side. Used to force lowest-resolution playback +// in feeds to save bandwidth. +@OptIn(UnstableApi::class) +fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { + var bestIndex: Int? = null + var bestShortSide = Int.MAX_VALUE for (i in 0 until group.length) { - if (group.isTrackSelected(i)) { - val format = group.getTrackFormat(i) - return minOf(format.width, format.height).takeIf { it > 0 } + val format = group.getTrackFormat(i) + val shortSide = minOf(format.width, format.height) + if (shortSide > 0 && shortSide < bestShortSide) { + bestShortSide = shortSide + bestIndex = i } } - return null + return bestIndex +} + +@OptIn(UnstableApi::class) +fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } + +fun clearVideoOverride(player: Player) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() +} + +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/VideoQualityButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityButton.kt index 8a1ad6380..de3e4f3be 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 @@ -51,10 +51,9 @@ 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 androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes @@ -72,15 +71,26 @@ fun VideoQualityButton( modifier: Modifier = Modifier, ) { var tracks by remember(player) { mutableStateOf(player.currentTracks) } + // Track the rendering video size separately from tracks. In adaptive playback, + // Tracks.Group.isTrackSelected(i) returns true for every rung in the adaptive + // set, so we can't derive the currently-playing resolution from the Tracks + // object. Player.videoSize + onVideoSizeChanged gives the real rendered size + // and updates whenever ABR steps up or down. + var videoSize by remember(player) { mutableStateOf(player.videoSize) } var openDialog by remember { mutableStateOf(false) } DisposableEffect(player) { tracks = player.currentTracks + videoSize = player.videoSize val listener = object : Player.Listener { override fun onTracksChanged(newTracks: Tracks) { tracks = newTracks } + + override fun onVideoSizeChanged(newSize: VideoSize) { + videoSize = newSize + } } player.addListener(listener) onDispose { player.removeListener(listener) } @@ -89,6 +99,8 @@ fun VideoQualityButton( val videoGroup = getVideoTrackGroup(tracks) ?: return if (videoGroup.length <= 1) return + val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, @@ -126,7 +138,7 @@ fun VideoQualityButton( ) { VideoQualityChoices( videoGroup = videoGroup, - currentShortSide = getCurrentPlayingShortSide(tracks), + currentShortSide = currentShortSide, isAuto = !hasVideoOverride(player), onSelectAuto = { clearVideoOverride(player) @@ -210,26 +222,3 @@ private fun formatBitrate(bitrate: Int): String = 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) } - -@OptIn(UnstableApi::class) -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/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 0252d4835..089705164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.remember import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -50,6 +51,12 @@ class PipVideoActivity : ComponentActivity() { GetMediaItem(mediaItemData) { mediaItem -> GetVideoController(mediaItem, muted, true) { controllerState -> + // PiP window is small, keep bandwidth low by forcing the lowest + // rendition. User can still manually change quality via controls. + ApplyInitialVideoQuality( + player = controllerState.controller, + isFullscreen = false, + ) RegisterBackgroundMedia(controllerState) RegisterControllerReceiver(controllerState) WatchControllerForActions(mediaItemData, controllerState) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index e4bed1081..19128180c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -570,6 +570,7 @@ private fun RenderImageOrVideo( automaticallyStartPlayback = true, controllerVisible = controllerVisible, hasBlurhash = content.blurhash != null, + isFullscreen = true, accountViewModel = accountViewModel, ) } @@ -630,6 +631,7 @@ private fun RenderImageOrVideo( automaticallyStartPlayback = true, controllerVisible = controllerVisible, hasBlurhash = content.blurhash != null, + isFullscreen = true, accountViewModel = accountViewModel, ) } From cb4a9874980a1b874a0a9ab5393029b7b3bf8d8d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 20 Apr 2026 20:45:38 +0200 Subject: [PATCH 2/2] refactor(video): apply code review fixes --- .../playback/composable/VideoViewInner.kt | 3 +- .../controls/InitialVideoQualitySelector.kt | 75 ++++++++++++------- .../composable/controls/VideoQualityButton.kt | 31 ++++---- ...vailability.kt => VideoQualityControls.kt} | 15 ++-- .../service/playback/pip/PipVideoActivity.kt | 3 +- 5 files changed, 78 insertions(+), 49 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/{VideoQualityAvailability.kt => VideoQualityControls.kt} (77%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index c8b1d009b..e2c164850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality +import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -75,7 +76,7 @@ fun VideoViewInner( ) { controller -> ApplyInitialVideoQuality( player = controller.controller, - isFullscreen = isFullscreen, + policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST, ) VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt index 994d029f5..0b5f200ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -20,77 +20,98 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import androidx.annotation.MainThread import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.media3.common.Player import androidx.media3.common.Tracks +/** + * Which HLS rendition to pick automatically the first time tracks become available for a media + * item. Expressed as an explicit policy instead of a `Boolean` so call sites must commit to one + * value at construction and any future dynamic-state caller is forced to key the effect on it. + */ +enum class VideoQualityPolicy { + /** Lock to the lowest-resolution rendition (feed and PiP: save bandwidth). */ + LOWEST, + + /** Clear any video override so the player uses adaptive bitrate (fullscreen). */ + AUTO, +} + /** * Applies a default video quality when tracks become available on the given player. * - * - In feed context (`isFullscreen = false`): locks to the lowest-resolution rendition to save - * bandwidth. The user can still manually pick any quality (or "Auto") via the quality button. - * - In fullscreen context (`isFullscreen = true`): clears any video override so the player uses - * adaptive bitrate selection (Auto). - * * The initial selection is applied once per media item. If the user later changes the quality * manually, or swaps to a different media item, the new choice wins — we don't reapply for the * same media id. Selections intentionally don't persist across composable lifecycles, so opening - * a feed video in fullscreen starts with Auto and returning to the feed starts with lowest again. + * a feed video in fullscreen starts with [VideoQualityPolicy.AUTO] and returning to the feed + * starts with [VideoQualityPolicy.LOWEST] again. */ @Composable fun ApplyInitialVideoQuality( player: Player, - isFullscreen: Boolean, + policy: VideoQualityPolicy, ) { // Tracks the media id we've already initialized so we don't fight user overrides after the - // first application. Scoped to this composable instance so fullscreen <-> feed transitions - // reset the choice as required (they're separate VideoViewInner instances with separate - // players, so isFullscreen never flips on a given instance). - val appliedForMediaId = remember(player) { arrayOf(null) } + // first application. + val appliedForMediaId = remember(player) { mutableStateOf(null) } + + DisposableEffect(player, policy) { + // Re-arm the guard whenever the player or the policy changes so a new policy gets a + // chance to apply even if the same media id has already been handled under the old one. + appliedForMediaId.value = null - DisposableEffect(player) { val listener = object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { - applyInitialQuality(player, tracks, isFullscreen, appliedForMediaId) + applyInitialQuality(player, tracks, policy, appliedForMediaId) } } // Tracks might already be available by the time we attach the listener. - applyInitialQuality(player, player.currentTracks, isFullscreen, appliedForMediaId) + applyInitialQuality(player, player.currentTracks, policy, appliedForMediaId) player.addListener(listener) onDispose { player.removeListener(listener) } } } +// Invoked from Player.Listener callbacks and DisposableEffect bodies, both of which run on +// the player's application looper (main thread for ExoPlayer). The body mutates Compose state +// and trackSelectionParameters; both are main-thread-only. +@MainThread private fun applyInitialQuality( player: Player, tracks: Tracks, - isFullscreen: Boolean, - appliedForMediaId: Array, + policy: VideoQualityPolicy, + appliedForMediaId: MutableState, ) { val mediaId = player.currentMediaItem?.mediaId ?: return - if (appliedForMediaId[0] == mediaId) return + if (appliedForMediaId.value == mediaId) return val videoGroup = getVideoTrackGroup(tracks) ?: return - // No point forcing a choice when there's only one rendition. + // No point forcing a choice when there's only one rendition, and no future update will + // change that for this media id, so mark it as settled. if (videoGroup.length <= 1) { - appliedForMediaId[0] = mediaId + appliedForMediaId.value = mediaId return } - if (isFullscreen) { - // Ensure adaptive selection is active by removing any pre-existing video override. - if (hasVideoOverride(player)) { - clearVideoOverride(player) + when (policy) { + VideoQualityPolicy.AUTO -> { + if (hasVideoOverride(player)) clearVideoOverride(player) + appliedForMediaId.value = mediaId } - } else { - val lowestIndex = findLowestResolutionTrackIndex(videoGroup) - if (lowestIndex != null) { + + VideoQualityPolicy.LOWEST -> { + // If no supported track has a positive short side yet, leave the guard unset so we + // retry on the next onTracksChanged when real video dimensions arrive. + val lowestIndex = findLowestResolutionTrackIndex(videoGroup) ?: return selectVideoTrack(player, videoGroup, lowestIndex) + appliedForMediaId.value = mediaId } } - appliedForMediaId[0] = mediaId } 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 de3e4f3be..6a3fb5539 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 @@ -71,26 +71,15 @@ fun VideoQualityButton( modifier: Modifier = Modifier, ) { var tracks by remember(player) { mutableStateOf(player.currentTracks) } - // Track the rendering video size separately from tracks. In adaptive playback, - // Tracks.Group.isTrackSelected(i) returns true for every rung in the adaptive - // set, so we can't derive the currently-playing resolution from the Tracks - // object. Player.videoSize + onVideoSizeChanged gives the real rendered size - // and updates whenever ABR steps up or down. - var videoSize by remember(player) { mutableStateOf(player.videoSize) } var openDialog by remember { mutableStateOf(false) } DisposableEffect(player) { tracks = player.currentTracks - videoSize = player.videoSize val listener = object : Player.Listener { override fun onTracksChanged(newTracks: Tracks) { tracks = newTracks } - - override fun onVideoSizeChanged(newSize: VideoSize) { - videoSize = newSize - } } player.addListener(listener) onDispose { player.removeListener(listener) } @@ -99,8 +88,6 @@ fun VideoQualityButton( val videoGroup = getVideoTrackGroup(tracks) ?: return if (videoGroup.length <= 1) return - val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } - AnimatedVisibility( visible = controllerVisible.value, modifier = modifier, @@ -131,6 +118,21 @@ fun VideoQualityButton( } if (openDialog) { + // Scope videoSize tracking to the open popup: HLS ABR fires onVideoSizeChanged on every + // rung switch, and we don't want to pay recomposition cost on cards whose menu isn't open. + var videoSize by remember(player) { mutableStateOf(player.videoSize) } + DisposableEffect(player) { + val listener = + object : Player.Listener { + override fun onVideoSizeChanged(newSize: VideoSize) { + videoSize = newSize + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } + val currentShortSide = minOf(videoSize.width, videoSize.height).takeIf { it > 0 } + Popup( alignment = Alignment.BottomCenter, onDismissRequest = { openDialog = false }, @@ -141,7 +143,7 @@ fun VideoQualityButton( currentShortSide = currentShortSide, isAuto = !hasVideoOverride(player), onSelectAuto = { - clearVideoOverride(player) + if (hasVideoOverride(player)) clearVideoOverride(player) openDialog = false }, onSelectTrack = { trackIndex -> @@ -207,6 +209,7 @@ private data class QualityChoice( private fun buildQualityChoices(group: Tracks.Group): ImmutableList { val choices = mutableListOf() for (i in 0 until group.length) { + if (!group.isTrackSupported(i)) continue val format = group.getTrackFormat(i) val shortSide = minOf(format.width, format.height) if (shortSide > 0) { 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/VideoQualityControls.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt index 28e2bf2a8..f62ba4353 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/VideoQualityControls.kt @@ -27,16 +27,18 @@ import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } +internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } // Finds the track with the smallest short side (min(width, height)) in the given video group. // Returns null if no track has a positive short side. Used to force lowest-resolution playback -// in feeds to save bandwidth. +// in feeds to save bandwidth. Skips tracks the device can't decode — ExoPlayer would silently +// reject an override pointing at an unsupported track and fall back to adaptive selection. @OptIn(UnstableApi::class) -fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { +internal fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { var bestIndex: Int? = null var bestShortSide = Int.MAX_VALUE for (i in 0 until group.length) { + if (!group.isTrackSupported(i)) continue val format = group.getTrackFormat(i) val shortSide = minOf(format.width, format.height) if (shortSide > 0 && shortSide < bestShortSide) { @@ -48,9 +50,9 @@ fun findLowestResolutionTrackIndex(group: Tracks.Group): Int? { } @OptIn(UnstableApi::class) -fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } +internal fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } -fun clearVideoOverride(player: Player) { +internal fun clearVideoOverride(player: Player) { player.trackSelectionParameters = player.trackSelectionParameters .buildUpon() @@ -58,7 +60,8 @@ fun clearVideoOverride(player: Player) { .build() } -fun selectVideoTrack( +@OptIn(UnstableApi::class) +internal fun selectVideoTrack( player: Player, group: Tracks.Group, trackIndex: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 089705164..5c21ecaf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -35,6 +35,7 @@ import androidx.media3.common.util.UnstableApi import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.controls.ApplyInitialVideoQuality +import com.vitorpamplona.amethyst.service.playback.composable.controls.VideoQualityPolicy import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -55,7 +56,7 @@ class PipVideoActivity : ComponentActivity() { // rendition. User can still manually change quality via controls. ApplyInitialVideoQuality( player = controllerState.controller, - isFullscreen = false, + policy = VideoQualityPolicy.LOWEST, ) RegisterBackgroundMedia(controllerState) RegisterControllerReceiver(controllerState)