fix(android): retry ExoPlayer after decoder loss

close #1540
This commit is contained in:
edde746
2026-07-26 14:58:21 +02:00
parent f5661c766e
commit ecc55d9b36
4 changed files with 444 additions and 18 deletions
@@ -281,6 +281,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var loggedEwasteEac3Workaround: Boolean = false
private var lastTrueHdDirectOutputLogKey: String? = null
private var loggedDecodedPcmTunnelingGuard: Boolean = false
private var hasRenderedVideoFrameForMedia: Boolean = false
private var videoDecoderRecoveryConsecutiveAttempts: Int = 0
private var videoDecoderRecoveryTotalAttempts: Int = 0
private var videoDecoderRecoveryPositionMs: Long? = null
private var firstFrameRendered: Boolean = false
var delegate: ExoPlayerDelegate? = null
var debugLoggingEnabled: Boolean = false
@@ -325,7 +329,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var selectedSubtitleTrackId: String? = null
private val audioTrackGroupMap = mutableMapOf<String, TrackGroup>()
private val subtitleTrackGroupMap = mutableMapOf<String, TrackGroup>()
private var pendingDvTrackRestore: PendingTrackRestore? = null
private var pendingTrackRestore: PendingTrackRestore? = null
private data class PendingTrackRestore(
val audio: TrackRestoreIdentity?,
@@ -893,6 +897,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val currentPosition = player.currentPosition
val duration = player.duration
val bufferedPosition = player.bufferedPosition
updateVideoDecoderRecoveryHealth(currentPosition, player.isPlaying)
// Emit position changes (every 250ms update)
if (currentPosition != lastPosition) {
@@ -1063,7 +1068,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return // skip processing the intermediate no-audio track list
}
if (restorePendingDvTrackSelection(tracks)) return
if (restorePendingTrackSelection(tracks)) return
// Log selected video and audio track details
val videoGroup = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.isSelected }
@@ -1148,6 +1153,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
if (retryAfterAudioTrackError(error, causeChain)) return
val rendererFormat = (error as? ExoPlaybackException)?.rendererFormat
val isVideoRenderer = rendererFormat?.sampleMimeType?.startsWith("video/") == true
if (
VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(error.errorCode, isVideoRenderer) &&
retryVideoDecoderInPlace(PlaybackException.getErrorCodeName(error.errorCode))
) {
return
}
val uri = currentMediaUri
if (uri != null) {
Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV")
@@ -1164,6 +1178,83 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error")
}
private fun retryVideoDecoderInPlace(reason: String): Boolean {
if (!VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrameForMedia,
videoDecoderRecoveryConsecutiveAttempts,
videoDecoderRecoveryTotalAttempts
)
) {
return false
}
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPlayWhenReady = player.playWhenReady
val previousDecoder = decoderInitName
pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
emitLog(
"info",
"track-restore",
"Saved selection before decoder recovery: audio=${restore.audio?.describe() ?: "none"}, " +
"subtitle=${restore.subtitle?.describe() ?: if (restore.subtitleDisabled) "off" else "none"}"
)
}
videoDecoderRecoveryConsecutiveAttempts++
videoDecoderRecoveryTotalAttempts++
videoDecoderRecoveryPositionMs = savedPosition
pendingStartPositionMs = savedPosition
pendingPlayWhenReady = savedPlayWhenReady
decoderInitName = null
firstFrameRendered = false
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
applyTrackSelectorPolicy(
reason = "video decoder recovery",
forceSelector = true,
clearAudioOverrides = true,
clearTextOverrides = true
)
emitLog(
"warn",
"video-recovery",
"Retrying warm decoder in ExoPlayer " +
"(consecutive=$videoDecoderRecoveryConsecutiveAttempts/${VideoDecoderRecoveryPolicy.MAX_CONSECUTIVE_ATTEMPTS}, " +
"total=$videoDecoderRecoveryTotalAttempts/${VideoDecoderRecoveryPolicy.MAX_ATTEMPTS_PER_MEDIA}) at ${savedPosition}ms; " +
"reason=$reason, decoder=${previousDecoder ?: "unknown"}"
)
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
return true
}
private fun updateVideoDecoderRecoveryHealth(currentPositionMs: Long, isPlaying: Boolean) {
val recoveryPositionMs = videoDecoderRecoveryPositionMs ?: return
if (!VideoDecoderRecoveryPolicy.hasSustainedPlayback(
hasRenderedFrame = firstFrameRendered,
isPlaying = isPlaying,
recoveryPositionMs = recoveryPositionMs,
currentPositionMs = currentPositionMs
)
) {
return
}
videoDecoderRecoveryConsecutiveAttempts = 0
videoDecoderRecoveryPositionMs = null
emitLog(
"info",
"video-recovery",
"Decoder recovery confirmed after ${currentPositionMs - recoveryPositionMs}ms of playback"
)
}
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
if (!isAudioTrackError(error.errorCode)) return false
@@ -1694,7 +1785,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
delegate?.onPropertyChange("track-list", trackList)
}
private fun captureDvTrackRestore(): PendingTrackRestore? {
private fun captureTrackRestore(): PendingTrackRestore? {
val tracks = exoPlayer?.currentTracks ?: return null
val audio = captureSelectedTrackIdentity(tracks, C.TRACK_TYPE_AUDIO)
?: captureMappedTrackIdentity(selectedAudioTrackId, audioTrackGroupMap, C.TRACK_TYPE_AUDIO)
@@ -1760,11 +1851,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun hasSelectedTextTrack(tracks: Tracks): Boolean = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }
private fun restorePendingDvTrackSelection(tracks: Tracks): Boolean {
val pending = pendingDvTrackRestore ?: return false
private fun restorePendingTrackSelection(tracks: Tracks): Boolean {
val pending = pendingTrackRestore ?: return false
if (trackSelector == null) return false
if (shouldWaitForDvRestoreTracks(pending, tracks)) return true
pendingDvTrackRestore = null
if (shouldWaitForRestoreTracks(pending, tracks)) return true
pendingTrackRestore = null
val audioMatch = pending.audio?.let { findTrackRestoreMatch(tracks, it) }
val subtitleMatch = pending.subtitle?.let { findTrackRestoreMatch(tracks, it) }
@@ -1781,9 +1872,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
audioOverride = TrackSelectionOverride(audioMatch.trackGroup, audioMatch.trackIndex)
selectionWillChange = selectionWillChange || !audioMatch.isSelected()
appliedRestore = true
emitLog("info", "track-restore", "Restoring audio after DV reload: ${pending.audio.describe()} -> ${audioMatch.format.describeTrackFormat()} score=${audioMatch.score}")
emitLog("info", "track-restore", "Restoring audio after media reload: ${pending.audio.describe()} -> ${audioMatch.format.describeTrackFormat()} score=${audioMatch.score}")
} else {
emitLog("warn", "track-restore", "Could not restore audio after DV reload: ${pending.audio.describe()}")
emitLog("warn", "track-restore", "Could not restore audio after media reload: ${pending.audio.describe()}")
}
}
@@ -1792,16 +1883,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
textDisabled = true
selectionWillChange = selectionWillChange || hasSelectedText
appliedRestore = true
emitLog("info", "track-restore", "Restoring subtitles off after DV reload")
emitLog("info", "track-restore", "Restoring subtitles off after media reload")
} else if (pending.subtitle != null) {
if (subtitleMatch != null) {
textOverride = TrackSelectionOverride(subtitleMatch.trackGroup, subtitleMatch.trackIndex)
textDisabled = false
selectionWillChange = selectionWillChange || !subtitleMatch.isSelected()
appliedRestore = true
emitLog("info", "track-restore", "Restoring subtitle after DV reload: ${pending.subtitle.describe()} -> ${subtitleMatch.format.describeTrackFormat()} score=${subtitleMatch.score}")
emitLog("info", "track-restore", "Restoring subtitle after media reload: ${pending.subtitle.describe()} -> ${subtitleMatch.format.describeTrackFormat()} score=${subtitleMatch.score}")
} else {
emitLog("warn", "track-restore", "Could not restore subtitle after DV reload: ${pending.subtitle.describe()}")
emitLog("warn", "track-restore", "Could not restore subtitle after media reload: ${pending.subtitle.describe()}")
}
}
@@ -1809,7 +1900,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
audioMatch?.let { updateAudioCodecForTunneling(it.format) }
evaluateVideoCodecForTunneling()
applyTrackSelectorPolicy(
reason = "DV track restore",
reason = "track restore",
forceSelector = true,
audioOverride = audioOverride,
audioDisabled = if (audioOverride != null) false else null,
@@ -1821,7 +1912,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return selectionWillChange
}
private fun shouldWaitForDvRestoreTracks(pending: PendingTrackRestore, tracks: Tracks): Boolean {
private fun shouldWaitForRestoreTracks(pending: PendingTrackRestore, tracks: Tracks): Boolean {
val hasAudioGroups = tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO }
val hasTextGroups = tracks.groups.any { it.type == C.TRACK_TYPE_TEXT }
return (pending.audio != null && !hasAudioGroups) ||
@@ -2612,7 +2703,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
renderTimeMs: Long
) {
val mediaGeneration = mediaGenerationAt(eventTime) ?: return
if (mediaGeneration != currentMediaGeneration || !claimPlaybackOutputReady()) return
if (mediaGeneration != currentMediaGeneration) return
hasRenderedVideoFrameForMedia = true
if (!claimPlaybackOutputReady()) return
emitLog("debug", "decoder-hang", "First frame rendered — decoder OK")
logNativeDvFirstFrameIfNeeded()
logDolbyVisionPlaybackPathIfNeeded()
@@ -2646,6 +2739,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog()
cancelDecoderHangCheck()
if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable
if (retryVideoDecoderInPlace("decoder hang: $decoderName")) return@Runnable
requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri,
@@ -2725,6 +2819,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitLog("warn", "watchdog", "0 frames rendered after ${elapsed}ms — triggering fallback")
stopFrameWatchdog()
if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return
if (retryVideoDecoderInPlace("black screen after ${elapsed}ms")) return
// Trigger fallback via the same delegate path as player errors
val uri = currentMediaUri ?: return
requestFormatFallback(
@@ -2906,6 +3001,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
loggedDirectAudioRecoveryBlocks.clear()
currentVideoFormat = null
firstFrameRendered = false
hasRenderedVideoFrameForMedia = false
videoDecoderRecoveryConsecutiveAttempts = 0
videoDecoderRecoveryTotalAttempts = 0
videoDecoderRecoveryPositionMs = null
loggedNativeDvSelectionKey = null
loggedNativeDvFirstFrame = false
loggedDvPlaybackPathKey = null
@@ -2933,7 +3032,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
subtitleTrackGroupMap.clear()
selectedAudioTrackId = null
selectedSubtitleTrackId = null
pendingDvTrackRestore = null
pendingTrackRestore = null
// Build external subtitle sources before prepare. Container sidecars are
// filtered to text tracks and merged with the primary source; standalone
@@ -3189,7 +3288,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPlayWhenReady = player.playWhenReady
pendingDvTrackRestore = captureDvTrackRestore()?.also { restore ->
pendingTrackRestore = captureTrackRestore()?.also { restore ->
emitLog(
"info",
"track-restore",
@@ -3763,6 +3862,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
pendingAudioRendererBounce = false
decoderInitName = null
hasRenderedVideoFrameForMedia = false
videoDecoderRecoveryConsecutiveAttempts = 0
videoDecoderRecoveryTotalAttempts = 0
videoDecoderRecoveryPositionMs = null
audioDecoderInitName = null
lastAudioTrackConfig = null
lastAudioSinkError = null
@@ -3784,7 +3887,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitSeekable(false, force = true)
selectedAudioTrackId = null
selectedSubtitleTrackId = null
pendingDvTrackRestore = null
pendingTrackRestore = null
audioTrackGroupMap.clear()
subtitleTrackGroupMap.clear()
exoPlayer?.clearVideoSurface()
@@ -0,0 +1,41 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.PlaybackException
/**
* Keeps runtime decoder recovery separate from genuine format incompatibility.
*
* A video format that has already rendered a frame is known to be supported. A
* later decoder failure can therefore be retried in ExoPlayer before the
* session falls back to mpv. Cold-start failures still use the normal fallback,
* while consecutive and per-media caps prevent reload loops.
*/
internal object VideoDecoderRecoveryPolicy {
const val MAX_CONSECUTIVE_ATTEMPTS = 1
const val MAX_ATTEMPTS_PER_MEDIA = 3
const val HEALTHY_PLAYBACK_PROGRESS_MS = 3000L
fun isTransientVideoDecoderError(errorCode: Int, isVideoRenderer: Boolean): Boolean =
isVideoRenderer &&
(errorCode == PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ||
errorCode == PlaybackException.ERROR_CODE_DECODING_FAILED)
fun canRetryRuntimeFailure(
hasRenderedVideoFrame: Boolean,
consecutiveAttempts: Int,
totalAttempts: Int
): Boolean =
hasRenderedVideoFrame &&
consecutiveAttempts < MAX_CONSECUTIVE_ATTEMPTS &&
totalAttempts < MAX_ATTEMPTS_PER_MEDIA
fun hasSustainedPlayback(
hasRenderedFrame: Boolean,
isPlaying: Boolean,
recoveryPositionMs: Long,
currentPositionMs: Long
): Boolean =
hasRenderedFrame &&
isPlaying &&
currentPositionMs - recoveryPositionMs >= HEALTHY_PLAYBACK_PROGRESS_MS
}
@@ -1,8 +1,15 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import android.os.Looper
import android.widget.FrameLayout
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.SinglePeriodTimeline
import org.junit.Assert.assertEquals
@@ -12,6 +19,8 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class ExoPlayerFallbackTerminalTest {
@@ -184,6 +193,101 @@ class ExoPlayerFallbackTerminalTest {
}
}
@Test
@Config(sdk = [28])
fun warmVideoDecoderErrorRetriesOnceBeforeFallback() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
setField(core, "currentMediaGeneration", 7)
setField(core, "currentMediaUri", "file:///missing-video.mkv")
emitRenderedFirstFrame(core, mediaGeneration = 7)
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
assertEquals(0, delegate.fallbackRequests)
assertEquals(1, getField(core, "videoDecoderRecoveryConsecutiveAttempts"))
assertEquals(1, getField(core, "videoDecoderRecoveryTotalAttempts"))
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
assertEquals(1, delegate.fallbackRequests)
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
@Test
@Config(sdk = [28])
fun coldVideoDecoderErrorFallsBackImmediately() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
setField(core, "currentMediaGeneration", 7)
setField(core, "currentMediaUri", "file:///missing-video.mkv")
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
assertEquals(1, delegate.fallbackRequests)
assertEquals(0, getField(core, "videoDecoderRecoveryConsecutiveAttempts"))
assertEquals(0, getField(core, "videoDecoderRecoveryTotalAttempts"))
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
@Test
@Config(sdk = [28])
fun sustainedPlaybackRearmsDecoderRecovery() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
setField(core, "currentMediaGeneration", 7)
setField(core, "currentMediaUri", "file:///missing-video.mkv")
emitRenderedFirstFrame(core, mediaGeneration = 7)
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
emitRenderedFirstFrame(core, mediaGeneration = 7)
invokeVideoDecoderRecoveryHealth(
core,
currentPositionMs = VideoDecoderRecoveryPolicy.HEALTHY_PLAYBACK_PROGRESS_MS,
isPlaying = true
)
assertEquals(0, getField(core, "videoDecoderRecoveryConsecutiveAttempts"))
assertEquals(1, getField(core, "videoDecoderRecoveryTotalAttempts"))
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
assertEquals(0, delegate.fallbackRequests)
assertEquals(2, getField(core, "videoDecoderRecoveryTotalAttempts"))
invokePlayerError(core, videoDecoderError(), mediaGeneration = 7)
assertEquals(1, delegate.fallbackRequests)
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
private fun setField(target: Any, name: String, value: Any?) {
target.javaClass.getDeclaredField(name).apply {
isAccessible = true
@@ -233,6 +337,75 @@ class ExoPlayerFallbackTerminalTest {
}
}
private fun emitRenderedFirstFrame(core: ExoPlayerCore, mediaGeneration: Int) {
val mediaItem = MediaItem.Builder()
.setMediaId(mediaGeneration.toString())
.setUri("file:///video.mkv")
.build()
val timeline = SinglePeriodTimeline(
1_000_000L,
true,
false,
false,
null,
mediaItem
)
val eventTime = AnalyticsListener.EventTime(
0L,
timeline,
0,
null,
0L,
timeline,
0,
null,
0L,
0L
)
val analytics = getField(core, "decoderHangListener") as AnalyticsListener
analytics.onRenderedFirstFrame(eventTime, Any(), 0L)
}
private fun videoDecoderError(): ExoPlaybackException = ExoPlaybackException.createForRenderer(
IllegalStateException("codec reclaimed"),
"MediaCodecVideoRenderer",
0,
Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build(),
C.FORMAT_HANDLED,
false,
PlaybackException.ERROR_CODE_DECODING_FAILED
)
private fun invokePlayerError(
core: ExoPlayerCore,
error: PlaybackException,
mediaGeneration: Int
) {
ExoPlayerCore::class.java.getDeclaredMethod(
"handlePlayerError",
PlaybackException::class.java,
Int::class.javaPrimitiveType
).apply {
isAccessible = true
invoke(core, error, mediaGeneration)
}
}
private fun invokeVideoDecoderRecoveryHealth(
core: ExoPlayerCore,
currentPositionMs: Long,
isPlaying: Boolean
) {
ExoPlayerCore::class.java.getDeclaredMethod(
"updateVideoDecoderRecoveryHealth",
Long::class.javaPrimitiveType,
Boolean::class.javaPrimitiveType
).apply {
isAccessible = true
invoke(core, currentPositionMs, isPlaying)
}
}
private fun frameWatchdogElapsed(core: ExoPlayerCore, nowMs: Long, isPlaying: Boolean): Long {
val method = ExoPlayerCore::class.java.getDeclaredMethod(
"frameWatchdogElapsedMs",
@@ -0,0 +1,109 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.PlaybackException
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VideoDecoderRecoveryPolicyTest {
@Test
fun runtimeVideoDecoderFailuresAreRecoverable() {
assertTrue(
VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
isVideoRenderer = true
)
)
assertTrue(
VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(
PlaybackException.ERROR_CODE_DECODING_FAILED,
isVideoRenderer = true
)
)
}
@Test
fun formatAndNonVideoErrorsAreNotRecoverable() {
assertFalse(
VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED,
isVideoRenderer = true
)
)
assertFalse(
VideoDecoderRecoveryPolicy.isTransientVideoDecoderError(
PlaybackException.ERROR_CODE_DECODING_FAILED,
isVideoRenderer = false
)
)
}
@Test
fun runtimeFailureRequiresAWarmDecoderAndAvailableBudgets() {
assertFalse(
VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrame = false,
consecutiveAttempts = 0,
totalAttempts = 0
)
)
assertTrue(
VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrame = true,
consecutiveAttempts = 0,
totalAttempts = 0
)
)
assertFalse(
VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrame = true,
consecutiveAttempts = 1,
totalAttempts = 1
)
)
assertFalse(
VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrame = true,
consecutiveAttempts = 0,
totalAttempts = VideoDecoderRecoveryPolicy.MAX_ATTEMPTS_PER_MEDIA
)
)
}
@Test
fun sustainedPlaybackRequiresAFrameAndForwardProgress() {
assertFalse(
VideoDecoderRecoveryPolicy.hasSustainedPlayback(
hasRenderedFrame = false,
isPlaying = true,
recoveryPositionMs = 10_000,
currentPositionMs = 13_000
)
)
assertFalse(
VideoDecoderRecoveryPolicy.hasSustainedPlayback(
hasRenderedFrame = true,
isPlaying = false,
recoveryPositionMs = 10_000,
currentPositionMs = 13_000
)
)
assertFalse(
VideoDecoderRecoveryPolicy.hasSustainedPlayback(
hasRenderedFrame = true,
isPlaying = true,
recoveryPositionMs = 10_000,
currentPositionMs = 12_999
)
)
assertTrue(
VideoDecoderRecoveryPolicy.hasSustainedPlayback(
hasRenderedFrame = true,
isPlaying = true,
recoveryPositionMs = 10_000,
currentPositionMs = 13_000
)
)
}
}