Merge pull request #2584 from vitorpamplona/claude/optimize-video-loading-5opqr
Optimize video playback with warm player pool and buffer tuning
This commit is contained in:
@@ -525,10 +525,16 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// initializes diskcache on an IO thread.
|
||||
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
|
||||
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
|
||||
// few hundred ms on a populated 4 GB cache — so leaving it for the first session's
|
||||
// onGetSession would do that work on the main thread. The short delay keeps the IO
|
||||
// dispatcher free for the urgent first-paint work above (account load, image loader,
|
||||
// ui state, robohash) while still landing the warmup well before a typical user can
|
||||
// scroll to and tap a video. The previous 10 s delay was long enough that a fast user
|
||||
// (or a deep link) could lose the lazy { } race and trigger main-thread init.
|
||||
applicationIOScope.launch {
|
||||
// Prepares video cache later
|
||||
delay(10_000)
|
||||
delay(1_500)
|
||||
videoCache
|
||||
}
|
||||
}
|
||||
|
||||
+32
-13
@@ -21,10 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
@@ -49,25 +50,43 @@ fun GetVideoController(
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
|
||||
|
||||
if (BackgroundMedia.isPlaying()) {
|
||||
// There is a video playing, start this one on mute.
|
||||
state.controller.volume = 0f
|
||||
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying")
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
state.controller.volume = if (muted) 0f else 1f
|
||||
Log.d("PlaybackService") { "OnEach $muted" }
|
||||
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
|
||||
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
|
||||
// Read first and only push an IPC if the value actually needs to change —
|
||||
// with several feed videos preloading at once each volume= write was a
|
||||
// round-trip to the service for nothing.
|
||||
val targetVolume =
|
||||
when {
|
||||
BackgroundMedia.isPlaying() -> 0f
|
||||
muted -> 0f
|
||||
else -> 1f
|
||||
}
|
||||
if (state.controller.volume != targetVolume) {
|
||||
state.controller.volume = targetVolume
|
||||
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
|
||||
}
|
||||
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
|
||||
// buffer for this exact MediaItem, the MediaController's local mirror already
|
||||
// shows the matching mediaId. Calling setMediaItem in that case would reset the
|
||||
// player and discard the buffer — exactly what the warm pool exists to avoid.
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
}
|
||||
}
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
}.collectAsState(null)
|
||||
|
||||
controllerState?.let {
|
||||
inner(it)
|
||||
|
||||
+43
-45
@@ -29,7 +29,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(
|
||||
@@ -45,56 +52,47 @@ fun LoadThumbAndThenVideoView(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDialog: (() -> Unit)? = null,
|
||||
) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
var loadingFinished by remember(thumbUri) { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
accountViewModel.loadThumb(
|
||||
context,
|
||||
thumbUri,
|
||||
onReady = {
|
||||
loadingFinished =
|
||||
if (it != null) {
|
||||
Pair(true, it)
|
||||
} else {
|
||||
Pair(true, null)
|
||||
// Run the Coil fetch in this LaunchedEffect's scope (was previously launched into the
|
||||
// AccountViewModel's viewModelScope, which meant a scroll-away wouldn't cancel the in-flight
|
||||
// image request — wasted bandwidth, plus the late callback wrote into a state that no
|
||||
// longer mattered). Keying on thumbUri also makes the effect re-fire when a recycled slot
|
||||
// gets a new audio track with a new cover instead of stalling on the stale Pair(true, ...).
|
||||
LaunchedEffect(thumbUri) {
|
||||
loadingFinished =
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val drawable =
|
||||
withContext(Dispatchers.IO) {
|
||||
context.imageLoader
|
||||
.execute(request)
|
||||
.image
|
||||
?.asDrawable(context.resources)
|
||||
}
|
||||
},
|
||||
onError = { loadingFinished = Pair(true, null) },
|
||||
)
|
||||
Pair(true, drawable)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
Pair(true, null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = VideoThumb(loadingFinished.second),
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
isLiveStream = isLiveStream,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog,
|
||||
)
|
||||
} else {
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = null,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
isLiveStream = isLiveStream,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog,
|
||||
)
|
||||
}
|
||||
VideoView(
|
||||
videoUri = videoUri,
|
||||
mimeType = mimeType,
|
||||
title = title,
|
||||
thumb = loadingFinished.second?.let { VideoThumb(it) },
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
artworkUri = thumbUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
isLiveStream = isLiveStream,
|
||||
accountViewModel = accountViewModel,
|
||||
onDialog = onDialog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -31,14 +31,13 @@ import kotlin.uuid.Uuid
|
||||
class MediaControllerState(
|
||||
// each composable has an ID.
|
||||
val id: String = Uuid.random().toString(),
|
||||
// This is filled after the controller returns from this class
|
||||
var controller: Player,
|
||||
val controller: Player,
|
||||
// visibility onscreen
|
||||
val visibility: VisibilityData = VisibilityData(),
|
||||
) {
|
||||
fun isPlaying() = controller.isPlaying
|
||||
|
||||
fun currrentMedia() = controller.currentMediaItem?.mediaId
|
||||
fun currentMedia() = controller.currentMediaItem?.mediaId
|
||||
|
||||
fun toggleMute() {
|
||||
controller.volume = if (controller.volume == 0f) 1f else 0f
|
||||
|
||||
+7
-5
@@ -34,7 +34,6 @@ import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.ui.compose.ContentFrame
|
||||
@@ -90,19 +89,22 @@ fun RenderVideoPlayer(
|
||||
hasBlurhash: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val containerSize = remember { mutableStateOf(IntSize.Zero) }
|
||||
val isLive = isLiveStreaming(mediaItem.src.videoUri)
|
||||
// Hold the container size in a non-state holder so layout passes don't trigger an
|
||||
// unnecessary recomposition of the whole player tree just to update a value that is only
|
||||
// ever read inside the onDoubleTap callback below.
|
||||
val containerWidth = remember { intArrayOf(0) }
|
||||
val isLive = remember(mediaItem.src.videoUri) { isLiveStreaming(mediaItem.src.videoUri) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
borderModifier
|
||||
.onSizeChanged { containerSize.value = it }
|
||||
.onSizeChanged { containerWidth[0] = it.width }
|
||||
.pointerInput(isLive, controllerState) {
|
||||
detectTapGestures(
|
||||
onTap = { controllerVisible.value = !controllerVisible.value },
|
||||
onDoubleTap = { offset ->
|
||||
if (!isLive) {
|
||||
val isLeftSide = offset.x < containerSize.value.width / 2
|
||||
val isLeftSide = offset.x < containerWidth[0] / 2
|
||||
if (isLeftSide) {
|
||||
controllerState.controller.seekBackward()
|
||||
} else {
|
||||
|
||||
+20
-5
@@ -105,15 +105,32 @@ fun VideoView(
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
|
||||
val automaticallyStartPlayback = remember { mutableStateOf(initialAutoStart) }
|
||||
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
|
||||
// doesn't inherit "tapped to show" state from a prior video.
|
||||
val automaticallyStartPlayback = remember(videoUri) { mutableStateOf(initialAutoStart) }
|
||||
|
||||
// Once the video is being shown, only honor the user's autoplay preference when it was auto-loaded.
|
||||
// If the user manually tapped the download button, they want it to play.
|
||||
val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value)
|
||||
|
||||
if (blurhash == null && thumbhash == null) {
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
|
||||
// Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta
|
||||
// dim tag so the next time this video appears (PiP, dialog, list re-enter) the cache hits
|
||||
// without waiting for ExoPlayer's onVideoSizeChanged. Keys are primitive width/height so
|
||||
// a freshly parsed DimensionTag instance for the same event doesn't re-run this lambda —
|
||||
// DimensionTag uses reference equality, not structural.
|
||||
val dimW = dimensions?.width
|
||||
val dimH = dimensions?.height
|
||||
val ratio =
|
||||
remember(videoUri, dimW, dimH) {
|
||||
if (dimW != null && dimH != null && dimW > 0 && dimH > 0) {
|
||||
MediaAspectRatioCache.add(videoUri, dimW, dimH)
|
||||
dimW.toFloat() / dimH.toFloat()
|
||||
} else {
|
||||
MediaAspectRatioCache.get(videoUri)
|
||||
}
|
||||
}
|
||||
|
||||
if (blurhash == null && thumbhash == null) {
|
||||
val modifier =
|
||||
if (ratio != null && automaticallyStartPlayback.value) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
@@ -149,8 +166,6 @@ fun VideoView(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
|
||||
|
||||
val modifier =
|
||||
if (ratio != null) {
|
||||
Modifier.aspectRatio(ratio)
|
||||
|
||||
+6
-1
@@ -59,6 +59,11 @@ fun VideoViewInner(
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value }
|
||||
|
||||
// The proxy port is decided once per video URI; recomputing on every recomposition does
|
||||
// pointless work (the result is anyway dropped because GetMediaItem.remember is keyed on the
|
||||
// URI alone, so the cached MediaItemData is locked in on the first frame).
|
||||
val proxyPort = remember(videoUri) { accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri) }
|
||||
|
||||
GetMediaItem(
|
||||
videoUri = videoUri,
|
||||
title = title,
|
||||
@@ -67,7 +72,7 @@ fun VideoViewInner(
|
||||
callbackUri = nostrUriCallback,
|
||||
mimeType = mimeType,
|
||||
aspectRatio = aspectRatio,
|
||||
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri),
|
||||
proxyPort = proxyPort,
|
||||
keepPlaying = true,
|
||||
waveformData = waveform,
|
||||
isLiveStream = isLiveStream,
|
||||
|
||||
+23
-14
@@ -38,24 +38,33 @@ import androidx.compose.ui.unit.dp
|
||||
private val FadeIn = fadeIn()
|
||||
private val FadeOut = fadeOut()
|
||||
|
||||
private val TopGradientColors =
|
||||
listOf(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
Color.Transparent,
|
||||
// Both gradient brushes are static; pre-build them once at class init so we don't allocate a
|
||||
// new Brush on every recomposition while the controllers are visible (which is most of the
|
||||
// time during playback / interaction).
|
||||
private val TopGradientBrush =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Black.copy(alpha = 0.3f),
|
||||
Color.Transparent,
|
||||
),
|
||||
)
|
||||
|
||||
private val BottomGradientColors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.4f),
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
private val BottomGradientBrush =
|
||||
Brush.verticalGradient(
|
||||
colors =
|
||||
listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.4f),
|
||||
Color.Black.copy(alpha = 0.7f),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun GradientOverlay(
|
||||
controllerVisible: State<Boolean>,
|
||||
colors: List<Color>,
|
||||
brush: Brush,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -70,7 +79,7 @@ private fun GradientOverlay(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.background(brush = Brush.verticalGradient(colors = colors)),
|
||||
.background(brush = brush),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -80,11 +89,11 @@ fun TopGradientOverlay(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 80.dp,
|
||||
) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier)
|
||||
) = GradientOverlay(controllerVisible, TopGradientBrush, height, modifier)
|
||||
|
||||
@Composable
|
||||
fun BottomGradientOverlay(
|
||||
controllerVisible: State<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 120.dp,
|
||||
) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier)
|
||||
) = GradientOverlay(controllerVisible, BottomGradientBrush, height, modifier)
|
||||
|
||||
+5
-6
@@ -47,9 +47,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@@ -79,11 +77,12 @@ fun MuteButton(
|
||||
)
|
||||
}
|
||||
|
||||
// LaunchedEffect already runs on Main, and delay() suspends without holding a thread, so
|
||||
// the previous launch(Dispatchers.IO) was just unnecessary dispatcher hopping for a state
|
||||
// mutation that's also fine on Main.
|
||||
LaunchedEffect(key1 = controllerVisible) {
|
||||
launch(Dispatchers.IO) {
|
||||
delay(2000)
|
||||
holdOn.value = false
|
||||
}
|
||||
delay(2000)
|
||||
holdOn.value = false
|
||||
}
|
||||
|
||||
val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) }
|
||||
|
||||
+5
-3
@@ -50,6 +50,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
private val FadeIn = fadeIn()
|
||||
private val FadeOut = fadeOut()
|
||||
@@ -60,7 +62,7 @@ fun OverflowMenuButtonPreview() {
|
||||
ThemeComparisonColumn {
|
||||
Box(Modifier.background(BitcoinOrange)) {
|
||||
OverflowMenuButton(
|
||||
actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture),
|
||||
actions = persistentListOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture),
|
||||
startingMuteState = false,
|
||||
onFullscreenClick = {},
|
||||
onMuteClick = {},
|
||||
@@ -76,7 +78,7 @@ fun OverflowMenuButtonPreview() {
|
||||
@Composable
|
||||
fun AnimatedOverflowMenuButton(
|
||||
controllerVisible: State<Boolean>,
|
||||
actions: List<VideoPlayerAction>,
|
||||
actions: ImmutableList<VideoPlayerAction>,
|
||||
startingMuteState: Boolean,
|
||||
onFullscreenClick: (() -> Unit)?,
|
||||
onMuteClick: () -> Unit,
|
||||
@@ -107,7 +109,7 @@ fun AnimatedOverflowMenuButton(
|
||||
|
||||
@Composable
|
||||
fun OverflowMenuButton(
|
||||
actions: List<VideoPlayerAction>,
|
||||
actions: ImmutableList<VideoPlayerAction>,
|
||||
startingMuteState: Boolean,
|
||||
onFullscreenClick: (() -> Unit)?,
|
||||
onMuteClick: () -> Unit,
|
||||
|
||||
+7
-1
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
@@ -105,7 +106,7 @@ fun RenderTopButtons(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isLive = isLiveStreaming(mediaData.videoUri)
|
||||
val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
|
||||
val pipSupported =
|
||||
remember {
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
@@ -212,17 +213,22 @@ fun RenderTopButtons(
|
||||
}
|
||||
|
||||
val canFullscreen = onZoomClick != null
|
||||
// ImmutableList so Compose can treat the action lists as stable parameters when they're
|
||||
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
|
||||
// overflow tree to recompose whenever any unrelated parent state ticks.
|
||||
val topBarActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
buttonItems
|
||||
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
|
||||
.map { it.action }
|
||||
.toImmutableList()
|
||||
}
|
||||
val overflowActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
buttonItems
|
||||
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
|
||||
.map { it.action }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
Row(modifier) {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ fun RenderPipVideo(
|
||||
val modifier =
|
||||
remember {
|
||||
val ratio =
|
||||
controller.currrentMedia()?.let {
|
||||
controller.currentMedia()?.let {
|
||||
MediaAspectRatioCache.get(it)
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -24,6 +24,7 @@ import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.DefaultLoadControl
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -42,10 +43,35 @@ class ExoPlayerBuilder(
|
||||
.Builder(context)
|
||||
.apply {
|
||||
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
|
||||
setLoadControl(feedTunedLoadControl())
|
||||
}.build()
|
||||
.apply {
|
||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||
addListener(KeepVideosPlaying(this))
|
||||
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache))
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Default DefaultLoadControl buffers 50s ahead before slowing down. Every visible video
|
||||
// in the feed is prepared eagerly so it's ready when the user scrolls to it; with the
|
||||
// default settings 5 simultaneous preloads would fight for ~250s of buffer between them
|
||||
// and chew through ~30+ MB per HD player. Feed playback is optimized for "the active
|
||||
// video plays smoothly while a few neighbours stay warm," so we cap the buffer at ~15s
|
||||
// and let playback kick in as soon as ~750 ms is buffered. Fullscreen still gets a
|
||||
// healthy buffer because seeks-within-15s are virtually instant from disk cache.
|
||||
private fun feedTunedLoadControl() =
|
||||
DefaultLoadControl
|
||||
.Builder()
|
||||
.setBufferDurationsMs(
|
||||
// minBufferMs =
|
||||
10_000,
|
||||
// maxBufferMs =
|
||||
15_000,
|
||||
// bufferForPlaybackMs =
|
||||
750,
|
||||
// bufferForPlaybackAfterRebufferMs =
|
||||
2_000,
|
||||
).setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
+152
-29
@@ -34,16 +34,43 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.yield
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class ExoPlayerPool(
|
||||
val builder: ExoPlayerBuilder,
|
||||
private val poolSize: Int,
|
||||
// Requested ceiling on paused-with-buffer players retained across releases. Each retained
|
||||
// player keeps its decoder and LoadControl buffer alive, which costs both memory and a
|
||||
// MediaCodec instance, so warm slots count against the same [poolSize] codec budget as
|
||||
// cold players (see [warmSlotsCap]). Default 3 keeps the most recent few feed videos hot
|
||||
// for scroll-back without monopolizing the device's decoder pool.
|
||||
requestedWarmSlots: Int = DEFAULT_WARM_SLOTS,
|
||||
) {
|
||||
private val playerPool = ConcurrentLinkedQueue<ExoPlayer>()
|
||||
// Cap warm slots at poolSize-1 so there's always at least one slot available for a cold
|
||||
// (cleared) player; otherwise a feed full of unique URIs would starve the cold pool and
|
||||
// every new URI would force a fresh ExoPlayer build.
|
||||
private val warmSlotsCap = requestedWarmSlots.coerceAtMost((poolSize - 1).coerceAtLeast(0))
|
||||
|
||||
// Idle players that have been stop()'d and clearMediaItems()'d — ready to be re-prepared
|
||||
// with any URI. Maintained as a FIFO so the oldest cleared instance is reused first.
|
||||
private val coldPool = ConcurrentLinkedQueue<ExoPlayer>()
|
||||
private val poolStartingSize = 3
|
||||
|
||||
// Most-recent paused players, indexed by the mediaId of the MediaItem they still hold.
|
||||
// ArrayDeque is used as an LRU: head = oldest, tail = newest. Access is guarded by
|
||||
// [warmPoolLock] (a plain monitor, since both acquire and release callers run on the
|
||||
// service's main thread but we don't want to require the suspending [mutex] in acquire).
|
||||
private data class WarmPlayer(
|
||||
val mediaId: String,
|
||||
val player: ExoPlayer,
|
||||
)
|
||||
|
||||
private val warmPool = ArrayDeque<WarmPlayer>(warmSlotsCap.coerceAtLeast(1))
|
||||
private val warmPoolLock = Any()
|
||||
|
||||
// Exists to avoid exceptions stopping the coroutine
|
||||
val exceptionHandler =
|
||||
CoroutineExceptionHandler { _, throwable ->
|
||||
@@ -54,21 +81,63 @@ class ExoPlayerPool(
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Guards against firing the warmup more than once if create() is called repeatedly
|
||||
// (e.g. on reconfiguration or when both pools share a startup hook).
|
||||
private val warmupStarted = AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Pre-warms the pool with [poolStartingSize] ExoPlayer instances on the main looper, yielding
|
||||
* between each build so the warmup is spread across frames instead of stalling the UI in one
|
||||
* burst. ExoPlayer must be constructed on the same thread that will operate it (the main
|
||||
* thread for this pool), so we cannot fan out across IO threads here. Idempotent — additional
|
||||
* calls are no-ops.
|
||||
*/
|
||||
fun create(context: Context) {
|
||||
while (playerPool.size < poolStartingSize) {
|
||||
playerPool.offer(builder.build(context))
|
||||
if (!warmupStarted.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
while (coldPool.size < poolStartingSize) {
|
||||
coldPool.offer(builder.build(context))
|
||||
// Hand the frame back so an in-flight onGetSession / acquirePlayer / layout
|
||||
// pass isn't blocked behind the next build.
|
||||
yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acquirePlayer(context: Context): ExoPlayer {
|
||||
if (playerPool.isEmpty()) {
|
||||
// If the pool is empty, create a new player (or handle it differently)
|
||||
return builder.build(context)
|
||||
/**
|
||||
* Acquire a player. When [preferredMediaId] matches a warm entry, returns that player intact
|
||||
* — it still holds its MediaItem and any populated LoadControl buffer, so the caller can
|
||||
* skip [androidx.media3.common.Player.setMediaItem] / [androidx.media3.common.Player.prepare]
|
||||
* and resume immediately. Falls back to a cold (cleared) player or a freshly built one.
|
||||
*/
|
||||
fun acquirePlayer(
|
||||
context: Context,
|
||||
preferredMediaId: String? = null,
|
||||
): ExoPlayer {
|
||||
if (preferredMediaId != null) {
|
||||
val warm = takeWarm(preferredMediaId)
|
||||
if (warm != null) {
|
||||
Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" }
|
||||
return warm
|
||||
}
|
||||
}
|
||||
|
||||
return playerPool.poll() ?: builder.build(context)
|
||||
return coldPool.poll() ?: builder.build(context)
|
||||
}
|
||||
|
||||
private fun takeWarm(mediaId: String): ExoPlayer? =
|
||||
synchronized(warmPoolLock) {
|
||||
// Iterate from the newest end so a duplicated URI returns the freshest player.
|
||||
val it = warmPool.listIterator(warmPool.size)
|
||||
while (it.hasPrevious()) {
|
||||
val entry = it.previous()
|
||||
if (entry.mediaId == mediaId) {
|
||||
it.remove()
|
||||
return@synchronized entry.player
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
fun releasePlayerAsync(player: ExoPlayer) {
|
||||
scope.launch {
|
||||
releasePlayer(player)
|
||||
@@ -77,27 +146,70 @@ class ExoPlayerPool(
|
||||
|
||||
suspend fun releasePlayer(player: ExoPlayer) {
|
||||
mutex.withLock {
|
||||
if (!player.isReleased) {
|
||||
if (player.isReleased) return@withLock
|
||||
|
||||
val mediaId = player.currentMediaItem?.mediaId
|
||||
if (mediaId != null && warmSlotsCap > 0) {
|
||||
// Warm path: keep the player paused but loaded so a quick scroll-back to the
|
||||
// same video resumes from the existing buffer instead of re-fetching from disk
|
||||
// cache and re-priming the decoder.
|
||||
player.pause()
|
||||
player.stop()
|
||||
player.clearVideoSurface()
|
||||
player.clearMediaItems()
|
||||
|
||||
// Clear any video quality overrides so the next video starts with Auto
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
.build()
|
||||
|
||||
if (playerPool.size < poolSize) {
|
||||
if (!playerPool.contains(player)) {
|
||||
playerPool.add(player)
|
||||
}
|
||||
} else {
|
||||
player.release() // Release if pool is full.
|
||||
val evicted = pushWarm(mediaId, player)
|
||||
if (evicted != null) {
|
||||
Log.d("PlaybackService") { "ExoPlayerPool warm evict: ${evicted.mediaId}" }
|
||||
demoteToCold(evicted.player)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
demoteToCold(player)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushWarm(
|
||||
mediaId: String,
|
||||
player: ExoPlayer,
|
||||
): WarmPlayer? =
|
||||
synchronized(warmPoolLock) {
|
||||
// If the same URI is already warm (rare — duplicate VideoView in another scroller),
|
||||
// drop the older entry so it can be demoted; the freshest copy wins.
|
||||
val duplicate = warmPool.indexOfFirst { it.mediaId == mediaId }
|
||||
val displaced =
|
||||
if (duplicate >= 0) {
|
||||
warmPool.removeAt(duplicate)
|
||||
} else if (warmPool.size >= warmSlotsCap) {
|
||||
warmPool.removeFirst()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
warmPool.addLast(WarmPlayer(mediaId, player))
|
||||
displaced
|
||||
}
|
||||
|
||||
private fun demoteToCold(player: ExoPlayer) {
|
||||
if (player.isReleased) return
|
||||
player.pause()
|
||||
player.stop()
|
||||
player.clearVideoSurface()
|
||||
player.clearMediaItems()
|
||||
|
||||
// Clear any video quality overrides so the next video starts with Auto
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.clearOverridesOfType(C.TRACK_TYPE_VIDEO)
|
||||
.build()
|
||||
|
||||
// Total idle (cold + warm) must respect the device-derived poolSize cap so we don't
|
||||
// exceed the MediaCodec instance budget. Warm slots get first dibs; cold gets the rest.
|
||||
val warmSize = synchronized(warmPoolLock) { warmPool.size }
|
||||
val coldCap = (poolSize - warmSize).coerceAtLeast(0)
|
||||
if (coldPool.size < coldCap) {
|
||||
if (!coldPool.contains(player)) {
|
||||
coldPool.add(player)
|
||||
}
|
||||
} else {
|
||||
player.release() // Release if pool is full.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +217,22 @@ class ExoPlayerPool(
|
||||
scope
|
||||
.launch {
|
||||
mutex.withLock {
|
||||
playerPool.forEach { it.release() }
|
||||
playerPool.clear()
|
||||
val warmSnapshot =
|
||||
synchronized(warmPoolLock) {
|
||||
val copy = warmPool.toList()
|
||||
warmPool.clear()
|
||||
copy
|
||||
}
|
||||
warmSnapshot.forEach { it.player.release() }
|
||||
coldPool.forEach { it.release() }
|
||||
coldPool.clear()
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_WARM_SLOTS = 3
|
||||
}
|
||||
}
|
||||
|
||||
+40
-20
@@ -37,13 +37,14 @@ import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class SessionListener(
|
||||
val session: MediaSession,
|
||||
@@ -72,7 +73,22 @@ class MediaSessionPool(
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
|
||||
|
||||
val globalCallback = MediaSessionCallback(this, appContext)
|
||||
var lastCleanup = TimeUtils.now()
|
||||
|
||||
// Last cleanup timestamp in nanos, guarded by CAS so concurrent releaseSession() calls
|
||||
// can't all win the time check and each launch a redundant scope.launch sweep.
|
||||
private val lastCleanupNs = AtomicLong(System.nanoTime())
|
||||
|
||||
// The bitmap loader is stateless w.r.t. the session; a fresh allocation per session was
|
||||
// pure noise. ExoPlayer's DEFAULT_EXECUTOR_SERVICE is a process-wide singleton, the
|
||||
// dataSourceFactory is owned by the pool, and the appContext is already retained.
|
||||
@OptIn(UnstableApi::class)
|
||||
private val sharedBitmapLoader by lazy {
|
||||
DataSourceBitmapLoader
|
||||
.Builder(appContext)
|
||||
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
|
||||
.setDataSourceFactory(dataSourceFactory)
|
||||
.build()
|
||||
}
|
||||
|
||||
// protects from LruCache killing playing sessions
|
||||
private val playingMap = mutableMapOf<String, SessionListener>()
|
||||
@@ -102,18 +118,16 @@ class MediaSessionPool(
|
||||
id: String,
|
||||
keepPlaying: Boolean,
|
||||
context: Context,
|
||||
// Best-effort affinity hint: when the pool still has a paused player carrying this
|
||||
// exact mediaId (matches MediaItem.mediaId, which is the videoUri), the warm player
|
||||
// is reused so the populated buffer survives. Null falls back to a cold acquire.
|
||||
preferredMediaId: String?,
|
||||
): MediaSession {
|
||||
val mediaSession =
|
||||
MediaSession
|
||||
.Builder(context, exoPlayerPool.acquirePlayer(context))
|
||||
.Builder(context, exoPlayerPool.acquirePlayer(context, preferredMediaId))
|
||||
.apply {
|
||||
setBitmapLoader(
|
||||
DataSourceBitmapLoader
|
||||
.Builder(context)
|
||||
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
|
||||
.setDataSourceFactory(dataSourceFactory)
|
||||
.build(),
|
||||
)
|
||||
setBitmapLoader(sharedBitmapLoader)
|
||||
setId(id)
|
||||
setCallback(globalCallback)
|
||||
}.build()
|
||||
@@ -142,16 +156,17 @@ class MediaSessionPool(
|
||||
}
|
||||
|
||||
fun cleanupUnused() {
|
||||
if (lastCleanup < TimeUtils.oneMinuteAgo()) {
|
||||
lastCleanup = TimeUtils.now()
|
||||
scope.launch {
|
||||
val snap = cache.snapshot()
|
||||
snap.values.forEach {
|
||||
if (it.session.connectedControllers.isEmpty()) {
|
||||
releaseSession(it.session)
|
||||
}
|
||||
val now = System.nanoTime()
|
||||
val previous = lastCleanupNs.get()
|
||||
if (now - previous < CLEANUP_INTERVAL_NS) return
|
||||
// CAS so only one caller actually launches the sweep when many releases fire at once.
|
||||
if (!lastCleanupNs.compareAndSet(previous, now)) return
|
||||
scope.launch {
|
||||
val snap = cache.snapshot()
|
||||
snap.values.forEach {
|
||||
if (it.session.connectedControllers.isEmpty()) {
|
||||
releaseSession(it.session)
|
||||
}
|
||||
lastCleanup = TimeUtils.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,13 +190,14 @@ class MediaSessionPool(
|
||||
id: String,
|
||||
keepPlaying: Boolean,
|
||||
context: Context,
|
||||
preferredMediaId: String? = null,
|
||||
): MediaSession {
|
||||
val existingSession = playingMap.get(id) ?: cache.get(id)
|
||||
if (existingSession != null) {
|
||||
return existingSession.session
|
||||
}
|
||||
|
||||
return newSession(id, keepPlaying, context)
|
||||
return newSession(id, keepPlaying, context, preferredMediaId)
|
||||
}
|
||||
|
||||
fun playingContent() = playingMap.values
|
||||
@@ -234,4 +250,8 @@ class MediaSessionPool(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CLEANUP_INTERVAL_NS = TimeUnit.MINUTES.toNanos(1)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -66,7 +66,14 @@ class CurrentPlayPositionCacher(
|
||||
Player.STATE_READY -> {
|
||||
if (!isLiveStreaming) {
|
||||
cache.get(uri)?.let { lastPosition ->
|
||||
if (abs(player.currentPosition - lastPosition) > 5 * 60) {
|
||||
// Restore the saved position only if it's meaningfully far from
|
||||
// the player's current position. Position values are in
|
||||
// milliseconds, so the previous `5 * 60` constant was a 300 ms
|
||||
// threshold — small enough to trigger a seek (and an extra buffer
|
||||
// flush right at playback start) for almost any saved position.
|
||||
// 5 s gives the user a perceptible "resumed where I left off"
|
||||
// without forcing a re-seek for trivially short clips.
|
||||
if (abs(player.currentPosition - lastPosition) > 5_000) {
|
||||
player.seekTo(lastPosition)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-6
@@ -105,7 +105,15 @@ class PlaybackService : MediaSessionService() {
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
// creates new
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver)
|
||||
.also {
|
||||
poolNoProxy = it
|
||||
// Kick off the player pool warmup as soon as we know this pool is being used.
|
||||
// It runs async on the main looper, yielding between builds, so the very first
|
||||
// session still acquires synchronously while subsequent ones can grab a warm
|
||||
// ExoPlayer instead of paying the build cost on the main thread.
|
||||
it.exoPlayerPool.create(applicationContext)
|
||||
}
|
||||
} else {
|
||||
poolWithProxy?.let { return it }
|
||||
|
||||
@@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() {
|
||||
val videoCache = Amethyst.instance.videoCache
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver)
|
||||
.also {
|
||||
poolWithProxy = it
|
||||
it.exoPlayerPool.create(applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,23 +173,27 @@ class PlaybackService : MediaSessionService() {
|
||||
return
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying && it.session.player.volume > 0) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playing.forEachIndexed { idx, it ->
|
||||
// Falls through to the first muted-but-playing session. Earlier this loop missed
|
||||
// its return and called super.onUpdateNotification once per playing session,
|
||||
// hammering the notification system whenever multiple feed videos were preloading.
|
||||
playing.forEach {
|
||||
if (it.session.player.isPlaying) {
|
||||
super.onUpdateNotification(it.session, startInForegroundRequired)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +204,17 @@ class PlaybackService : MediaSessionService() {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
|
||||
val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true)
|
||||
// Optional warm-pool affinity hint: when the pool still has a paused ExoPlayer
|
||||
// holding this exact URI, the new session reuses it so the buffer survives.
|
||||
val preferredMediaId = controllerInfo.connectionHints.getString(HINT_VIDEO_URI)
|
||||
val manager = lazyPool(proxyPort)
|
||||
return manager.getSession(id, keepPlaying, applicationContext)
|
||||
return manager.getSession(id, keepPlaying, applicationContext, preferredMediaId)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val HINT_ID = "id"
|
||||
const val HINT_PROXY_PORT = "proxyPort"
|
||||
const val HINT_KEEP_PLAYING = "keepPlaying"
|
||||
const val HINT_VIDEO_URI = "videoUri"
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -36,7 +36,15 @@ import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
object PlaybackServiceClient {
|
||||
val executorService: ExecutorService = Executors.newCachedThreadPool()
|
||||
// Runs the MediaController.buildAsync() completion callbacks. The work per callback is
|
||||
// trivial in the steady state (Future.get() on an already-completed future + a non-blocking
|
||||
// trySend into this video's own callbackFlow channel), so the IPC bind itself dominates and
|
||||
// happens on Media3's own threads regardless. We size the pool small enough to stay bounded
|
||||
// under churn but parallel enough that one stuck listener (e.g. the defensive 5s get()
|
||||
// timeout actually firing) can't stall the rest of the videos onscreen behind it. The
|
||||
// original newCachedThreadPool was unbounded and could spin up a thread per concurrent
|
||||
// video, each lingering for the 60s keep-alive afterwards.
|
||||
val executorService: ExecutorService = Executors.newFixedThreadPool(4)
|
||||
|
||||
fun shutdown() {
|
||||
executorService.shutdown()
|
||||
@@ -56,11 +64,15 @@ object PlaybackServiceClient {
|
||||
Bundle().apply {
|
||||
// link the id with the client's id to make sure it can return the
|
||||
// same session on background media.
|
||||
putString("id", id)
|
||||
putBoolean("keepPlaying", keepPlaying)
|
||||
putString(PlaybackService.HINT_ID, id)
|
||||
putBoolean(PlaybackService.HINT_KEEP_PLAYING, keepPlaying)
|
||||
proxyPort?.let {
|
||||
putInt("proxyPort", it)
|
||||
putInt(PlaybackService.HINT_PROXY_PORT, it)
|
||||
}
|
||||
// Carry the URI so the service can ask the player pool for an existing warm
|
||||
// (paused-with-buffer) ExoPlayer that already holds this MediaItem. Falls back
|
||||
// gracefully — if no warm match exists, the pool returns a cold player.
|
||||
putString(PlaybackService.HINT_VIDEO_URI, videoUri)
|
||||
}
|
||||
|
||||
val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
|
||||
|
||||
@@ -68,6 +68,10 @@ fun GifVideoView(
|
||||
accountViewModel: AccountViewModel,
|
||||
thumbhash: String? = null,
|
||||
) {
|
||||
// Pure read path — DimensionTag.aspectRatio() is a one-line int division and
|
||||
// MediaAspectRatioCache.get() is a synchronized LruCache lookup. Wrapping this in
|
||||
// remember() to avoid the recompute would cost more (slot read + N equality checks)
|
||||
// than the work it saves; that's why this stays as a plain expression.
|
||||
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
|
||||
val autoPlay = accountViewModel.settings.autoPlayVideos()
|
||||
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
|
||||
|
||||
-27
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.LruCache
|
||||
@@ -34,9 +33,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.asDrawable
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
@@ -1578,29 +1574,6 @@ class AccountViewModel(
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun loadThumb(
|
||||
context: Context,
|
||||
thumbUri: String,
|
||||
onReady: (Drawable?) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val myCover =
|
||||
context.imageLoader
|
||||
.execute(request)
|
||||
.image
|
||||
?.asDrawable(context.resources)
|
||||
onReady(myCover)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
onError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMentions(
|
||||
mentions: ImmutableList<String>,
|
||||
onReady: (ImmutableList<User>) -> Unit,
|
||||
|
||||
Reference in New Issue
Block a user