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..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 @@ -26,6 +26,8 @@ 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.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 @@ -50,6 +52,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 +74,10 @@ fun VideoViewInner( mediaItem = mediaItem, muted = muted, ) { controller -> + ApplyInitialVideoQuality( + player = controller.controller, + policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST, + ) 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..0b5f200ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/InitialVideoQualitySelector.kt @@ -0,0 +1,117 @@ +/* + * 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.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. + * + * 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 [VideoQualityPolicy.AUTO] and returning to the feed + * starts with [VideoQualityPolicy.LOWEST] again. + */ +@Composable +fun ApplyInitialVideoQuality( + player: Player, + policy: VideoQualityPolicy, +) { + // Tracks the media id we've already initialized so we don't fight user overrides after the + // 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 + + val listener = + object : Player.Listener { + override fun onTracksChanged(tracks: Tracks) { + applyInitialQuality(player, tracks, policy, appliedForMediaId) + } + } + + // Tracks might already be available by the time we attach the listener. + 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, + policy: VideoQualityPolicy, + appliedForMediaId: MutableState, +) { + val mediaId = player.currentMediaItem?.mediaId ?: return + if (appliedForMediaId.value == mediaId) return + + val videoGroup = getVideoTrackGroup(tracks) ?: return + // 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.value = mediaId + return + } + + when (policy) { + VideoQualityPolicy.AUTO -> { + if (hasVideoOverride(player)) clearVideoOverride(player) + appliedForMediaId.value = mediaId + } + + 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 + } + } +} 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 deleted file mode 100644 index 25581490c..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityAvailability.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 } - -// 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 - 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 } - } - } - 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 index 8a1ad6380..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 @@ -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 @@ -119,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 }, @@ -126,10 +140,10 @@ fun VideoQualityButton( ) { VideoQualityChoices( videoGroup = videoGroup, - currentShortSide = getCurrentPlayingShortSide(tracks), + currentShortSide = currentShortSide, isAuto = !hasVideoOverride(player), onSelectAuto = { - clearVideoOverride(player) + if (hasVideoOverride(player)) clearVideoOverride(player) openDialog = false }, onSelectTrack = { trackIndex -> @@ -195,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) { @@ -210,26 +225,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/composable/controls/VideoQualityControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt new file mode 100644 index 000000000..f62ba4353 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt @@ -0,0 +1,74 @@ +/* + * 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.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 + +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. 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) +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) { + bestShortSide = shortSide + bestIndex = i + } + } + return bestIndex +} + +@OptIn(UnstableApi::class) +internal fun hasVideoOverride(player: Player): Boolean = player.trackSelectionParameters.overrides.any { (key, _) -> key.type == C.TRACK_TYPE_VIDEO } + +internal fun clearVideoOverride(player: Player) { + player.trackSelectionParameters = + player.trackSelectionParameters + .buildUpon() + .clearOverridesOfType(C.TRACK_TYPE_VIDEO) + .build() +} + +@OptIn(UnstableApi::class) +internal 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..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 @@ -34,6 +34,8 @@ 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.controls.VideoQualityPolicy import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData @@ -50,6 +52,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, + policy = VideoQualityPolicy.LOWEST, + ) 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, ) }