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 99f0a55562..c8b1d009b1 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 0000000000..994d029f59 --- /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 25581490cf..28e2bf2a88 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 8a1ad6380c..de3e4f3be7 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 0252d48358..0897051643 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 e4bed10811..19128180c2 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, ) }