mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(video): release MediaController after 30s in background
Pause-on-background (0de70e51) stops audio/video from leaking past the
foreground, but the MediaController itself stays bound — so the
underlying ExoPlayer keeps holding its codec and decoder buffer the
whole time the app is away.
Schedule a 30s timer on ON_PAUSE; if the activity is still backgrounded
when it fires, flip a keepAlive gate that swaps the controllerAsFlow
out for flowOf(null). collectAsState cancels the previous collection,
awaitClose releases the MediaController, the session disconnects, and
the ExoPlayer goes back to the pool (warm slot keyed by URI). ON_RESUME
cancels any pending timer and flips the gate back, so the flow rebuilds
a fresh MediaController. The onEach warm-pool fast path then re-attaches
to the same paused player — currentMediaItem still matches, no
setMediaItem call, no re-prepare, so position + buffer come back intact.
PiP is exempt via the existing BackgroundMedia.isMutex check, same
pattern the pause-on-background handler uses.
https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad
This commit is contained in:
+131
-41
@@ -21,16 +21,32 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.media3.common.Player
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal const val BACKGROUND_RELEASE_TIMEOUT_MS = 30_000L
|
||||
|
||||
@Composable
|
||||
fun GetVideoController(
|
||||
@@ -40,55 +56,129 @@ fun GetVideoController(
|
||||
inner: @Composable (mediaControllerState: MediaControllerState) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val controllerState by remember(mediaItem) {
|
||||
PlaybackServiceClient
|
||||
.controllerAsFlow(
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
|
||||
|
||||
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
|
||||
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
|
||||
// Read first and only push an IPC if the value actually needs to change —
|
||||
// with several feed videos preloading at once each volume= write was a
|
||||
// round-trip to the service for nothing.
|
||||
val targetVolume =
|
||||
when {
|
||||
BackgroundMedia.isPlaying() -> 0f
|
||||
muted -> 0f
|
||||
else -> 1f
|
||||
// After the app has been in the background for BACKGROUND_RELEASE_TIMEOUT_MS,
|
||||
// drop the MediaController so the underlying ExoPlayer + codec/buffer can be
|
||||
// returned to the pool. On resume the flow is rebuilt, the new session reuses
|
||||
// the same paused player from the warm pool (keyed by URI), and the onEach
|
||||
// warm-pool fast path keeps position and buffered data intact.
|
||||
val keepAlive = remember { mutableStateOf(true) }
|
||||
|
||||
val controllerState by remember(mediaItem, keepAlive.value) {
|
||||
if (keepAlive.value) {
|
||||
PlaybackServiceClient
|
||||
.controllerAsFlow(
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
proxyPort = mediaItem.src.proxyPort,
|
||||
keepPlaying = mediaItem.src.keepPlaying,
|
||||
context = context,
|
||||
).onEach { state ->
|
||||
Log.d("PlaybackService") { "Controller instance: ${state.controller}" }
|
||||
|
||||
// The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda
|
||||
// sets it to 0f when the player is acquired, so the controller arrives at 0f.
|
||||
// Read first and only push an IPC if the value actually needs to change —
|
||||
// with several feed videos preloading at once each volume= write was a
|
||||
// round-trip to the service for nothing.
|
||||
val targetVolume =
|
||||
when {
|
||||
BackgroundMedia.isPlaying() -> 0f
|
||||
muted -> 0f
|
||||
else -> 1f
|
||||
}
|
||||
if (state.controller.volume != targetVolume) {
|
||||
state.controller.volume = targetVolume
|
||||
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
|
||||
}
|
||||
if (state.controller.volume != targetVolume) {
|
||||
state.controller.volume = targetVolume
|
||||
Log.d("PlaybackService") { "OnEach volume=$targetVolume" }
|
||||
}
|
||||
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
if (play) {
|
||||
state.controller.playWhenReady = true
|
||||
}
|
||||
|
||||
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
|
||||
// buffer for this exact MediaItem, the MediaController's local mirror already
|
||||
// shows the matching mediaId. Calling setMediaItem in that case would reset the
|
||||
// player and discard the buffer — exactly what the warm pool exists to avoid.
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
// Warm-pool fast path: when the underlying ExoPlayer was retained paused-with-
|
||||
// buffer for this exact MediaItem, the MediaController's local mirror already
|
||||
// shows the matching mediaId. Calling setMediaItem in that case would reset the
|
||||
// player and discard the buffer — exactly what the warm pool exists to avoid.
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
flowOf<MediaControllerState?>(null)
|
||||
}
|
||||
}.collectAsState(null)
|
||||
|
||||
ReleaseControllerWhenBackgroundedFor(
|
||||
timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS,
|
||||
controllerState = controllerState,
|
||||
keepAlive = keepAlive,
|
||||
)
|
||||
|
||||
controllerState?.let {
|
||||
inner(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips [keepAlive] to `false` after the host activity has been at ON_PAUSE for
|
||||
* [timeoutMs], so the gated flow upstream releases the MediaController. ON_RESUME
|
||||
* cancels any pending timer and flips it back to `true` so the controller is
|
||||
* reacquired.
|
||||
*
|
||||
* The BackgroundMedia (PiP) controller is exempt — it's opted into background
|
||||
* playback and must keep its MediaController alive past the timeout.
|
||||
*/
|
||||
@Composable
|
||||
private fun ReleaseControllerWhenBackgroundedFor(
|
||||
timeoutMs: Long,
|
||||
controllerState: MediaControllerState?,
|
||||
keepAlive: MutableState<Boolean>,
|
||||
) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentControllerState by rememberUpdatedState(controllerState)
|
||||
|
||||
DisposableEffect(lifecycleOwner, keepAlive) {
|
||||
val scope = CoroutineScope(Dispatchers.Main)
|
||||
var timeoutJob: Job? = null
|
||||
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(timeoutMs)
|
||||
val cs = currentControllerState
|
||||
if (cs == null || !BackgroundMedia.isMutex(cs)) {
|
||||
keepAlive.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
keepAlive.value = true
|
||||
}
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
timeoutJob?.cancel()
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user