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:
Vitor Pamplona
2026-06-27 08:49:29 -04:00
co-authored by Claude Opus 4.8
parent 23a1c8af13
commit c0fadf9473
@@ -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
}
}
}
}