fix(player): report every AudioTrack release so a failed one can recover

An episode that opens but never plays, forever, with no error and no way out
except force-quitting the app. The reporter's log has the whole shape: media
opens at 85206ms, the first video frame renders, `AudioTrack init failed 0
Config(48000, 252, 5, 40000)` is logged exactly once, and the position never
moves again. Force-quitting fixes it for a while, which is the tell — the state
that breaks recovery is process-wide and static.

`DefaultAudioSink` releases its `AudioOutput` on every flush — every seek, every
renderer disable, every reconfigure — and increments a private static
`pendingReleaseCount` as it does. It decrements only from `Listener::onReleased`.
`RawPositionAudioOutput.release` never called `delegate.release()` for a
cacheable output, and it forwarded `addListener` straight through, so the sink's
listener sat on the real output while the wrapper was parked and the increment
was never balanced. media3's own delivery is lossy too: it posts `onReleased` to
the playback looper, which `ExoPlayer.release()` has already quit by the time the
20ms-delayed release runs, so even a real release drops its decrement at
teardown.

A counter that never returns to zero silently disables media3's escalation of
both init and write failures: `PendingExceptionHolder` arms its throw deadline
only when nothing is pending, and short-circuits every retry while something is.
So the `InitializationException` is never thrown, the audio renderer never
becomes ready, and the player is pinned in `STATE_BUFFERING`. No
`PlaybackException` means `retryAfterAudioTrackError` never runs, which is why
the same failure recovered onto decoded PCM earlier in the same log and hung
outright later.

The wrapper now owns the listener set and answers every flush exactly once: at
once when it parks the track, because a parked track is never going to release;
on the delegate's confirmation for a real release; and from the provider at
teardown, where nothing else ever will. Bitstream outputs are not parked at all —
a direct route is often single-instance and a parked one would block its own
successor.

An eviction therefore builds its replacement while the old AudioTrack is still
going away, as upstream does. Holding the count open across the park to buy
media3 patience for that window was tried and is worse: it pins the counter above
zero for the whole live track after the first seek, which is the hang above.
Refusing to allocate until the release confirms is worse too — the refusal
reaches media3 as an init failure with no pending release to excuse it, so the
200ms deadline starts immediately and a slow TV teardown turns an ordinary config
change into a playback error. If the overlapping allocation does fail, media3
escalates into the audio recovery ladder and the watchdog below backs it up.

Because no amount of accounting hygiene guarantees media3 will raise the next
failure, add the watchdog that was missing. Nothing covered "buffering, holding
data, not moving": the frame watchdog wants `STATE_READY` and zero frames, the
decoder-hang check is cancelled by the first frame, `ResumeStallPolicy` treats a
frozen clock as explicitly not its business, `EndOfStreamPolicy` wants the
position past the duration, and media3's stuck-buffering detector wants an empty
buffer. `BufferingStallPolicy` covers exactly that hole and escalates through the
existing audio ladder — now shared with the exception path — then to the mpv
backend rather than leaving a spinner up.

The watchdog only indicts a player that could have started. `DefaultLoadControl`
is configured to hold playback until 5s is buffered after a rebuffer, so the
stall threshold is derived from that same constant rather than guessing at one,
and a buffer below it reads as starved — the loader's business, not the
renderer's. Starvation also restarts the stall clock, so a minute of network
rebuffering cannot bank the timeout and have the first poll after recovery
report a stall that never happened.

Also raise the passthrough buffer to a second. media3 defaults it to 250ms, which
the AC3 factor doubles to the 40000 bytes that failed here, and 1.10.1's only
retry is to keep halving; upstream adopted the same 1s floor in #3207.

Recovery now resumes from the furthest position reached rather than `lastPosition`,
which the poller writes down as freely as up — a dead clock reporting 0 is how an
audio recovery restarted a resumed episode from the top. On the Dart side the
episode loading flags are cleared on every exit of the in-place reload, not just
the success and rollback paths; a flag stranded by a superseded reload made the
Next button a no-op for the rest of the session.

