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:
Claude
2026-05-16 22:25:19 +00:00
parent 280f21159f
commit e5cf575602
@@ -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()
}
}
}