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:
Vitor Pamplona
2026-04-26 10:29:35 -04:00
committed by GitHub
20 changed files with 438 additions and 187 deletions
@@ -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
}
}
@@ -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)
@@ -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,
)
}
}
@@ -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
@@ -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 {
@@ -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)
@@ -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,
@@ -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)
@@ -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) }
@@ -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,
@@ -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) {
@@ -58,7 +58,7 @@ fun RenderPipVideo(
val modifier =
remember {
val ratio =
controller.currrentMedia()?.let {
controller.currentMedia()?.let {
MediaAspectRatioCache.get(it)
}
@@ -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()
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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
@@ -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,