fix(android): auto-recover frozen video after pause/resume on stalling decoders

Some TV SoCs (Amlogic Mi Box class) stall the MediaCodec output path
after pause/resume: audio and the clock keep advancing but the picture
freezes until a seek flushes the codec. Arm a watchdog on every
transition to playing with a warm decoder; if rendered frames stop
advancing while the position moves, recover with a 250ms seek-back
(same-position seeks are short-circuited without a codec flush),
capped per session and logged for field diagnosis.

close #1454
This commit is contained in:
edde746
2026-07-05 09:08:29 +02:00
parent c4184c6148
commit 4a43ae2771
3 changed files with 320 additions and 0 deletions
@@ -234,6 +234,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var frameWatchdogRunnable: Runnable? = null
private var frameWatchdogStartTime: Long = 0L
// Post-resume video stall watchdog (#1454): some TV SoCs (Amlogic Mi Box class)
// stall the MediaCodec output path after pause→resume — the clock and audio keep
// advancing but the picture freezes until a seek flushes the codec. Armed on every
// transition to playing with a warm decoder; recovers with a micro seek-back,
// capped per session.
private var resumeStallRunnable: Runnable? = null
private var resumeStallVerifyRunnable: Runnable? = null
private var resumeStallBaselineFrames = 0
private var resumeStallBaselinePositionMs = 0L
private var resumeStallRechecksLeft = 0
private var resumeStallRecoveryCount = 0
private var loggedResumeStallCap = false
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -1026,6 +1039,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged: $isPlaying")
if (isPlaying) pendingPlayWhenReady = null
if (isPlaying) armResumeStallWatchdog() else cancelResumeStallWatchdog()
delegate?.onPropertyChange("pause", !isPlaying)
}
@@ -1163,6 +1177,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitLog("error", "player", "Error code=${error.errorCode}: ${error.message}, cause=${causeChain.ifEmpty { "none" }}")
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
emitSeekable(false, force = true)
// If native DV7 failed, retry with conversion before falling to MPV
@@ -1258,6 +1273,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)
emitLog(
@@ -2704,6 +2720,124 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
frameWatchdogRunnable = null
}
// Post-resume video stall watchdog (#1454) — see the field comment. Decision
// logic lives in ResumeStallPolicy; this wiring is main-thread only, like the
// frame watchdog above.
private fun armResumeStallWatchdog() {
cancelResumeStallCheck()
val player = exoPlayer ?: return
if (resumeStallRecoveryCount >= ResumeStallPolicy.MAX_RECOVERIES_PER_SESSION) {
if (!loggedResumeStallCap) {
loggedResumeStallCap = true
emitLog("warn", "resume-stall", "Recovery cap reached ($resumeStallRecoveryCount) — watchdog disabled for this session")
}
return
}
// A cold decoder (0 frames ever rendered) is the startup watchdog's territory.
val baselineFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
if (baselineFrames <= 0) return
val hasVideoTrack = player.currentTracks.groups.any {
it.type == C.TRACK_TYPE_VIDEO && it.isSelected
}
if (!hasVideoTrack) return
resumeStallBaselineFrames = baselineFrames
resumeStallBaselinePositionMs = player.currentPosition
resumeStallRechecksLeft = ResumeStallPolicy.MAX_RECHECKS
val windowMs = ResumeStallPolicy.checkWindowMs(
currentVideoFormat?.frameRate,
detectedFrameRate,
player.playbackParameters.speed
)
emitLog("debug", "resume-stall", "Armed (baselineFrames=$baselineFrames, windowMs=$windowMs)")
resumeStallRunnable = Runnable { checkResumeStall(windowMs) }
handler.postDelayed(resumeStallRunnable!!, windowMs)
}
/** Cancels a pending stall check but keeps a recovery verification alive: the
* recovery seek itself flickers isPlaying through buffering, which must not
* silence its own confirmation log. */
private fun cancelResumeStallCheck() {
resumeStallRunnable?.let { handler.removeCallbacks(it) }
resumeStallRunnable = null
}
private fun cancelResumeStallWatchdog() {
cancelResumeStallCheck()
resumeStallVerifyRunnable?.let { handler.removeCallbacks(it) }
resumeStallVerifyRunnable = null
}
private fun checkResumeStall(windowMs: Long) {
resumeStallRunnable = null
if (disposing || !isInitialized) return
val player = exoPlayer ?: return
if (!player.isPlaying || player.playbackState != Player.STATE_READY) return
val hasVideoTrack = player.currentTracks.groups.any {
it.type == C.TRACK_TYPE_VIDEO && it.isSelected
}
if (!hasVideoTrack) return
val currentFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
val verdict = ResumeStallPolicy.evaluate(
baselineFrames = resumeStallBaselineFrames,
currentFrames = currentFrames,
baselinePositionMs = resumeStallBaselinePositionMs,
currentPositionMs = player.currentPosition,
durationMs = player.duration,
windowMs = windowMs
)
when (verdict) {
ResumeStallPolicy.Verdict.HEALTHY ->
emitLog("debug", "resume-stall", "Cleared (frames $resumeStallBaselineFrames$currentFrames)")
ResumeStallPolicy.Verdict.SKIP_NEAR_EOF ->
emitLog("debug", "resume-stall", "Skipped (near end of stream)")
ResumeStallPolicy.Verdict.RECHECK -> {
if (resumeStallRechecksLeft-- > 0) {
resumeStallRunnable = Runnable { checkResumeStall(windowMs) }
handler.postDelayed(resumeStallRunnable!!, windowMs)
} else {
emitLog("debug", "resume-stall", "Gave up (clock not advancing — not a decoder stall)")
}
}
ResumeStallPolicy.Verdict.STALLED -> recoverFromResumeStall(player, windowMs, currentFrames)
}
}
private fun recoverFromResumeStall(player: ExoPlayer, windowMs: Long, stalledFrames: Int) {
resumeStallRecoveryCount++
val positionMs = player.currentPosition
// The clock-advance guard guarantees positionMs ≥ windowMs/2 here, so the
// target never resolves to the current position (which ExoPlayer would
// short-circuit without the codec flush this recovery exists for).
val targetMs = (positionMs - ResumeStallPolicy.SEEK_BACK_MS).coerceAtLeast(0L)
emitLog(
"warn",
"resume-stall",
"Video frozen after resume: no new frames in ${windowMs}ms at ${positionMs}ms " +
"(frames=$stalledFrames, tunneling=$currentTunneledPlayback, decoder=${decoderInitName ?: "unknown"}, " +
"model=${Build.MODEL}) — recovering with seek to ${targetMs}ms " +
"($resumeStallRecoveryCount/${ResumeStallPolicy.MAX_RECOVERIES_PER_SESSION})"
)
seekTo(targetMs)
resumeStallVerifyRunnable = Runnable { verifyResumeStallRecovery(stalledFrames, windowMs) }
handler.postDelayed(resumeStallVerifyRunnable!!, windowMs)
}
private fun verifyResumeStallRecovery(stalledFrames: Int, windowMs: Long) {
resumeStallVerifyRunnable = null
if (disposing || !isInitialized) return
val player = exoPlayer ?: return
if (!player.isPlaying) return // paused or reloaded since; nothing to verify
val frames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
if (frames != stalledFrames) {
emitLog("info", "resume-stall", "Recovery confirmed: video frames advancing again (frames $stalledFrames$frames)")
} else {
emitLog("warn", "resume-stall", "Recovery seek did not restart video frames (still $frames after ${windowMs}ms)")
}
}
// Public API
fun open(
@@ -2718,6 +2852,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
resumeStallRecoveryCount = 0
loggedResumeStallCap = false
// Reset FPS detection for new content
detectedFrameRate = -1f
@@ -3005,6 +3142,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
activeDoviMp4Wrapper = null
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
applyTrackSelectorPolicy(
reason = "DV reload",
@@ -3034,6 +3172,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
fun stop() {
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
exoPlayer?.stop()
emitSeekable(false, force = true)
setVisible(false)
@@ -3383,6 +3522,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
"videoDecoderName" to videoDecoderName,
"videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount,
"videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount,
"videoResumeStallRecoveries" to resumeStallRecoveryCount,
// ASS overlay swap timing (vsync-pinned; late = past the swap-time budget)
"subSwapCount" to assSubtitleView?.swapCount,
"subLateSwaps" to assSubtitleView?.lateSwapCount,
@@ -3544,6 +3684,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
stopPositionUpdates()
handler.removeCallbacksAndMessages(null)
// releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
@@ -0,0 +1,59 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
/**
* Decision logic for the post-resume video stall watchdog (#1454).
*
* Some TV SoCs (the Amlogic Mi Box class) stall the MediaCodec output path after a
* pause -> resume: the playback clock and audio keep advancing but no new video
* frame reaches the screen until the codec is flushed by a seek. The watchdog
* snapshots the rendered-frame counter when playback resumes and evaluates it one
* check window later; a confirmed stall is recovered with a small backward seek
* (ExoPlayer short-circuits a same-position seek without resetting renderers, so
* the recovery must move by a nonzero delta to force the codec flush).
*/
internal object ResumeStallPolicy {
/** Floor for the check window; frame-interval scaling only ever raises it. */
const val DEFAULT_CHECK_WINDOW_MS = 1000L
/** Frames that must have been due within the window before calling it a stall. */
const val MIN_FRAME_INTERVALS = 4
/** Assumed fps when neither the format nor timestamp detection knows it. */
const val FALLBACK_FPS = 24f
/** Recovery seek delta. Must stay under the 350ms watch-together drift deadband. */
const val SEEK_BACK_MS = 250L
/** Re-checks allowed while the clock itself is not advancing (generic stall, not this bug). */
const val MAX_RECHECKS = 2
/** Recovery cap per media session; a pathological stream stops arming after this. */
const val MAX_RECOVERIES_PER_SESSION = 5
enum class Verdict { HEALTHY, RECHECK, SKIP_NEAR_EOF, STALLED }
/** Window sized so even low-fps or slowed-down content has had several frames due. */
fun checkWindowMs(formatFps: Float?, detectedFps: Float?, speed: Float): Long {
val fps = formatFps?.takeIf { it > 1f } ?: detectedFps?.takeIf { it > 1f } ?: FALLBACK_FPS
val frameIntervalMs = (1000f / fps / speed.coerceAtLeast(0.25f)).toLong()
return maxOf(DEFAULT_CHECK_WINDOW_MS, MIN_FRAME_INTERVALS * frameIntervalMs)
}
fun evaluate(
baselineFrames: Int,
currentFrames: Int,
baselinePositionMs: Long,
currentPositionMs: Long,
durationMs: Long,
windowMs: Long,
): Verdict = when {
// Any counter movement counts as healthy, including a renderer re-enable
// resetting DecoderCounters below the baseline.
currentFrames != baselineFrames -> Verdict.HEALTHY
currentPositionMs - baselinePositionMs < windowMs / 2 -> Verdict.RECHECK
durationMs != C.TIME_UNSET && durationMs - currentPositionMs < 2 * windowMs -> Verdict.SKIP_NEAR_EOF
else -> Verdict.STALLED
}
}
@@ -0,0 +1,120 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import com.edde746.plezy.exoplayer.ResumeStallPolicy.Verdict
import org.junit.Assert.assertEquals
import org.junit.Test
class ResumeStallPolicyTest {
// checkWindowMs
@Test
fun windowFloorsAtDefaultForNormalFrameRates() {
// 24fps → 4 × 42ms = 168ms, floored to 1000ms
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = 24f, detectedFps = -1f, speed = 1f))
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = 60f, detectedFps = -1f, speed = 1f))
}
@Test
fun windowScalesUpForLowFrameRates() {
// 2fps → 4 × 500ms = 2000ms
assertEquals(2000L, ResumeStallPolicy.checkWindowMs(formatFps = 2f, detectedFps = -1f, speed = 1f))
}
@Test
fun windowScalesUpForSlowedPlayback() {
// 8fps at 0.25× speed → effective 2fps → 2000ms
assertEquals(2000L, ResumeStallPolicy.checkWindowMs(formatFps = 8f, detectedFps = -1f, speed = 0.25f))
// fast playback shrinks the interval; still floored
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = 24f, detectedFps = -1f, speed = 2f))
}
@Test
fun windowFallsBackThroughDetectedFpsToDefault() {
// Format fps unknown (NO_VALUE = -1) → detected fps wins
assertEquals(2000L, ResumeStallPolicy.checkWindowMs(formatFps = -1f, detectedFps = 2f, speed = 1f))
// Neither known → 24fps assumption → floor
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = -1f, detectedFps = -1f, speed = 1f))
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
}
// evaluate
@Test
fun advancingFramesAreHealthy() {
assertEquals(
Verdict.HEALTHY,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 124,
baselinePositionMs = 60_000, currentPositionMs = 61_000,
durationMs = 3_600_000, windowMs = 1000
)
)
}
@Test
fun counterResetCountsAsHealthy() {
// Renderer re-enable resets DecoderCounters below the baseline — not a stall.
assertEquals(
Verdict.HEALTHY,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 3,
baselinePositionMs = 60_000, currentPositionMs = 61_000,
durationMs = 3_600_000, windowMs = 1000
)
)
}
@Test
fun stalledClockRequestsRecheck() {
// Frames frozen but the position isn't advancing either: generic buffering
// stall, not the decoder freeze this watchdog targets.
assertEquals(
Verdict.RECHECK,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 100,
baselinePositionMs = 60_000, currentPositionMs = 60_200,
durationMs = 3_600_000, windowMs = 1000
)
)
}
@Test
fun nearEndOfStreamIsSkipped() {
// Video track ending before audio is legitimate near EOF.
assertEquals(
Verdict.SKIP_NEAR_EOF,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 100,
baselinePositionMs = 3_598_000, currentPositionMs = 3_599_000,
durationMs = 3_600_000, windowMs = 1000
)
)
}
@Test
fun frozenFramesWithAdvancingClockMidFileIsStalled() {
assertEquals(
Verdict.STALLED,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 100,
baselinePositionMs = 60_000, currentPositionMs = 61_000,
durationMs = 3_600_000, windowMs = 1000
)
)
}
@Test
fun unknownDurationSkipsEofGuardAndStalls() {
// Live/unknown duration (C.TIME_UNSET) must not suppress detection.
assertEquals(
Verdict.STALLED,
ResumeStallPolicy.evaluate(
baselineFrames = 100, currentFrames = 100,
baselinePositionMs = 60_000, currentPositionMs = 61_000,
durationMs = C.TIME_UNSET, windowMs = 1000
)
)
}
}