mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
fix(video): show browser-fallback overlay when a decoder silently stalls
Some codec failures never surface as a PlaybackException: a software HEVC decoder that can't keep up (e.g. iPhone-recorded hvc1 video on a device without a HEVC hardware decoder) just parks the player in STATE_BUFFERING forever — the buffer fills to the LoadControl cap, the playhead never leaves 0, and no error is ever raised. WatchPlaybackErrors only listened for onPlayerErrorChanged, so the existing RenderPlaybackError "Open in browser" overlay never showed and the user stared at a blank buffering box. Add a decode-stall watchdog that polls the controller and synthesizes a PlaybackException (ERROR_CODE_DECODING_FORMAT_UNSUPPORTED) once the player sits in STATE_BUFFERING, wanting to play, with >=2s of media buffered ahead (decoder is fed, not network-starved) yet a frozen playhead for 8s. The buffer-ahead guard distinguishes a hung decoder from genuine network starvation, whose buffer is depleted and so is never flagged. Recovery is automatic: the overlay clears on the STATE_READY transition and, belt-and-suspenders, the watchdog drops it the instant the playhead advances again — so a slow device that eventually decodes "just plays." Also narrow the recovery-clear to STATE_READY only (clearing on STATE_BUFFERING would wipe the synthetic error instantly) and clear on onMediaItemTransition so a pooled player starting a new video resets cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
23a1c8af13
commit
c0fadf9473
+99
-3
@@ -20,10 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
// How often the decode-stall watchdog samples the controller's position/buffer.
|
||||
private const val STALL_POLL_INTERVAL_MS = 1_000L
|
||||
|
||||
// The decoder is only considered "fed" once it has comfortably more than bufferForPlaybackMs
|
||||
// (750 ms in feedTunedLoadControl) of media ready ahead of the playhead. Below this we treat a
|
||||
// frozen playhead as ordinary network starvation, not a decode failure, and leave it alone.
|
||||
private const val STALL_MIN_BUFFER_AHEAD_MS = 2_000L
|
||||
|
||||
// How long the playhead may stay frozen while the decoder is fed before we declare the stream
|
||||
// undecodable. A healthy player crosses bufferForPlaybackMs and starts rendering within a second
|
||||
// or two, so 8 s of fed-but-frozen buffering is unambiguous.
|
||||
private const val STALL_TIMEOUT_MS = 8_000L
|
||||
|
||||
/**
|
||||
* Mirrors the MediaController's terminal-error state into [MediaControllerState.playbackError]
|
||||
@@ -33,6 +53,12 @@ import androidx.media3.common.Player
|
||||
* ContentFrame then renders nothing and the user is left staring at a blank box. Surfacing the
|
||||
* exception gives [RenderVideoPlayer] something to render and lets the user open the URL
|
||||
* externally instead.
|
||||
*
|
||||
* Some codec failures never surface as a [PlaybackException] at all: a software HEVC decoder that
|
||||
* can't keep up (e.g. iPhone-recorded `hvc1` video on a device without a HEVC hardware decoder)
|
||||
* just parks the player in [Player.STATE_BUFFERING] forever — the buffer fills to the LoadControl
|
||||
* cap, yet the playhead never leaves 0 and no error is ever raised. [watchForDecodeStall] polls
|
||||
* for that signature and synthesizes a [PlaybackException] so the same overlay kicks in.
|
||||
*/
|
||||
@Composable
|
||||
fun WatchPlaybackErrors(controllerState: MediaControllerState) {
|
||||
@@ -50,10 +76,20 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) {
|
||||
errorState.value = error
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
// A new item on a pooled player starts fresh; drop any error from the old one.
|
||||
if (errorState.value != null) errorState.value = null
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(state: Int) {
|
||||
// Any successful transition out of IDLE/ERROR (typically after a manual retry
|
||||
// via controller.prepare()) clears the overlay so the player can render.
|
||||
if (state == Player.STATE_READY || state == Player.STATE_BUFFERING) {
|
||||
// A successful transition to READY (the renderer produced output) is the only
|
||||
// real recovery — clear the overlay then. We deliberately do NOT clear on
|
||||
// STATE_BUFFERING: the synthetic decode-stall error below is raised *while*
|
||||
// buffering, and clearing on every buffering event would wipe it instantly.
|
||||
if (state == Player.STATE_READY) {
|
||||
if (errorState.value != null) errorState.value = null
|
||||
}
|
||||
}
|
||||
@@ -64,4 +100,64 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) {
|
||||
controller.removeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(controllerState) {
|
||||
watchForDecodeStall(controller, errorState)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls [controller] for the silent-decode-stall signature and, once seen continuously for
|
||||
* [STALL_TIMEOUT_MS], writes a synthetic [PlaybackException] so the browser-fallback overlay shows.
|
||||
*
|
||||
* Stall signature: the player wants to play and is stuck in [Player.STATE_BUFFERING] with at least
|
||||
* [STALL_MIN_BUFFER_AHEAD_MS] of media buffered ahead (so the decoder is fed, not network-starved),
|
||||
* yet the playhead has not advanced. A genuine mid-stream rebuffer fails the buffer-ahead guard —
|
||||
* its buffer is depleted, which is precisely why it rebuffers — so it is never flagged.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private suspend fun watchForDecodeStall(
|
||||
controller: Player,
|
||||
errorState: MutableState<PlaybackException?>,
|
||||
) {
|
||||
var unproductiveSinceMs = -1L
|
||||
var lastPosition = Long.MIN_VALUE
|
||||
|
||||
// delay() is cancellable, so the loop exits when the LaunchedEffect is torn down.
|
||||
while (true) {
|
||||
delay(STALL_POLL_INTERVAL_MS)
|
||||
|
||||
val position = controller.currentPosition
|
||||
val progressed = position != lastPosition
|
||||
lastPosition = position
|
||||
|
||||
val decoderFedButFrozen =
|
||||
controller.playbackState == Player.STATE_BUFFERING &&
|
||||
controller.playWhenReady &&
|
||||
controller.bufferedPosition - position >= STALL_MIN_BUFFER_AHEAD_MS
|
||||
|
||||
if (decoderFedButFrozen && !progressed) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (unproductiveSinceMs < 0) {
|
||||
unproductiveSinceMs = now
|
||||
} else if (now - unproductiveSinceMs >= STALL_TIMEOUT_MS && errorState.value == null) {
|
||||
errorState.value =
|
||||
PlaybackException(
|
||||
"Video decoding stalled with a full buffer — likely an unsupported codec",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
unproductiveSinceMs = -1L
|
||||
|
||||
// "If it works, just play": if the playhead is advancing again the stream is decoding
|
||||
// after all (a slow device that eventually produced a frame, a recovered hiccup), so
|
||||
// drop the stall overlay. Real decoder errors leave the player IDLE with a frozen
|
||||
// playhead, so they never progress here and are left for the STATE_READY listener.
|
||||
if (progressed && errorState.value != null) {
|
||||
errorState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user