Merge pull request #2461 from davotoula/claude/hls-resolution-selection-jzBTG

HLS video: Lowest resolution in feed/PiP, auto resolution in full screen
This commit is contained in:
Vitor Pamplona
2026-04-20 15:19:30 -04:00
committed by GitHub
7 changed files with 227 additions and 68 deletions
@@ -26,6 +26,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale 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.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -50,6 +52,7 @@ fun VideoViewInner(
controllerVisible: MutableState<Boolean> = mutableStateOf(false), controllerVisible: MutableState<Boolean> = mutableStateOf(false),
onZoom: (() -> Unit)? = null, onZoom: (() -> Unit)? = null,
hasBlurhash: Boolean = false, hasBlurhash: Boolean = false,
isFullscreen: Boolean = false,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -71,6 +74,10 @@ fun VideoViewInner(
mediaItem = mediaItem, mediaItem = mediaItem,
muted = muted, muted = muted,
) { controller -> ) { controller ->
ApplyInitialVideoQuality(
player = controller.controller,
policy = if (isFullscreen) VideoQualityPolicy.AUTO else VideoQualityPolicy.LOWEST,
)
VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen -> VideoPlayerActiveMutex(controller) { videoModifier, isClosestToTheCenterOfTheScreen ->
ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen) ControlWhenPlayerIsActive(controller, automaticallyStartPlayback, isClosestToTheCenterOfTheScreen)
RenderVideoPlayer( RenderVideoPlayer(
@@ -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<String?>(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<String?>,
) {
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
}
}
}
@@ -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
}
@@ -51,10 +51,9 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -119,6 +118,21 @@ fun VideoQualityButton(
} }
if (openDialog) { 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( Popup(
alignment = Alignment.BottomCenter, alignment = Alignment.BottomCenter,
onDismissRequest = { openDialog = false }, onDismissRequest = { openDialog = false },
@@ -126,10 +140,10 @@ fun VideoQualityButton(
) { ) {
VideoQualityChoices( VideoQualityChoices(
videoGroup = videoGroup, videoGroup = videoGroup,
currentShortSide = getCurrentPlayingShortSide(tracks), currentShortSide = currentShortSide,
isAuto = !hasVideoOverride(player), isAuto = !hasVideoOverride(player),
onSelectAuto = { onSelectAuto = {
clearVideoOverride(player) if (hasVideoOverride(player)) clearVideoOverride(player)
openDialog = false openDialog = false
}, },
onSelectTrack = { trackIndex -> onSelectTrack = { trackIndex ->
@@ -195,6 +209,7 @@ private data class QualityChoice(
private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> { private fun buildQualityChoices(group: Tracks.Group): ImmutableList<QualityChoice> {
val choices = mutableListOf<QualityChoice>() val choices = mutableListOf<QualityChoice>()
for (i in 0 until group.length) { for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i) val format = group.getTrackFormat(i)
val shortSide = minOf(format.width, format.height) val shortSide = minOf(format.width, format.height)
if (shortSide > 0) { 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) 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) 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()
}
@@ -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()
}
@@ -34,6 +34,8 @@ import androidx.compose.runtime.remember
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING 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.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.GetMediaItem
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData
@@ -50,6 +52,12 @@ class PipVideoActivity : ComponentActivity() {
GetMediaItem(mediaItemData) { mediaItem -> GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState -> 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) RegisterBackgroundMedia(controllerState)
RegisterControllerReceiver(controllerState) RegisterControllerReceiver(controllerState)
WatchControllerForActions(mediaItemData, controllerState) WatchControllerForActions(mediaItemData, controllerState)
@@ -570,6 +570,7 @@ private fun RenderImageOrVideo(
automaticallyStartPlayback = true, automaticallyStartPlayback = true,
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null, hasBlurhash = content.blurhash != null,
isFullscreen = true,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} }
@@ -630,6 +631,7 @@ private fun RenderImageOrVideo(
automaticallyStartPlayback = true, automaticallyStartPlayback = true,
controllerVisible = controllerVisible, controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null, hasBlurhash = content.blurhash != null,
isFullscreen = true,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} }