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 {
|
applicationIOScope.launch {
|
||||||
// Prepares video cache later
|
delay(1_500)
|
||||||
delay(10_000)
|
|
||||||
videoCache
|
videoCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-13
@@ -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)
|
||||||
|
|||||||
+43
-45
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -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
|
||||||
|
|||||||
+7
-5
@@ -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 {
|
||||||
|
|||||||
+20
-5
@@ -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)
|
||||||
|
|||||||
+6
-1
@@ -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,
|
||||||
|
|||||||
+23
-14
@@ -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)
|
||||||
|
|||||||
+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.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) }
|
||||||
|
|||||||
+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.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,
|
||||||
|
|||||||
+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.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) {
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+26
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+152
-29
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-20
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-6
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -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
|
||||||
|
|||||||
-27
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user