@@ -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
|
||||
}
|
||||
+173
@@ -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",
|
||||
|
||||
+109
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user