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 { applicationIOScope.launch {
// Prepares video cache later delay(1_500)
delay(10_000)
videoCache videoCache
} }
} }
@@ -21,10 +21,11 @@
package com.vitorpamplona.amethyst.service.playback.composable package com.vitorpamplona.amethyst.service.playback.composable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext 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.composable.mediaitem.LoadedMediaItem
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
@@ -49,25 +50,43 @@ fun GetVideoController(
).onEach { state -> ).onEach { state ->
Log.d("PlaybackService") { "Controller instance: ${state.controller}" } Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
if (BackgroundMedia.isPlaying()) { // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
// There is a video playing, start this one on mute. // sets it to 0f when the player is acquired, so the controller arrives at 0f.
state.controller.volume = 0f // Read first and only push an IPC if the value actually needs to change —
Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying") // with several feed videos preloading at once each volume= write was a
} else { // round-trip to the service for nothing.
// There is no other video playing. Use the default mute state to val targetVolume =
// decide if sound is on or not. when {
state.controller.volume = if (muted) 0f else 1f BackgroundMedia.isPlaying() -> 0f
Log.d("PlaybackService") { "OnEach $muted" } muted -> 0f
else -> 1f
}
if (state.controller.volume != targetVolume) {
state.controller.volume = targetVolume
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
} }
if (play) { if (play) {
state.controller.playWhenReady = true state.controller.playWhenReady = true
} }
state.controller.setMediaItem(mediaItem.item) // Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
state.controller.prepare() // 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 { controllerState?.let {
inner(it) inner(it)
@@ -29,7 +29,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.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 @Composable
fun LoadThumbAndThenVideoView( fun LoadThumbAndThenVideoView(
@@ -45,56 +52,47 @@ fun LoadThumbAndThenVideoView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onDialog: (() -> Unit)? = null, 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 val context = LocalContext.current
LaunchedEffect(Unit) { // Run the Coil fetch in this LaunchedEffect's scope (was previously launched into the
accountViewModel.loadThumb( // AccountViewModel's viewModelScope, which meant a scroll-away wouldn't cancel the in-flight
context, // image request — wasted bandwidth, plus the late callback wrote into a state that no
thumbUri, // longer mattered). Keying on thumbUri also makes the effect re-fire when a recycled slot
onReady = { // gets a new audio track with a new cover instead of stalling on the stale Pair(true, ...).
loadingFinished = LaunchedEffect(thumbUri) {
if (it != null) { loadingFinished =
Pair(true, it) try {
} else { val request = ImageRequest.Builder(context).data(thumbUri).build()
Pair(true, null) val drawable =
withContext(Dispatchers.IO) {
context.imageLoader
.execute(request)
.image
?.asDrawable(context.resources)
} }
}, Pair(true, drawable)
onError = { loadingFinished = Pair(true, null) }, } 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.first) {
if (loadingFinished.second != null) { VideoView(
VideoView( videoUri = videoUri,
videoUri = videoUri, mimeType = mimeType,
mimeType = mimeType, title = title,
title = title, thumb = loadingFinished.second?.let { VideoThumb(it) },
thumb = VideoThumb(loadingFinished.second), roundedCorner = roundedCorner,
roundedCorner = roundedCorner, contentScale = contentScale,
contentScale = contentScale, artworkUri = thumbUri,
artworkUri = thumbUri, authorName = authorName,
authorName = authorName, nostrUriCallback = nostrUriCallback,
nostrUriCallback = nostrUriCallback, isLiveStream = isLiveStream,
isLiveStream = isLiveStream, accountViewModel = accountViewModel,
accountViewModel = accountViewModel, onDialog = onDialog,
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,
)
}
} }
} }
@@ -31,14 +31,13 @@ import kotlin.uuid.Uuid
class MediaControllerState( class MediaControllerState(
// each composable has an ID. // each composable has an ID.
val id: String = Uuid.random().toString(), val id: String = Uuid.random().toString(),
// This is filled after the controller returns from this class val controller: Player,
var controller: Player,
// visibility onscreen // visibility onscreen
val visibility: VisibilityData = VisibilityData(), val visibility: VisibilityData = VisibilityData(),
) { ) {
fun isPlaying() = controller.isPlaying fun isPlaying() = controller.isPlaying
fun currrentMedia() = controller.currentMediaItem?.mediaId fun currentMedia() = controller.currentMediaItem?.mediaId
fun toggleMute() { fun toggleMute() {
controller.volume = if (controller.volume == 0f) 1f else 0f 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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntSize
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.compose.ContentFrame import androidx.media3.ui.compose.ContentFrame
@@ -90,19 +89,22 @@ fun RenderVideoPlayer(
hasBlurhash: Boolean = false, hasBlurhash: Boolean = false,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val containerSize = remember { mutableStateOf(IntSize.Zero) } // Hold the container size in a non-state holder so layout passes don't trigger an
val isLive = isLiveStreaming(mediaItem.src.videoUri) // 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( Box(
modifier = modifier =
borderModifier borderModifier
.onSizeChanged { containerSize.value = it } .onSizeChanged { containerWidth[0] = it.width }
.pointerInput(isLive, controllerState) { .pointerInput(isLive, controllerState) {
detectTapGestures( detectTapGestures(
onTap = { controllerVisible.value = !controllerVisible.value }, onTap = { controllerVisible.value = !controllerVisible.value },
onDoubleTap = { offset -> onDoubleTap = { offset ->
if (!isLive) { if (!isLive) {
val isLeftSide = offset.x < containerSize.value.width / 2 val isLeftSide = offset.x < containerWidth[0] / 2
if (isLeftSide) { if (isLeftSide) {
controllerState.controller.seekBackward() controllerState.controller.seekBackward()
} else { } else {
@@ -105,15 +105,32 @@ fun VideoView(
thumbhash: String? = null, thumbhash: String? = null,
) { ) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback() 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. // 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. // If the user manually tapped the download button, they want it to play.
val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value) val autoplay = alwaysShowVideo || (initialAutoStart && accountViewModel.settings.autoPlayVideos()) || (!initialAutoStart && automaticallyStartPlayback.value)
if (blurhash == null && thumbhash == null) { // Resolve the aspect ratio once per composition. Prime the URL-keyed cache from the imeta
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri) // 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 = val modifier =
if (ratio != null && automaticallyStartPlayback.value) { if (ratio != null && automaticallyStartPlayback.value) {
Modifier.aspectRatio(ratio) Modifier.aspectRatio(ratio)
@@ -149,8 +166,6 @@ fun VideoView(
} }
} }
} else { } else {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val modifier = val modifier =
if (ratio != null) { if (ratio != null) {
Modifier.aspectRatio(ratio) Modifier.aspectRatio(ratio)
@@ -59,6 +59,11 @@ fun VideoViewInner(
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
val muted = remember(videoUri) { DEFAULT_MUTED_SETTING.value } 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( GetMediaItem(
videoUri = videoUri, videoUri = videoUri,
title = title, title = title,
@@ -67,7 +72,7 @@ fun VideoViewInner(
callbackUri = nostrUriCallback, callbackUri = nostrUriCallback,
mimeType = mimeType, mimeType = mimeType,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri), proxyPort = proxyPort,
keepPlaying = true, keepPlaying = true,
waveformData = waveform, waveformData = waveform,
isLiveStream = isLiveStream, isLiveStream = isLiveStream,
@@ -38,24 +38,33 @@ import androidx.compose.ui.unit.dp
private val FadeIn = fadeIn() private val FadeIn = fadeIn()
private val FadeOut = fadeOut() private val FadeOut = fadeOut()
private val TopGradientColors = // Both gradient brushes are static; pre-build them once at class init so we don't allocate a
listOf( // new Brush on every recomposition while the controllers are visible (which is most of the
Color.Black.copy(alpha = 0.6f), // time during playback / interaction).
Color.Black.copy(alpha = 0.3f), private val TopGradientBrush =
Color.Transparent, Brush.verticalGradient(
colors =
listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.3f),
Color.Transparent,
),
) )
private val BottomGradientColors = private val BottomGradientBrush =
listOf( Brush.verticalGradient(
Color.Transparent, colors =
Color.Black.copy(alpha = 0.4f), listOf(
Color.Black.copy(alpha = 0.7f), Color.Transparent,
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.7f),
),
) )
@Composable @Composable
private fun GradientOverlay( private fun GradientOverlay(
controllerVisible: State<Boolean>, controllerVisible: State<Boolean>,
colors: List<Color>, brush: Brush,
height: Dp, height: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -70,7 +79,7 @@ private fun GradientOverlay(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(height) .height(height)
.background(brush = Brush.verticalGradient(colors = colors)), .background(brush = brush),
) )
} }
} }
@@ -80,11 +89,11 @@ fun TopGradientOverlay(
controllerVisible: State<Boolean>, controllerVisible: State<Boolean>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
height: Dp = 80.dp, height: Dp = 80.dp,
) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier) ) = GradientOverlay(controllerVisible, TopGradientBrush, height, modifier)
@Composable @Composable
fun BottomGradientOverlay( fun BottomGradientOverlay(
controllerVisible: State<Boolean>, controllerVisible: State<Boolean>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
height: Dp = 120.dp, 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.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Preview @Preview
@Composable @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) { LaunchedEffect(key1 = controllerVisible) {
launch(Dispatchers.IO) { delay(2000)
delay(2000) holdOn.value = false
holdOn.value = false
}
} }
val mutedInstance = remember(startingMuteState) { mutableStateOf(startingMuteState) } 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.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private val FadeIn = fadeIn() private val FadeIn = fadeIn()
private val FadeOut = fadeOut() private val FadeOut = fadeOut()
@@ -60,7 +62,7 @@ fun OverflowMenuButtonPreview() {
ThemeComparisonColumn { ThemeComparisonColumn {
Box(Modifier.background(BitcoinOrange)) { Box(Modifier.background(BitcoinOrange)) {
OverflowMenuButton( OverflowMenuButton(
actions = listOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture), actions = persistentListOf(VideoPlayerAction.Share, VideoPlayerAction.Download, VideoPlayerAction.PictureInPicture),
startingMuteState = false, startingMuteState = false,
onFullscreenClick = {}, onFullscreenClick = {},
onMuteClick = {}, onMuteClick = {},
@@ -76,7 +78,7 @@ fun OverflowMenuButtonPreview() {
@Composable @Composable
fun AnimatedOverflowMenuButton( fun AnimatedOverflowMenuButton(
controllerVisible: State<Boolean>, controllerVisible: State<Boolean>,
actions: List<VideoPlayerAction>, actions: ImmutableList<VideoPlayerAction>,
startingMuteState: Boolean, startingMuteState: Boolean,
onFullscreenClick: (() -> Unit)?, onFullscreenClick: (() -> Unit)?,
onMuteClick: () -> Unit, onMuteClick: () -> Unit,
@@ -107,7 +109,7 @@ fun AnimatedOverflowMenuButton(
@Composable @Composable
fun OverflowMenuButton( fun OverflowMenuButton(
actions: List<VideoPlayerAction>, actions: ImmutableList<VideoPlayerAction>,
startingMuteState: Boolean, startingMuteState: Boolean,
onFullscreenClick: (() -> Unit)?, onFullscreenClick: (() -> Unit)?,
onMuteClick: () -> 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.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import kotlinx.collections.immutable.toImmutableList
@Preview @Preview
@Composable @Composable
@@ -105,7 +106,7 @@ fun RenderTopButtons(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isLive = isLiveStreaming(mediaData.videoUri) val isLive = remember(mediaData.videoUri) { isLiveStreaming(mediaData.videoUri) }
val pipSupported = val pipSupported =
remember { remember {
context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
@@ -212,17 +213,22 @@ fun RenderTopButtons(
} }
val canFullscreen = onZoomClick != null 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 = val topBarActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
buttonItems buttonItems
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) } .filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
.map { it.action } .map { it.action }
.toImmutableList()
} }
val overflowActions = val overflowActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) { remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
buttonItems buttonItems
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) } .filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
.map { it.action } .map { it.action }
.toImmutableList()
} }
Row(modifier) { Row(modifier) {
@@ -58,7 +58,7 @@ fun RenderPipVideo(
val modifier = val modifier =
remember { remember {
val ratio = val ratio =
controller.currrentMedia()?.let { controller.currentMedia()?.let {
MediaAspectRatioCache.get(it) MediaAspectRatioCache.get(it)
} }
@@ -24,6 +24,7 @@ import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
@@ -42,10 +43,35 @@ class ExoPlayerBuilder(
.Builder(context) .Builder(context)
.apply { .apply {
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory)) setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
setLoadControl(feedTunedLoadControl())
}.build() }.build()
.apply { .apply {
addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(AspectRatioCacher(MediaAspectRatioCache))
addListener(KeepVideosPlaying(this)) addListener(KeepVideosPlaying(this))
addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) 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.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class ExoPlayerPool( class ExoPlayerPool(
val builder: ExoPlayerBuilder, val builder: ExoPlayerBuilder,
private val poolSize: Int, 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 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 // Exists to avoid exceptions stopping the coroutine
val exceptionHandler = val exceptionHandler =
CoroutineExceptionHandler { _, throwable -> CoroutineExceptionHandler { _, throwable ->
@@ -54,21 +81,63 @@ class ExoPlayerPool(
private val mutex = Mutex() 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) { fun create(context: Context) {
while (playerPool.size < poolStartingSize) { if (!warmupStarted.compareAndSet(false, true)) return
playerPool.offer(builder.build(context)) 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()) { * Acquire a player. When [preferredMediaId] matches a warm entry, returns that player intact
// If the pool is empty, create a new player (or handle it differently) * it still holds its MediaItem and any populated LoadControl buffer, so the caller can
return builder.build(context) * 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 coldPool.poll() ?: builder.build(context)
return playerPool.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) { fun releasePlayerAsync(player: ExoPlayer) {
scope.launch { scope.launch {
releasePlayer(player) releasePlayer(player)
@@ -77,27 +146,70 @@ class ExoPlayerPool(
suspend fun releasePlayer(player: ExoPlayer) { suspend fun releasePlayer(player: ExoPlayer) {
mutex.withLock { 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.pause()
player.stop() val evicted = pushWarm(mediaId, player)
player.clearVideoSurface() if (evicted != null) {
player.clearMediaItems() Log.d("PlaybackService") { "ExoPlayerPool warm evict: ${evicted.mediaId}" }
demoteToCold(evicted.player)
// 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.
} }
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 scope
.launch { .launch {
mutex.withLock { mutex.withLock {
playerPool.forEach { it.release() } val warmSnapshot =
playerPool.clear() synchronized(warmPoolLock) {
val copy = warmPool.toList()
warmPool.clear()
copy
}
warmSnapshot.forEach { it.player.release() }
coldPool.forEach { it.release() }
coldPool.clear()
} }
}.invokeOnCompletion { }.invokeOnCompletion {
scope.cancel() 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.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
class SessionListener( class SessionListener(
val session: MediaSession, val session: MediaSession,
@@ -72,7 +73,22 @@ class MediaSessionPool(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
val globalCallback = MediaSessionCallback(this, appContext) 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 // protects from LruCache killing playing sessions
private val playingMap = mutableMapOf<String, SessionListener>() private val playingMap = mutableMapOf<String, SessionListener>()
@@ -102,18 +118,16 @@ class MediaSessionPool(
id: String, id: String,
keepPlaying: Boolean, keepPlaying: Boolean,
context: Context, 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 { ): MediaSession {
val mediaSession = val mediaSession =
MediaSession MediaSession
.Builder(context, exoPlayerPool.acquirePlayer(context)) .Builder(context, exoPlayerPool.acquirePlayer(context, preferredMediaId))
.apply { .apply {
setBitmapLoader( setBitmapLoader(sharedBitmapLoader)
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(dataSourceFactory)
.build(),
)
setId(id) setId(id)
setCallback(globalCallback) setCallback(globalCallback)
}.build() }.build()
@@ -142,16 +156,17 @@ class MediaSessionPool(
} }
fun cleanupUnused() { fun cleanupUnused() {
if (lastCleanup < TimeUtils.oneMinuteAgo()) { val now = System.nanoTime()
lastCleanup = TimeUtils.now() val previous = lastCleanupNs.get()
scope.launch { if (now - previous < CLEANUP_INTERVAL_NS) return
val snap = cache.snapshot() // CAS so only one caller actually launches the sweep when many releases fire at once.
snap.values.forEach { if (!lastCleanupNs.compareAndSet(previous, now)) return
if (it.session.connectedControllers.isEmpty()) { scope.launch {
releaseSession(it.session) 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, id: String,
keepPlaying: Boolean, keepPlaying: Boolean,
context: Context, context: Context,
preferredMediaId: String? = null,
): MediaSession { ): MediaSession {
val existingSession = playingMap.get(id) ?: cache.get(id) val existingSession = playingMap.get(id) ?: cache.get(id)
if (existingSession != null) { if (existingSession != null) {
return existingSession.session return existingSession.session
} }
return newSession(id, keepPlaying, context) return newSession(id, keepPlaying, context, preferredMediaId)
} }
fun playingContent() = playingMap.values 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 -> { Player.STATE_READY -> {
if (!isLiveStreaming) { if (!isLiveStreaming) {
cache.get(uri)?.let { lastPosition -> 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) player.seekTo(lastPosition)
} }
} }
@@ -105,7 +105,15 @@ class PlaybackService : MediaSessionService() {
val blossomServerResolver = Amethyst.instance.blossomResolver val blossomServerResolver = Amethyst.instance.blossomResolver
// creates new // 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 { } else {
poolWithProxy?.let { return it } poolWithProxy?.let { return it }
@@ -116,7 +124,11 @@ class PlaybackService : MediaSessionService() {
val videoCache = Amethyst.instance.videoCache val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver 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 return
} }
playing.forEachIndexed { idx, it -> playing.forEach {
if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) { if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) {
super.onUpdateNotification(it.session, startInForegroundRequired) super.onUpdateNotification(it.session, startInForegroundRequired)
return return
} }
} }
playing.forEachIndexed { idx, it -> playing.forEach {
if (it.session.player.isPlaying && it.session.player.volume > 0) { if (it.session.player.isPlaying && it.session.player.volume > 0) {
super.onUpdateNotification(it.session, startInForegroundRequired) super.onUpdateNotification(it.session, startInForegroundRequired)
return 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) { if (it.session.player.isPlaying) {
super.onUpdateNotification(it.session, startInForegroundRequired) super.onUpdateNotification(it.session, startInForegroundRequired)
return
} }
} }
} }
@@ -188,7 +204,17 @@ class PlaybackService : MediaSessionService() {
val id = controllerInfo.connectionHints.getString("id") ?: return null val id = controllerInfo.connectionHints.getString("id") ?: return null
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort") val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
val keepPlaying = controllerInfo.connectionHints.getBoolean("keepPlaying", true) 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) 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 import kotlin.uuid.Uuid
object PlaybackServiceClient { 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() { fun shutdown() {
executorService.shutdown() executorService.shutdown()
@@ -56,11 +64,15 @@ object PlaybackServiceClient {
Bundle().apply { Bundle().apply {
// link the id with the client's id to make sure it can return the // link the id with the client's id to make sure it can return the
// same session on background media. // same session on background media.
putString("id", id) putString(PlaybackService.HINT_ID, id)
putBoolean("keepPlaying", keepPlaying) putBoolean(PlaybackService.HINT_KEEP_PLAYING, keepPlaying)
proxyPort?.let { 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)) val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java))
@@ -68,6 +68,10 @@ fun GifVideoView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
thumbhash: String? = null, 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 ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val autoPlay = accountViewModel.settings.autoPlayVideos() val autoPlay = accountViewModel.settings.autoPlayVideos()
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier 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.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.LruCache import android.util.LruCache
@@ -34,9 +33,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
@@ -1578,29 +1574,6 @@ class AccountViewModel(
super.onCleared() 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( fun loadMentions(
mentions: ImmutableList<String>, mentions: ImmutableList<String>,
onReady: (ImmutableList<User>) -> Unit, onReady: (ImmutableList<User>) -> Unit,