From e5cf57560297c85379ac1710a1724d4ffe1c07eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 22:25:19 +0000 Subject: [PATCH 1/3] feat(video): release MediaController after 30s in background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../playback/composable/GetVideoController.kt | 172 +++++++++++++----- 1 file changed, 131 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 869dc653da..b6bd3caf85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -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(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, +) { + 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() + } + } +} From b71b7cb420fb79d501f48cc1c7e2f5c4a86dd5c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 22:50:33 +0000 Subject: [PATCH 2/3] fix(video): skip background release timer for PiP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30s release timer fires on ON_PAUSE, but PipVideoActivity enters PiP mode (which dispatches ON_PAUSE) immediately after onCreate. The MediaController is built asynchronously and RegisterBackgroundMedia runs only after inner() mounts, so there's a window where the timer's BackgroundMedia.isMutex check sees a stale (or null) bgInstance and releases a controller that was about to start background playback — blanking the PiP window. Add an opt-out parameter on GetVideoController and disable the timer from PipVideoActivity. PiP IS the explicit background-playback opt-in, so the timer doesn't apply by design. https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad --- .../playback/composable/GetVideoController.kt | 18 +++++++++++++----- .../service/playback/pip/PipVideoActivity.kt | 8 +++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index b6bd3caf85..f1fce2f1f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -53,6 +53,12 @@ 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 @@ -116,11 +122,13 @@ fun GetVideoController( } }.collectAsState(null) - ReleaseControllerWhenBackgroundedFor( - timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS, - controllerState = controllerState, - keepAlive = keepAlive, - ) + if (releaseOnBackgroundTimeout) { + ReleaseControllerWhenBackgroundedFor( + timeoutMs = BACKGROUND_RELEASE_TIMEOUT_MS, + controllerState = controllerState, + keepAlive = keepAlive, + ) + } controllerState?.let { inner(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 6f5ed7dd56..3281c86388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -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( From 4d428025d35232e2509c3d10a3a35431a4cd1629 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 23:18:51 +0000 Subject: [PATCH 3/3] feat(voice): pause voice notes on background like videos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voice notes go through GetVideoController, so the 30s background release timer + warm-pool reattach already applied to them. What was missing was the immediate ON_PAUSE handler: voice didn't use ControlWhenPlayerIsActive, so playback kept running for the full 30s before the release timer hit and cut it off via the warm-pool pause. Extract a small PauseControllerWhenInBackground composable from the ON_PAUSE arm of ControlWhenPlayerIsActive and call it from VoiceTrack. Result matches video: pause on ON_PAUSE, release at 30s, reassemble the controller (paused, position preserved) when the user returns — no auto-resume since voice has no autoplay setting. PiP exemption (BackgroundMedia.isMutex) is preserved. https://claude.ai/code/session_01RoEUbAN8ejF21Ns3eM6xad --- .../composable/ControlWhenPlayerIsActive.kt | 31 +++++++++++++++++++ .../amethyst/ui/note/types/VoiceTrack.kt | 2 ++ 2 files changed, 33 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index a26dfbdb63..f955953c93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 7d6def80c7..52e76860cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -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,