mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
feat(video): default feed/PiP to lowest HLS resolution, fullscreen to auto
This commit is contained in:
+6
@@ -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<Boolean> = 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(
|
||||
|
||||
+96
@@ -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<String?>(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<String?>,
|
||||
) {
|
||||
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
|
||||
}
|
||||
+40
-10
@@ -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()
|
||||
}
|
||||
|
||||
+15
-26
@@ -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()
|
||||
}
|
||||
|
||||
+7
@@ -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)
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user