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:
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user