mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge pull request #2973 from vitorpamplona/claude/stop-video-background-timeout-jSLCO
Release MediaController after 30s background timeout
This commit is contained in:
+31
@@ -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 {
|
||||
|
||||
+139
-41
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user