close #1790
This commit is contained in:
edde746
2026-08-05 12:03:05 +02:00
parent 80d3537975
commit b97a22c213
12 changed files with 1072 additions and 90 deletions
@@ -0,0 +1,36 @@
package com.edde746.plezy.exoplayer
/**
* Decides which audio outputs `RawPositionOutputProvider` may park in its reuse cache instead of
* releasing on a sink flush.
*
* The cache exists so a seek does not tear down and rebuild the hardware audio pipeline, which
* costs 7-10s of silence on Android TV boxes that reinitialize tunneled output slowly (Sony
* Bravia class). That win is real for decoded PCM, but parking a *bitstream* output is a
* different trade: a direct/passthrough `AudioTrack` occupies the platform's direct output for as
* long as it is held, and several HDMI HALs expose only one. A parked AC3 track therefore blocks
* the next AC3 track from being created at all, and the failure surfaces as
* `UnsupportedOperationException: Cannot create AudioTrack` rather than as a slow seek (#1790).
*
* Parking also has to stay honest about media3's process-wide pending-release accounting: a
* cached output is *not* releasing, so `RawPositionAudioOutput` signals `onReleased` immediately
* for it. That is only true because nothing scarce is being held, which is exactly what this
* policy guarantees.
*
* Offload outputs are excluded for the same reason plus their separate gapless/end-of-stream
* state machine, which the cache does not model.
*/
internal object AudioOutputCachePolicy {
/**
* `AudioTrack.flush()` only reliably resets a stopped track from N MR1 onwards; below that the
* cache would hand back a track carrying the previous playback's state.
*/
const val MIN_SDK_INT = 25
fun mayCache(encoding: Int, isOffload: Boolean, sdkInt: Int): Boolean {
if (sdkInt < MIN_SDK_INT) return false
if (isOffload) return false
return isPcmEncoding(encoding)
}
}
@@ -1,6 +1,7 @@
package com.edde746.plezy.exoplayer
import android.content.Context
import android.media.AudioFormat
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
@@ -25,6 +26,19 @@ internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeT
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
/**
* Linear PCM output encodings, i.e. the sink decoded the bitstream instead of
* passing it through. Mirrors the platform's `AudioFormat.ENCODING_PCM_*` set.
*/
internal fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
/**
* mpv `audio-spdif` codec names and the exact platform encoding a route must
* advertise to carry that bitstream.
@@ -0,0 +1,82 @@
package com.edde746.plezy.exoplayer
/**
* Decision logic for the buffering stall watchdog (#1790).
*
* A renderer that can never become ready pins ExoPlayer in `STATE_BUFFERING` with a full buffer
* and a frozen clock. The reported case is an `AudioTrack` that fails to initialize: media3 logs a
* sink error, keeps retrying, and — when its process-wide pending-release accounting has been
* knocked out of balance — never converts the failure into a `PlaybackException`. Nothing else
* notices. The frame watchdog wants `STATE_READY` and zero rendered frames, the decoder-hang check
* is cancelled by the first frame, [ResumeStallPolicy] needs `isPlaying` and deliberately treats a
* frozen clock as "not a decoder stall", [EndOfStreamPolicy] needs the position past the duration,
* and media3's own stuck-buffering detector needs an *empty* buffer. So the user sits on a spinner
* with no error and no way out.
*
* This watchdog covers exactly that hole: buffering, intending to play, holding data, and not
* moving. A starved buffer is left alone — that is an ordinary network stall and the loader is the
* right thing to wait for.
*/
internal object BufferingStallPolicy {
/** Poll interval while buffering. */
const val CHECK_INTERVAL_MS = 2_000L
/**
* How long a buffering player may hold enough data to start and still not move before it is
* called stalled. Long enough to sit out a slow decoder handover or a track reselection, short
* enough that a user has not yet given up and force-quit.
*
* Only time spent above [MIN_BUFFER_AHEAD_MS] counts: a rebuffer that takes a minute to refill
* is the loader doing its job, and the clock restarts once it is done.
*/
const val STALL_TIMEOUT_MS = 12_000L
/** Position movement that counts as progress rather than clock jitter. */
const val PROGRESS_EPSILON_MS = 250L
/**
* Media buffered ahead of the position before a motionless player is anomalous at all.
*
* This has to clear `DefaultLoadControl`'s own play-start bar with room to spare, or the
* watchdog would indict the player for obeying it: below
* [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS] the load control deliberately keeps
* playback in `STATE_BUFFERING`, and the two evaluate on independent schedules.
*/
const val MIN_BUFFER_AHEAD_MS = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + 2_000L
enum class Verdict {
/** The position moved; re-baseline and keep watching. */
HEALTHY,
/** Enough data to start, not moving, but not for long enough yet. */
WAITING,
/** Not moving because there is not yet enough to play. An ordinary rebuffer; not ours. */
STARVED,
/** Enough data to start, playback is wanted, and nothing has moved. */
STALLED
}
fun evaluate(
elapsedMs: Long,
baselinePositionMs: Long,
currentPositionMs: Long,
bufferedPositionMs: Long
): Verdict = when {
currentPositionMs - baselinePositionMs >= PROGRESS_EPSILON_MS -> Verdict.HEALTHY
bufferedPositionMs - currentPositionMs < MIN_BUFFER_AHEAD_MS -> Verdict.STARVED
elapsedMs < STALL_TIMEOUT_MS -> Verdict.WAITING
else -> Verdict.STALLED
}
/**
* Whether [verdict] means the elapsed stall clock was measuring something the watchdog is not
* responsible for, and must restart.
*
* Without this a long rebuffer accrues the whole timeout while starved, and the first poll after
* the buffer refills reports a stall that never happened.
*/
fun resetsStallClock(verdict: Verdict): Boolean = verdict == Verdict.HEALTHY || verdict == Verdict.STARVED
}
@@ -275,6 +275,14 @@ class ExoPlayerCore(private val activity: Activity) :
private var resumeStallRecoveryCount = 0
private var loggedResumeStallCap = false
// Buffering stall watchdog (#1790): a renderer that never becomes ready pins the player in
// STATE_BUFFERING with a full buffer and a frozen clock, and — when media3 absorbs the
// underlying failure instead of raising it — nothing else ever notices. Armed on every
// transition into buffering; see BufferingStallPolicy.
private var bufferingStallRunnable: Runnable? = null
private var bufferingStallSinceMs = 0L
private var bufferingStallBaselinePositionMs = 0L
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -323,8 +331,17 @@ class ExoPlayerCore(private val activity: Activity) :
// Track state for event emission
private var lastPosition: Long = 0
/** Position to use for fallback: max of current position and pending start position. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs)
/**
* Highest position actually reached in the current media generation, or the last explicit seek
* target. [lastPosition] tracks the emitted timeline and follows the player down as well as up,
* so a renderer that reports 0 while its clock is dead would otherwise hand recovery a 0ms
* resume point — which is how an audio recovery restarted a resumed episode from the top
* (#1790). Recovery reads this instead.
*/
private var lastKnownGoodPositionMs: Long = 0L
/** Position to use for fallback: the furthest of the tracked positions. */
private val effectivePosition: Long get() = maxOf(lastPosition, pendingStartPositionMs, lastKnownGoodPositionMs)
private var lastDuration: Long = 0
private var lastBufferedPosition: Long = 0
private var positionUpdateRunnable: Runnable? = null
@@ -752,9 +769,19 @@ class ExoPlayerCore(private val activity: Activity) :
setTargetBufferBytes(targetBufferBytes)
setPrioritizeTimeOverSizeThresholds(false)
if (availableMB <= 2048) {
setBufferDurationsMs(15_000, 50_000, 1_000, 5_000)
setBufferDurationsMs(
15_000,
50_000,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
} else {
setBufferDurationsMs(30_000, 60_000, 1_000, 5_000)
setBufferDurationsMs(
30_000,
60_000,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_MS,
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
}.build()
emitLog(
@@ -963,6 +990,7 @@ class ExoPlayerCore(private val activity: Activity) :
// classifies an EOF by position against duration.
if (currentPosition != lastPosition && terminalErrorGeneration != currentMediaGeneration) {
lastPosition = currentPosition
if (currentPosition > lastKnownGoodPositionMs) lastKnownGoodPositionMs = currentPosition
delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
}
@@ -1004,6 +1032,7 @@ class ExoPlayerCore(private val activity: Activity) :
private fun resetPlaybackProgress(startPositionMs: Long) {
lastPosition = startPositionMs
lastKnownGoodPositionMs = startPositionMs
lastDuration = 0L
lastBufferedPosition = 0L
// Dart already seeds the visible timeline before open. Emitting native
@@ -1083,8 +1112,10 @@ class ExoPlayerCore(private val activity: Activity) :
when (state) {
Player.STATE_BUFFERING -> {
delegate?.onPropertyChange("paused-for-cache", true)
armBufferingStallWatchdog()
}
Player.STATE_READY -> {
cancelBufferingStallWatchdog()
// Restore start position if it was lost during track reselection
// (e.g. tunneling state change in onTracksChanged triggers renderer teardown)
if (pendingStartPositionMs > 0L) {
@@ -1120,6 +1151,7 @@ class ExoPlayerCore(private val activity: Activity) :
}
Player.STATE_ENDED -> {
stopFrameWatchdog()
cancelBufferingStallWatchdog()
delegate?.onPropertyChange("eof-reached", true)
delegate?.onEvent("end-file", mapOf("reason" to "eof"))
}
@@ -1206,6 +1238,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
emitSeekable(false, force = true)
// If native DV7 failed, retry with conversion before falling to MPV
@@ -1346,7 +1379,7 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousDecoder = decoderInitName
pendingTrackRestore = pendingTrackRestore ?: captureTrackRestore()?.also { restore ->
@@ -1368,6 +1401,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "video decoder recovery",
forceSelector = true,
@@ -1413,25 +1447,43 @@ class ExoPlayerCore(private val activity: Activity) :
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean {
if (!isAudioTrackError(error.errorCode)) return false
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
return recoverAudioOutputInPlace(
label = PlaybackException.getErrorCodeName(error.errorCode),
reason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}",
fallbackFormat = errorFormat,
blockDirectOutput = true
)
}
/**
* Re-prepares the current media with the audio output forced onto a path that has not just
* failed. Shared by the [PlaybackException] route and by the buffering stall watchdog, which
* reaches the same failure without an exception ever being raised (#1790).
*/
private fun recoverAudioOutputInPlace(
label: String,
reason: String,
fallbackFormat: Format?,
blockDirectOutput: Boolean
): Boolean {
val player = exoPlayer ?: return false
val uri = currentMediaUri ?: return false
if (audioRecoveryAttempts >= MAX_AUDIO_RECOVERY_ATTEMPTS) {
emitLog(
"warn",
"audio-recovery",
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for ${PlaybackException.getErrorCodeName(error.errorCode)}"
"ExoPlayer audio recovery exhausted after $audioRecoveryAttempts attempts for $label"
)
return false
}
val selectedFormat = selectedAudioFormat()
val errorFormat = (error as? ExoPlaybackException)?.rendererFormat?.takeIf { format ->
format.sampleMimeType?.startsWith("audio/") == true
}
val recoveryFormat = selectedFormat ?: errorFormat
val recoveryFormat = selectedAudioFormat() ?: fallbackFormat
val actions = mutableListOf<String>()
if (blockDirectOutput) {
recoveryFormat?.sampleMimeType
?.takeIf { isEncodedAudioMimeType(it) }
?.let { mimeType ->
@@ -1439,7 +1491,7 @@ class ExoPlayerCore(private val activity: Activity) :
actions.add("force-decoded-pcm($mimeType)")
}
}
}
if (!tunnelingDisabledForAudioRecovery) {
tunnelingDisabledForAudioRecovery = true
actions.add("disable-tunneling")
@@ -1447,7 +1499,7 @@ class ExoPlayerCore(private val activity: Activity) :
if (actions.isEmpty()) actions.add("reload")
audioRecoveryAttempts++
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
val previousAudioTrackConfig = lastAudioTrackConfig
pendingStartPositionMs = savedPosition
@@ -1455,11 +1507,12 @@ class ExoPlayerCore(private val activity: Activity) :
audioDecoderInitName = null
lastAudioTrackConfig = null
lastAudioRecoveryAction = actions.joinToString(",")
lastAudioRecoveryReason = "${PlaybackException.getErrorCodeName(error.errorCode)}: ${error.message ?: causeChain.ifEmpty { "unknown" }}"
lastAudioRecoveryReason = reason
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(reason = "audio recovery", forceSelector = true)
emitLog(
@@ -2632,15 +2685,6 @@ class ExoPlayerCore(private val activity: Activity) :
return selectedAudioGroup.mediaTrackGroup.getFormat(0)
}
private fun isPcmEncoding(encoding: Int): Boolean = when (encoding) {
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_FLOAT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT -> true
else -> false
}
private fun formatAudioSummary(format: Format): String {
val parts = mutableListOf<String>()
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
@@ -3124,6 +3168,99 @@ class ExoPlayerCore(private val activity: Activity) :
}
}
// Buffering stall watchdog (#1790) — see BufferingStallPolicy for the coverage argument.
// Main-thread only, like the watchdogs above.
private fun armBufferingStallWatchdog() {
cancelBufferingStallWatchdog()
val player = exoPlayer ?: return
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = player.currentPosition
val mediaGeneration = currentMediaGeneration
bufferingStallRunnable = object : Runnable {
override fun run() {
if (mediaGeneration != currentMediaGeneration) return
if (disposing || !isInitialized) return
val current = exoPlayer ?: return
if (current.playbackState != Player.STATE_BUFFERING) {
cancelBufferingStallWatchdog()
return
}
val now = System.currentTimeMillis()
// Paused mid-buffer: nothing is meant to progress, so the clock does not run.
if (!current.playWhenReady) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
return
}
val elapsedMs = now - bufferingStallSinceMs
val verdict = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = bufferingStallBaselinePositionMs,
currentPositionMs = current.currentPosition,
bufferedPositionMs = current.bufferedPosition
)
if (verdict == BufferingStallPolicy.Verdict.STALLED) {
cancelBufferingStallWatchdog()
recoverFromBufferingStall(current, mediaGeneration, elapsedMs)
return
}
// Moving again, or still short of the load control's play-start threshold: either way the
// clock was not measuring a stall this watchdog owns, so it restarts.
if (BufferingStallPolicy.resetsStallClock(verdict)) {
bufferingStallSinceMs = now
bufferingStallBaselinePositionMs = current.currentPosition
}
handler.postDelayed(this, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
}
handler.postDelayed(bufferingStallRunnable!!, BufferingStallPolicy.CHECK_INTERVAL_MS)
}
private fun cancelBufferingStallWatchdog() {
bufferingStallRunnable?.let { handler.removeCallbacks(it) }
bufferingStallRunnable = null
}
private fun recoverFromBufferingStall(player: ExoPlayer, mediaGeneration: Int, stalledMs: Long) {
val positionMs = player.currentPosition
val playWhenReady = player.playWhenReady
val sinkError = lastAudioSinkError
emitLog(
"warn",
"buffering-stall",
"No progress for ${stalledMs}ms at ${positionMs}ms while buffering " +
"(buffered=${player.bufferedPosition}ms, audio=${selectedAudioFormat()?.let { formatAudioSummary(it) } ?: "unknown"}, " +
"tunneling=$currentTunneledPlayback, lastSinkError=${sinkError ?: "none"})"
)
// A sink error with no exception behind it is the shape media3 absorbs, so take the audio
// path off bitstream. Without one, the reload alone is the unstick.
if (recoverAudioOutputInPlace(
label = "buffering stall",
reason = "buffering stall after ${stalledMs}ms: ${sinkError ?: "no sink error reported"}",
fallbackFormat = null,
blockDirectOutput = sinkError != null
)
) {
return
}
// In-place recovery is spent. Hand over to the MPV backend, which has its own audio path,
// rather than leaving the user on a spinner.
val uri = currentMediaUri ?: return
requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri,
positionMs = positionMs,
playWhenReady = playWhenReady,
errorMessage = "Playback stalled while buffering for ${stalledMs}ms"
)
}
// Public API
fun open(
@@ -3140,6 +3277,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
resumeStallRecoveryCount = 0
loggedResumeStallCap = false
@@ -3451,7 +3589,7 @@ class ExoPlayerCore(private val activity: Activity) :
val uri = currentMediaUri ?: return false
if (currentMediaIsLive) return false
val savedPosition = maxOf(player.currentPosition, lastPosition, pendingStartPositionMs)
val savedPosition = maxOf(player.currentPosition, effectivePosition)
val savedPlayWhenReady = player.playWhenReady
pendingTrackRestore = captureTrackRestore()?.also { restore ->
emitLog(
@@ -3479,6 +3617,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
applyTrackSelectorPolicy(
reason = "DV reload",
@@ -3508,6 +3647,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
exoPlayer?.stop()
emitSeekable(false, force = true)
setVisible(false)
@@ -3527,6 +3667,7 @@ class ExoPlayerCore(private val activity: Activity) :
pendingStartPositionMs = 0L
player.seekTo(clampedPositionMs)
lastPosition = clampedPositionMs
lastKnownGoodPositionMs = clampedPositionMs
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
}
@@ -4012,6 +4153,7 @@ class ExoPlayerCore(private val activity: Activity) :
stopFrameWatchdog()
cancelDecoderHangCheck()
cancelResumeStallWatchdog()
cancelBufferingStallWatchdog()
stopPositionUpdates()
handler.removeCallbacksAndMessages(null)
// releasePending (not clearVideoFrameRate): on the ExoPlayer→MPV fallback
@@ -319,6 +319,14 @@ class ExoPlayerPlugin :
fallbackInProgress = false
}
// An initialize without an intervening dispose would otherwise orphan the previous core
// along with its ExoPlayer, audio sink, codecs and surface views — nothing else holds a
// reference, so they would never be released.
playerCore?.let { stale ->
playerCore = null
stale.dispose()
}
try {
val core = ExoPlayerCore(currentActivity).apply {
delegate = this@ExoPlayerPlugin
@@ -31,6 +31,16 @@ internal object LoadControlPolicy {
*/
const val MIN_TARGET_BYTES = 32 * MIB
/** `DefaultLoadControl` play-start threshold on a first buffer. */
const val BUFFER_FOR_PLAYBACK_MS = 1_000
/**
* `DefaultLoadControl` play-start threshold after a rebuffer. Below this the player is
* *supposed* to stay in `STATE_BUFFERING`, so anything judging a buffering player has to clear
* this bar before it can call the wait anomalous — see [BufferingStallPolicy].
*/
const val BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5_000
/**
* Fraction of a memory budget the allocator may claim. Matches the threshold the Buffer
* Size setting already warns at (`value > heapMB / 4`).
@@ -161,6 +161,11 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
.setMinPcmBufferDurationUs(500_000)
.setMaxPcmBufferDurationUs(1_000_000)
.setPcmBufferMultiplicationFactor(4)
// media3 defaults passthrough to 250ms, which the AC3 factor doubles to 500ms — 40000
// bytes at 640 kbps. Some HDMI routes reject a buffer that short outright and the only
// retry media3 1.10.1 has is to keep halving it (#1790). Ask for a second up front;
// upstream adopted the same 1s floor as its last-resort retry in #3207.
.setPassthroughBufferDurationUs(500_000)
.build()
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
@@ -514,17 +519,46 @@ internal class DvSanitizingVideoRenderer(
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
//
// Two invariants keep that safe (#1790):
//
// 1. Only outputs [AudioOutputCachePolicy] admits are parked, so the cache never holds a
// scarce direct/passthrough route hostage while the next one is being created.
// 2. Every flush is answered by exactly one onReleased, promptly.
//
// DefaultAudioSink increments a *static, process-wide* counter on every flush and decrements it
// only from `Listener::onReleased`, which media3 delivers by posting to the playback looper — a
// looper that is already dead by the time a player-release actually completes. A dropped callback
// pins the counter above zero, and any nonzero value disables media3's escalation of *both* init
// and write failures: `PendingExceptionHolder` refuses to arm its throw deadline and every retry
// short-circuits, so a sink error never becomes a PlaybackException and playback hangs in
// STATE_BUFFERING with no recovery. The wrapper therefore owns the listener set and answers the
// flush itself when it parks the track, because a parked track is never going to release.
//
// Deferring that answer until the parked track is really evicted is tempting — it would let
// media3 stay patient through the eviction, whose replacement is built while the old AudioTrack
// is still going away — but it holds the counter above zero for the whole live track after the
// first seek, which is the very hang above. So the eviction overlap is tolerated instead, as
// upstream tolerates it: if the replacement does fail to allocate, media3 escalates on its own
// 200ms deadline into the audio recovery ladder, and the buffering stall watchdog backs that up.
//
// Everything below is confined to the ExoPlayer playback thread: sink flush/release, provider
// lookups, and media3's own onReleased delivery all run there.
@OptIn(UnstableApi::class)
private class RawPositionOutputProvider(
internal class RawPositionOutputProvider(
private val delegate: AudioOutputProvider,
private val rawPositionUs: AtomicLong,
private val log: ((String, String, String) -> Unit)?
private val log: ((String, String, String) -> Unit)?,
private val sdkInt: Int = Build.VERSION.SDK_INT
) : AudioOutputProvider {
private var cachedOutput: RawPositionAudioOutput? = null
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
/** Outputs whose real release was started but whose completion has not been observed yet. */
private val unsettledReleases = LinkedHashSet<RawPositionAudioOutput>()
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
@@ -533,14 +567,24 @@ private class RawPositionOutputProvider(
val cached = cachedOutput
if (cached != null && cachedConfig == config) {
cachedOutput = null
cached.markReacquired()
return cached
}
cached?.forceRelease()
cachedOutput = null
cachedConfig = null
// The replacement is built while the evicted track is still going away. Upstream does the
// same; the alternatives are worse (see the note above this class).
val realOutput = delegate.getAudioOutput(config)
cachedConfig = config
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
return RawPositionAudioOutput(
delegate = realOutput,
rawPositionUs = rawPositionUs,
provider = this,
mayCache = AudioOutputCachePolicy.mayCache(config.encoding, config.isOffload, sdkInt),
log = log
)
}
fun returnToCache(output: RawPositionAudioOutput) {
@@ -551,6 +595,14 @@ private class RawPositionOutputProvider(
cachedOutput = output
}
fun onRealReleaseStarted(output: RawPositionAudioOutput) {
unsettledReleases.add(output)
}
fun onReleaseSettled(output: RawPositionAudioOutput) {
unsettledReleases.remove(output)
}
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
@@ -561,15 +613,21 @@ private class RawPositionOutputProvider(
cachedOutput?.forceRelease()
cachedOutput = null
cachedConfig = null
// Player teardown: media3 schedules the real AudioTrack release with a delay and posts the
// completion back to the playback looper this call is in the middle of quitting, so nothing
// will ever deliver it. Settle here rather than leak the accounting for the whole process.
for (output in unsettledReleases.toList()) output.settleReleaseNow()
unsettledReleases.clear()
delegate.release()
}
}
@OptIn(UnstableApi::class)
private class RawPositionAudioOutput(
internal class RawPositionAudioOutput(
private val delegate: AudioOutput,
private val rawPositionUs: AtomicLong,
private val provider: RawPositionOutputProvider,
private val mayCache: Boolean,
private val log: ((String, String, String) -> Unit)?
) : AudioOutput {
@@ -578,6 +636,46 @@ private class RawPositionAudioOutput(
private var writtenBytes = 0L
private var failed = false
/**
* DefaultAudioSink registers here instead of on the real output, so the wrapper can report the
* releases media3 will not: a parked output never really releases, and a released output's
* completion callback is posted to a playback looper that may already be gone. The set is
* cleared on every release report and repopulated by the sink on the next acquisition, which
* also stops one stale listener per reuse from piling up on the real output.
*/
private val listeners = mutableListOf<AudioOutput.Listener>()
private var releaseSignalled = false
private val currentListener: AudioOutput.Listener?
get() = listeners.lastOrNull()
private val forwarder = object : AudioOutput.Listener {
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) {
currentListener?.onPositionAdvancing(playoutStartSystemTimeMs)
}
override fun onOffloadDataRequest() {
currentListener?.onOffloadDataRequest()
}
override fun onOffloadPresentationEnded() {
currentListener?.onOffloadPresentationEnded()
}
override fun onUnderrun() {
currentListener?.onUnderrun()
}
override fun onReleased() {
provider.onReleaseSettled(this@RawPositionAudioOutput)
signalReleased()
}
}
init {
delegate.addListener(forwarder)
}
override fun getPositionUs(): Long {
val pos = delegate.getPositionUs()
rawPositionUs.set(pos)
@@ -626,24 +724,50 @@ private class RawPositionAudioOutput(
override fun release() {
rawPositionUs.set(Long.MIN_VALUE)
if (failed) {
delegate.release()
return
}
if (Build.VERSION.SDK_INT >= 25) {
if (!failed && mayCache) {
delegate.stop()
delegate.flush()
provider.returnToCache(this)
} else {
delegate.release()
// A parked track is never going to release, so answer the flush now. Holding the sink's
// pending-release count open for it would disable media3's escalation of every later sink
// error, for as long as the track stays parked or live — the #1790 hang, re-armed by an
// ordinary seek.
signalReleased()
return
}
startRealRelease()
}
/** Releases for real even when the output would otherwise be cacheable. */
fun forceRelease() {
rawPositionUs.set(Long.MIN_VALUE)
startRealRelease()
}
/** Reports a release whose completion callback can no longer be delivered. */
fun settleReleaseNow() {
provider.onReleaseSettled(this)
signalReleased()
}
/** Re-arms the wrapper for a fresh acquisition out of the provider's cache. */
fun markReacquired() {
releaseSignalled = false
}
private fun startRealRelease() {
provider.onRealReleaseStarted(this)
delegate.release()
}
private fun signalReleased() {
if (releaseSignalled) return
releaseSignalled = true
val notified = listeners.toList()
listeners.clear()
for (listener in notified) listener.onReleased()
}
override fun setVolume(volume: Float) = delegate.setVolume(volume)
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
override fun getAudioSessionId() = delegate.getAudioSessionId()
@@ -651,8 +775,12 @@ private class RawPositionAudioOutput(
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
override fun isStalled() = delegate.isStalled()
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
@@ -0,0 +1,85 @@
package com.edde746.plezy.exoplayer
import android.media.AudioFormat
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AudioOutputCachePolicyTest {
@Test
fun decodedPcmIsCacheable() {
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = 34
)
)
}
@Test
fun everyPcmWidthIsCacheable() {
val pcmEncodings = listOf(
AudioFormat.ENCODING_PCM_8BIT,
AudioFormat.ENCODING_PCM_16BIT,
AudioFormat.ENCODING_PCM_24BIT_PACKED,
AudioFormat.ENCODING_PCM_32BIT,
AudioFormat.ENCODING_PCM_FLOAT
)
for (encoding in pcmEncodings) {
assertTrue(
"expected $encoding to be cacheable",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
/** Parking a bitstream track holds the platform's direct output, which is often single-instance. */
@Test
fun bitstreamOutputIsNeverCacheable() {
val passthroughEncodings = listOf(
AudioFormat.ENCODING_AC3,
AudioFormat.ENCODING_E_AC3,
AudioFormat.ENCODING_E_AC3_JOC,
AudioFormat.ENCODING_DTS,
AudioFormat.ENCODING_DTS_HD,
AudioFormat.ENCODING_DOLBY_TRUEHD
)
for (encoding in passthroughEncodings) {
assertFalse(
"expected $encoding to be excluded from the cache",
AudioOutputCachePolicy.mayCache(encoding = encoding, isOffload = false, sdkInt = 34)
)
}
}
@Test
fun offloadOutputIsNeverCacheable() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = true,
sdkInt = 34
)
)
}
@Test
fun cachingIsOffBelowTheFlushableApiLevel() {
assertFalse(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT - 1
)
)
assertTrue(
AudioOutputCachePolicy.mayCache(
encoding = AudioFormat.ENCODING_PCM_16BIT,
isOffload = false,
sdkInt = AudioOutputCachePolicy.MIN_SDK_INT
)
)
}
}
@@ -0,0 +1,156 @@
package com.edde746.plezy.exoplayer
import com.edde746.plezy.exoplayer.BufferingStallPolicy.Verdict
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BufferingStallPolicyTest {
private val position = 85_206L
private fun evaluate(
elapsedMs: Long = BufferingStallPolicy.STALL_TIMEOUT_MS,
baselinePositionMs: Long = position,
currentPositionMs: Long = position,
bufferedAheadMs: Long = 30_000L
) = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = baselinePositionMs,
currentPositionMs = currentPositionMs,
bufferedPositionMs = currentPositionMs + bufferedAheadMs
)
// Progress
@Test
fun advancingPositionIsHealthy() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS)
)
}
@Test
fun advancingPositionOutranksAnExpiredTimeout() {
assertEquals(
Verdict.HEALTHY,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, currentPositionMs = position + 5_000)
)
}
@Test
fun clockJitterBelowTheEpsilonIsNotProgress() {
assertEquals(
Verdict.STALLED,
evaluate(currentPositionMs = position + BufferingStallPolicy.PROGRESS_EPSILON_MS - 1)
)
}
@Test
fun positionMovingBackwardsIsNotProgress() {
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
}
// Timeout
@Test
fun frozenPositionWaitsOutTheTimeout() {
assertEquals(Verdict.WAITING, evaluate(elapsedMs = BufferingStallPolicy.STALL_TIMEOUT_MS - 1))
}
/** The #1790 shape: data available, nothing playing, no error raised. */
@Test
fun bufferedButFrozenIsStalled() {
assertEquals(Verdict.STALLED, evaluate())
}
// Starvation — the loader's problem, not the renderer's
@Test
fun emptyBufferIsStarved() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 0))
}
@Test
fun starvationOutranksAnExpiredTimeout() {
assertEquals(
Verdict.STARVED,
evaluate(elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS, bufferedAheadMs = 0)
)
}
/**
* `DefaultLoadControl` deliberately holds playback in `STATE_BUFFERING` until it has
* [LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS] buffered. A watchdog that indicts
* the player inside that window is indicting it for obeying its own load control.
*/
@Test
fun aBufferBelowTheLoadControlPlayStartThresholdIsStarved() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS.toLong()
)
)
}
@Test
fun theStallThresholdClearsTheLoadControlPlayStartThreshold() {
assertTrue(
"MIN_BUFFER_AHEAD_MS (${BufferingStallPolicy.MIN_BUFFER_AHEAD_MS}) must exceed the load " +
"control's post-rebuffer play-start threshold " +
"(${LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS})",
BufferingStallPolicy.MIN_BUFFER_AHEAD_MS > LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
@Test
fun starvedIsStickyWhileTheBufferStaysThin() {
assertEquals(
Verdict.STARVED,
evaluate(
elapsedMs = 10 * BufferingStallPolicy.STALL_TIMEOUT_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1
)
)
}
// Stall clock ownership
@Test
fun starvationAndProgressBothRestartTheStallClock() {
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.STARVED))
assertTrue(BufferingStallPolicy.resetsStallClock(Verdict.HEALTHY))
}
@Test
fun waitingKeepsTheStallClockRunning() {
assertFalse(BufferingStallPolicy.resetsStallClock(Verdict.WAITING))
}
/**
* Regression: a long network stall used to accrue the whole timeout while starved, so the very
* first poll after the buffer refilled reported a stall that never happened and force-reloaded
* a perfectly healthy rebuffer. Starvation restarts the clock, so the refilled buffer gets the
* full timeout to start playing.
*/
@Test
fun aRecoveringBufferIsNotStalledByTimeSpentStarved() {
val starvedFor = 60_000L
val starved = evaluate(elapsedMs = starvedFor, bufferedAheadMs = 0)
assertEquals(Verdict.STARVED, starved)
assertTrue(BufferingStallPolicy.resetsStallClock(starved))
// The watchdog re-baselines on that verdict, so the next poll starts from zero elapsed.
assertEquals(
Verdict.WAITING,
evaluate(
elapsedMs = BufferingStallPolicy.CHECK_INTERVAL_MS,
bufferedAheadMs = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1
)
)
}
}
@@ -0,0 +1,302 @@
package com.edde746.plezy.exoplayer
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import androidx.annotation.OptIn
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioOutput
import androidx.media3.exoplayer.audio.AudioOutputProvider
import java.nio.ByteBuffer
import java.util.concurrent.atomic.AtomicLong
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* `DefaultAudioSink` increments a static, process-wide pending-release counter on every flush and
* decrements it only when the output reports `onReleased`. A dropped report pins that counter above
* zero for the life of the process, which silently disables media3's own AudioTrack-init retry and
* turns any later init failure into a permanent buffering hang (#1790). These tests pin the
* wrapper's side of that contract: exactly one report per release, from every path.
*/
@OptIn(UnstableApi::class)
class RawPositionAudioOutputTest {
private val rawPositionUs = AtomicLong(Long.MIN_VALUE)
private fun config(
encoding: Int = AudioFormat.ENCODING_PCM_16BIT,
isOffload: Boolean = false,
isTunneling: Boolean = false
): AudioOutputProvider.OutputConfig = AudioOutputProvider.OutputConfig.Builder()
.setEncoding(encoding)
.setSampleRate(48_000)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setBufferSize(40_000)
.setIsOffload(isOffload)
.setIsTunneling(isTunneling)
.build()
private fun provider(delegate: FakeOutputProvider) = RawPositionOutputProvider(delegate, rawPositionUs, log = null, sdkInt = 34)
private fun acquire(
provider: RawPositionOutputProvider,
config: AudioOutputProvider.OutputConfig
): Pair<RawPositionAudioOutput, RecordingListener> {
val output = provider.getAudioOutput(config) as RawPositionAudioOutput
val listener = RecordingListener()
output.addListener(listener)
return output to listener
}
// Release reporting
/**
* A parked track is never going to release, so its flush has to be answered at once. Deferring
* until the eviction would hold `DefaultAudioSink`'s process-wide pending-release count above
* zero for the whole live track after the first seek, and any nonzero value stops media3
* escalating *both* init and write failures — the #1790 hang, re-armed by an ordinary seek.
*/
@Test
fun aParkedOutputAnswersItsFlushImmediately() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("the flush must be answered while the track is parked", 1, listener.releasedCount)
assertFalse("a parked output must keep its AudioTrack", delegate.outputs.single().released)
}
@Test
fun realReleaseReportsOnlyOnceTheDelegateConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
val real = delegate.outputs.single()
assertTrue("a bitstream output must really release", real.released)
assertEquals("release must not be reported before the AudioTrack is gone", 0, listener.releasedCount)
real.confirmReleased()
assertEquals(1, listener.releasedCount)
}
@Test
fun releaseIsReportedOnceWhenTheDelegateNeverConfirms() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
// Player teardown: the sink flushes, then media3 posts the release completion to a playback
// looper that is already quitting, so the confirmation never arrives.
output.release()
provider.release()
assertEquals(1, listener.releasedCount)
}
@Test
fun aLateDelegateConfirmationDoesNotReportTwice() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config(encoding = AudioFormat.ENCODING_AC3))
output.release()
output.settleReleaseNow()
delegate.outputs.single().confirmReleased()
assertEquals(1, listener.releasedCount)
}
// Reuse
@Test
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, _) = acquire(provider, config())
first.release()
val (second, _) = acquire(provider, config())
assertSame(first, second)
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/**
* The sink charges the pending-release counter once per flush and builds a fresh listener per
* acquisition, so every reuse cycle has to settle its own flush. One unanswered cycle is enough
* to pin the counter above zero for the rest of the process.
*/
@Test
fun everyReuseCycleAnswersItsOwnFlush() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, listener) = acquire(provider, config())
output.release()
assertEquals("cycle $it must report exactly one release", 1, listener.releasedCount)
}
assertEquals("the delegate must not build a second AudioTrack", 1, delegate.outputs.size)
}
/** A stale listener per cycle on the real output is how the accounting drifted in the first place. */
@Test
fun reuseDoesNotAccumulateListenersOnTheRealOutput() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
repeat(5) {
val (output, _) = acquire(provider, config())
output.release()
}
assertEquals(1, delegate.outputs.single().listeners.size)
}
@Test
fun forwardedEventsReachTheCurrentListenerOnly() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (first, firstListener) = acquire(provider, config())
first.release()
val (_, secondListener) = acquire(provider, config())
delegate.outputs.single().emitUnderrun()
assertEquals(0, firstListener.underrunCount)
assertEquals(1, secondListener.underrunCount)
}
// Eviction — the overlap media3 itself tolerates, kept tolerable
/**
* The replacement is deliberately built while the evicted track is still going away. Refusing
* until it confirms looks safer, but the refusal reaches media3 as an init failure with no
* pending release to excuse it, so its 200ms deadline starts immediately — and on the TVs this
* cache exists for a teardown can outlast that, turning an ordinary config change into a
* playback error. Upstream tolerates the same overlap.
*/
@Test
fun anEvictedParkedOutputIsReplacedWithoutWaitingForItsRelease() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, _) = acquire(provider, config())
output.release()
provider.getAudioOutput(config(isTunneling = true))
assertTrue("the parked output must have been released", delegate.outputs.first().released)
assertEquals("the replacement must not wait for the release", 2, delegate.outputs.size)
}
/**
* The evicted track's flush was already answered when it parked, so its late confirmation —
* however late — must not report a second release and drive the process-wide count negative.
*/
@Test
fun aSlowEvictionReleaseDoesNotReportASecondTime() {
val delegate = FakeOutputProvider()
val provider = provider(delegate)
val (output, listener) = acquire(provider, config())
output.release()
val evicted = delegate.outputs.first()
provider.getAudioOutput(config(isTunneling = true))
assertEquals(1, listener.releasedCount)
evicted.confirmReleased()
evicted.confirmReleased()
assertEquals(1, listener.releasedCount)
}
// Fakes
private class RecordingListener : AudioOutput.Listener {
var releasedCount = 0
var underrunCount = 0
override fun onPositionAdvancing(playoutStartSystemTimeMs: Long) = Unit
override fun onOffloadDataRequest() = Unit
override fun onOffloadPresentationEnded() = Unit
override fun onUnderrun() {
underrunCount++
}
override fun onReleased() {
releasedCount++
}
}
private class FakeOutputProvider : AudioOutputProvider {
val outputs = mutableListOf<FakeAudioOutput>()
override fun getFormatSupport(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getOutputConfig(formatConfig: AudioOutputProvider.FormatConfig) = throw UnsupportedOperationException()
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput = FakeAudioOutput().also { outputs.add(it) }
override fun addListener(listener: AudioOutputProvider.Listener) = Unit
override fun removeListener(listener: AudioOutputProvider.Listener) = Unit
override fun release() = Unit
}
private class FakeAudioOutput : AudioOutput {
val listeners = mutableListOf<AudioOutput.Listener>()
var released = false
var stopped = false
var flushed = false
fun confirmReleased() {
for (listener in listeners.toList()) listener.onReleased()
}
fun emitUnderrun() {
for (listener in listeners.toList()) listener.onUnderrun()
}
override fun play() = Unit
override fun pause() = Unit
override fun flush() {
flushed = true
}
override fun stop() {
stopped = true
}
override fun release() {
released = true
}
override fun write(buffer: ByteBuffer, encodedAccessUnitCount: Int, presentationTimeUs: Long) = true
override fun setVolume(volume: Float) = Unit
override fun isOffloadedPlayback() = false
override fun getAudioSessionId() = 1
override fun getSampleRate() = 48_000
override fun getBufferSizeInFrames() = 0L
override fun getPositionUs() = 0L
override fun getPlaybackParameters(): PlaybackParameters = PlaybackParameters.DEFAULT
override fun isStalled() = false
override fun addListener(listener: AudioOutput.Listener) {
listeners.add(listener)
}
override fun removeListener(listener: AudioOutput.Listener) {
listeners.remove(listener)
}
override fun setPlaybackParameters(playbackParams: PlaybackParameters) = Unit
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = Unit
override fun setOffloadEndOfStream() = Unit
override fun attachAuxEffect(effectId: Int) = Unit
override fun setAuxEffectSendLevel(level: Float) = Unit
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = Unit
}
}
+5 -1
View File
@@ -258,7 +258,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
if (widget.isLive) {
onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null;
} else {
onNext = (_nextEpisode != null && authority.canNavigateMediaItems) ? _playNext : null;
// _playNext no-ops while a navigation is in flight; matching that here
// keeps the control from looking live while it does nothing.
onNext = (_nextEpisode != null && !_isLoadingNext && authority.canNavigateMediaItems)
? _playNext
: null;
}
VoidCallback? onPrevious;
@@ -139,6 +139,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
return;
}
// Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping
// throw would be an unhandled async error that also strands the loading flag set by
// _playNext — which then silently disables item navigation for the rest of the session.
try {
// Carry the playing version to the next episode by signature — its Media
// list may order versions differently, so the bare index is a guess and
// the source id is per-episode.
@@ -185,6 +189,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preservedSecondarySubtitleTrack: secondarySubtitlePreference,
reason: 'episode navigation',
);
} catch (e, stackTrace) {
appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace);
_clearEpisodeLoadingFlags();
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
}
}
Future<PlaybackSourceChangeOutcome> _switchPlaybackSource({
@@ -970,6 +979,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
// throw before the operational try/catch is entered. Identity ownership
// prevents this continuation from releasing a newer transition.
_releasePlaybackTransition(reloadLease);
// Every superseded return leaves the episode loading flags set otherwise, and
// _playNext/_playPrevious treat them as a re-entrancy guard — a stuck flag turns the
// Next button into a no-op for the rest of the session. Idempotent: the success and
// rollback paths above have already cleared them, and this skips the rebuild when
// neither flag is set.
_clearEpisodeLoadingFlags();
}
}
}