Merge pull request #2973 from vitorpamplona/claude/stop-video-background-timeout-jSLCO

Release MediaController after 30s background timeout
This commit is contained in:
Vitor Pamplona
2026-05-19 10:34:44 -04:00
committed by GitHub
4 changed files with 179 additions and 42 deletions
@@ -118,6 +118,37 @@ fun ControlWhenPlayerIsActive(
}
}
/**
* Pauses [mediaControllerState] when the host activity leaves the foreground.
*
* Standalone version of the ON_PAUSE arm of [ControlWhenPlayerIsActive] for
* callers (e.g. voice notes) that don't have the visibility mutex / auto-resume
* logic but still need to stop playback when the app backgrounds. Pairs with
* the 30s release timer in [GetVideoController] — pause immediately, release
* the controller after the timeout, reassemble on resume.
*
* Skips the explicit BackgroundMedia (PiP) instance: that one is opted in to
* keep-playing.
*/
@Composable
fun PauseControllerWhenInBackground(mediaControllerState: MediaControllerState) {
val controller = mediaControllerState.controller
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, mediaControllerState) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE &&
controller.isPlaying &&
!BackgroundMedia.isMutex(mediaControllerState)
) {
controller.pause()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
}
class PlayerEventListener(
val view: View,
) : Player.Listener {
@@ -21,74 +21,172 @@
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(
mediaItem: LoadedMediaItem,
muted: Boolean = false,
play: Boolean = false,
// Opt-out for callers whose lifecycle owner is already a background-playback
// surface (PiP): there, the 30s timer would fire as soon as the activity
// enters PiP mode and race the controller build / `RegisterBackgroundMedia`
// registration, killing the just-attached controller and blanking the
// window. The opt-out skips the timer entirely for those callers.
releaseOnBackgroundTimeout: Boolean = true,
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)
if (releaseOnBackgroundTimeout) {
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()
}
}
}
@@ -52,7 +52,13 @@ class PipVideoActivity : ComponentActivity() {
val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value }
GetMediaItem(mediaItemData) { mediaItem ->
GetVideoController(mediaItem, muted, true) { controllerState ->
GetVideoController(
mediaItem = mediaItem,
muted = muted,
play = true,
// PiP IS the opt-in for background playback — never release on background.
releaseOnBackgroundTimeout = false,
) { controllerState ->
// PiP window is small, keep bandwidth low by forcing the lowest
// rendition. User can still manually change quality via controls.
ApplyInitialVideoQuality(
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
import com.vitorpamplona.amethyst.service.playback.composable.PauseControllerWhenInBackground
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedSaveButton
import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedShareButton
@@ -165,6 +166,7 @@ fun RenderAudioWithWaveform(
mediaItem = mediaItem,
muted = false,
) { controller ->
PauseControllerWhenInBackground(controller)
RenderVoicePlayer(
mediaItem = mediaItem,
controllerState = controller,