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/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 869dc653da..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 @@ -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(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, +) { + 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() + } + } +} 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( 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,