From ecc55d9b3658338ea60d7bdd0f778817bde6cb84 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:23:12 +0200 Subject: [PATCH] fix(android): retry ExoPlayer after decoder loss close #1540 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 139 ++++++++++++-- .../exoplayer/VideoDecoderRecoveryPolicy.kt | 41 +++++ .../ExoPlayerFallbackTerminalTest.kt | 173 ++++++++++++++++++ .../VideoDecoderRecoveryPolicyTest.kt | 109 +++++++++++ 4 files changed, 444 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicy.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicyTest.kt diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 76858666..ea81bd7d 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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() private val subtitleTrackGroupMap = mutableMapOf() - 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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicy.kt new file mode 100644 index 00000000..c2b8fcae --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicy.kt @@ -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 +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt index b285e012..57b8396a 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt @@ -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", diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicyTest.kt new file mode 100644 index 00000000..0f4c0f43 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/VideoDecoderRecoveryPolicyTest.kt @@ -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 + ) + ) + } +}