fix(exoplayer): recover a stalled playback session instead of spinning on it

A session that lost its sink sat spinning forever: the watchdog measured media
time, which does not advance while the picture is frozen, so a stall could not be
told from an ordinary rebuffer and the recovery ladder was never climbed.

The stall is now judged in playout time against the load control's own view of
whether the buffer was ever enough. Readiness is the union of the three signals
rather than a precedence chain, because media3 stops asking its load control once
a renderer wedges - the very failure this watchdog exists to catch - and a stale
verdict could otherwise hold it shut. The watchdog is armed on every path that
replaces the source, including the same-state reloads that produce no state change
of their own, and a seek rebaselines it so a backward seek does not inherit the
old clock.

Handing over to MPV keeps the position playback reached rather than restarting the
episode, and a play or pause issued mid-handover is recorded on the queued open,
which is the only thing left to command while one core is being disposed and its
replacement does not yet exist.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent f63a4b039a
commit 21cf1ff8d4
6 changed files with 407 additions and 16 deletions
@@ -38,10 +38,15 @@ internal object BufferingStallPolicy {
/**
* 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.
* A fallback bar, used only until media3 has answered for itself (see [evaluate]). It 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.
*
* Measured in playout time, not media time, because `shouldStartPlayback` divides buffered media
* duration by the playback speed: at 2x the same bar needs twice the media, and comparing raw
* media duration would call a legitimately rebuffering fast-forward stalled.
*/
const val MIN_BUFFER_AHEAD_MS = LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + 2_000L
@@ -59,16 +64,41 @@ internal object BufferingStallPolicy {
STALLED
}
/**
* Every signal here can only ever *permit* an indictment; none of them vetoes one, because a
* signal that can go stale would otherwise wedge this watchdog shut in exactly the failure it
* exists to catch.
*
* [loadControlReady] is media3's own `shouldStartPlayback` verdict when it has given one, which is
* the most faithful answer available — but it is only asked while the renderers report ready, and
* a renderer that never becomes ready is that failure. A `false` recorded before the renderer got
* stuck would never be revised, so it is treated as no answer rather than as a denial.
*
* [loading] false means the load control stopped asking for data, whether because its duration
* thresholds are met or because its byte target is full. On a high bitrate stream that byte cap is
* reached well below [MIN_BUFFER_AHEAD_MS] (see [LoadControlPolicy]), and without this signal a
* genuinely stuck player there would look like an ordinary rebuffer forever. A network stall is
* the opposite: the loader keeps wanting data it cannot get, so [loading] stays true and none of
* the three signals fires.
*/
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
bufferedPositionMs: Long,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
): Verdict {
val speed = if (playbackSpeed > 0f) playbackSpeed else 1f
val bufferedPlayoutMs = ((bufferedPositionMs - currentPositionMs) / speed).toLong()
val readyToPlay = loadControlReady == true || !loading || bufferedPlayoutMs >= MIN_BUFFER_AHEAD_MS
return when {
currentPositionMs - baselinePositionMs >= PROGRESS_EPSILON_MS -> Verdict.HEALTHY
!readyToPlay -> Verdict.STARVED
elapsedMs < STALL_TIMEOUT_MS -> Verdict.WAITING
else -> Verdict.STALLED
}
}
/**
@@ -283,6 +283,12 @@ class ExoPlayerCore(private val activity: Activity) :
private var bufferingStallSinceMs = 0L
private var bufferingStallBaselinePositionMs = 0L
/**
* The live load control, so the buffering watchdog can ask whether media3 itself considers the
* buffer sufficient to start rather than re-deriving that from durations.
*/
private var observingLoadControl: ObservingLoadControl? = null
// Decoder hang detection: tracks gap between decoder init and first rendered frame
private var decoderHangRunnable: Runnable? = null
private var decoderInitName: String? = null
@@ -789,7 +795,7 @@ class ExoPlayerCore(private val activity: Activity) :
LoadControlPolicy.BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS
)
}
}.build()
}.build().let { ObservingLoadControl(it).also { observing -> observingLoadControl = observing } }
emitLog(
"info",
"init",
@@ -1427,6 +1433,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -1532,6 +1540,8 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// See reloadCurrentMediaForDvMode: a same-state reload never re-arms the watchdog by itself.
armBufferingStallWatchdog()
return true
}
@@ -3183,6 +3193,8 @@ class ExoPlayerCore(private val activity: Activity) :
val player = exoPlayer ?: return
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = player.currentPosition
// Fresh window: do not judge it on a verdict media3 gave for the previous one.
observingLoadControl?.reset()
val mediaGeneration = currentMediaGeneration
bufferingStallRunnable = object : Runnable {
override fun run() {
@@ -3208,7 +3220,16 @@ class ExoPlayerCore(private val activity: Activity) :
elapsedMs = elapsedMs,
baselinePositionMs = bufferingStallBaselinePositionMs,
currentPositionMs = current.currentPosition,
bufferedPositionMs = current.bufferedPosition
bufferedPositionMs = current.bufferedPosition,
// The load control's bar is in playout time, so a fast-forward legitimately needs
// proportionally more media. Only used when media3 has not answered for itself yet.
playbackSpeed = current.playbackParameters.speed,
// media3's own verdict, which also covers the byte-target release no duration comparison
// can express — but it is only asked once the renderers are ready.
loadControlReady = observingLoadControl?.startPlaybackVerdict,
// A loader that has stopped asking for data has all it wants, whatever the duration says.
// This is the signal that survives a renderer which never becomes ready.
loading = current.isLoading
)
if (verdict == BufferingStallPolicy.Verdict.STALLED) {
cancelBufferingStallWatchdog()
@@ -3253,6 +3274,8 @@ class ExoPlayerCore(private val activity: Activity) :
blockDirectOutput = sinkError != null
)
) {
// recoverAudioOutputInPlace re-arms the watchdog itself, so the retry is watched and can
// escalate to the handover below.
return
}
@@ -3262,7 +3285,9 @@ class ExoPlayerCore(private val activity: Activity) :
requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri,
positionMs = positionMs,
// Not the raw position: a failed sink can report 0, which would restart a resumed episode from
// the beginning. Every other fallback path hands over the tracked position for the same reason.
positionMs = maxOf(positionMs, effectivePosition),
playWhenReady = playWhenReady,
errorMessage = "Playback stalled while buffering for ${stalledMs}ms"
)
@@ -3406,6 +3431,10 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(this, uri, startPositionMs)
prepare()
playWhenReady = autoPlay
// Same reason as the recovery reloads: replacing the source while the previous item was
// already buffering produces no state change, so nothing would arm the watchdog for this
// generation. The runnable cancels itself as soon as the player is not buffering.
armBufferingStallWatchdog()
}
val sourceLabel = if (isLive) "live HLS" else "media"
@@ -3645,6 +3674,10 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, uri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Replacing the source while the player is already buffering produces no state change, and that
// change is the only thing that arms the stall watchdog — so arm it here or a reload that never
// becomes ready has nothing left watching it.
armBufferingStallWatchdog()
emitLog("info", "dv-debug", "Reloaded media for DV mode $dvMode at ${savedPosition}ms")
return true
}
@@ -3684,6 +3717,17 @@ class ExoPlayerCore(private val activity: Activity) :
player.seekTo(clampedPositionMs)
lastPosition = clampedPositionMs
lastKnownGoodPositionMs = clampedPositionMs
// A seek discards whatever the stall watchdog was measuring: it compares against a baseline
// position, and the policy reads a lower position as stalled, so a backward seek during a
// legitimate buffered wait would inherit the pre-seek stall time and trip recovery on the next
// poll. Re-baseline here so the new position gets the full timeout.
if (bufferingStallRunnable != null) {
bufferingStallSinceMs = System.currentTimeMillis()
bufferingStallBaselinePositionMs = clampedPositionMs
// The load control's recorded verdict belongs to the buffer this seek just discarded, and the
// policy prefers it over the live loader state, so a stale answer would decide the new window.
observingLoadControl?.reset()
}
delegate?.onPropertyChange("time-pos", clampedPositionMs / 1000.0)
}
@@ -3789,6 +3833,9 @@ class ExoPlayerCore(private val activity: Activity) :
setCurrentMediaSource(player, mediaUri, savedPosition)
player.prepare()
player.playWhenReady = savedPlayWhenReady
// Same-state source replacement: media3 reports no change when the player was already
// buffering, so arm here or this window keeps the previous one's watchdog and verdict.
armBufferingStallWatchdog()
} else {
// Already attached — select the existing track via override. The
// reported id carries media3's merge prefixes, so compare the tag.
@@ -61,7 +61,13 @@ class ExoPlayerPlugin :
val headers: Map<String, String>?,
val startPositionMs: Long,
val hasStartPosition: Boolean,
val autoPlay: Boolean,
/**
* Whether to start playing once open. Mutable because a play or pause arriving while this
* request is queued behind a backend handover has no player to reach — the outgoing core is
* being disposed and the replacement does not exist yet — so the latest intent is recorded here
* instead. Without that, a pause the caller was told succeeded is silently undone by the open.
*/
var autoPlay: Boolean,
val isLive: Boolean,
val externalSubtitles: List<Map<String, Any?>>?,
val contentFrameRate: Float,
@@ -824,6 +830,9 @@ class ExoPlayerPlugin :
}
private fun handlePlay(result: MethodChannel.Result) {
// A backend handover leaves nothing to command: record the intent on the queued open instead,
// so the replacement starts the way the caller last asked.
pendingOpen?.autoPlay = true
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "no", result)
return
@@ -835,6 +844,10 @@ class ExoPlayerPlugin :
}
private fun handlePause(result: MethodChannel.Result) {
// See handlePlay. This direction matters more: the caller is told the pause succeeded, and a
// queued open that still carried autoPlay would start playing anyway — on a car, that is
// playback after the vehicle said no.
pendingOpen?.autoPlay = false
if (usingMpvFallback) {
handleFallbackMpvProperty("pause", "yes", result)
return
@@ -0,0 +1,91 @@
package com.edde746.plezy.exoplayer
import androidx.annotation.OptIn
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.ExoTrackSelection
import androidx.media3.exoplayer.upstream.Allocator
/**
* A [LoadControl] that remembers whether it last said playback may start.
*
* The buffering stall watchdog ([BufferingStallPolicy]) needs to tell apart a player that is
* buffering because the load control is holding it back — an ordinary rebuffer, not its business —
* from one the load control has released that still refuses to move, which is the failure it exists
* to catch.
*
* Re-deriving that condition cannot be done faithfully: `DefaultLoadControl.shouldStartPlayback`
* releases playback on buffered *playout* duration (media duration divided by playback speed) or,
* with `prioritizeTimeOverSizeThresholds` disabled, as soon as its byte target is full — which on a
* high bitrate stream happens well below any duration bar, and depends on allocator accounting that
* no public API exposes. So ask the object ExoPlayer asks, and record its answer.
*
* Note what this does *not* reach: `ExoPlayerImplInternal.shouldTransitionToReadyState` returns
* early when the renderers are not ready, so it never asks at all in the case the watchdog was
* written for — a renderer stuck forever. [BufferingStallPolicy] therefore also takes
* `Player.isLoading`, which stays observable throughout.
*
* Every member is forwarded explicitly, and that is not a style choice: Kotlin's `by` delegation
* only generates forwarders for *abstract* interface members, so a Java interface like this one —
* where all but `getAllocator` carry default implementations that throw
* `IllegalStateException("... not implemented")` — would inherit those throwing defaults and blow up
* inside `ExoPlayer.Builder.build()`. [ObservingLoadControlTest] builds a real player through this
* wrapper so a media3 upgrade that adds another such method fails a test rather than a device.
*/
@OptIn(UnstableApi::class)
class ObservingLoadControl(private val delegate: LoadControl) : LoadControl {
/**
* The delegate's most recent verdict, or null if it has not been asked since [reset].
*
* ExoPlayer asks on every playback-loop iteration while buffering, so a reader running on the
* watchdog's schedule sees a current answer; null only means "not yet asked".
*/
@Volatile
var startPlaybackVerdict: Boolean? = null
private set
/** Forgets the recorded verdict, for a watchdog arming a fresh buffering window. */
fun reset() {
startPlaybackVerdict = null
}
// The Parameters overloads are the ones ExoPlayer 1.10 calls; the older signatures are deprecated
// shims that route through them.
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean {
val verdict = delegate.shouldStartPlayback(parameters)
startPlaybackVerdict = verdict
return verdict
}
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = delegate.shouldContinueLoading(parameters)
override fun shouldContinuePreloading(
playerId: PlayerId,
timeline: Timeline,
mediaPeriodId: MediaSource.MediaPeriodId,
bufferedDurationUs: Long
): Boolean = delegate.shouldContinuePreloading(playerId, timeline, mediaPeriodId, bufferedDurationUs)
override fun onPrepared(playerId: PlayerId) = delegate.onPrepared(playerId)
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: TrackGroupArray,
trackSelections: Array<out ExoTrackSelection>
) = delegate.onTracksSelected(parameters, trackGroups, trackSelections)
override fun onStopped(playerId: PlayerId) = delegate.onStopped(playerId)
override fun onReleased(playerId: PlayerId) = delegate.onReleased(playerId)
override fun getAllocator(playerId: PlayerId): Allocator = delegate.getAllocator(playerId)
override fun getBackBufferDurationUs(playerId: PlayerId): Long = delegate.getBackBufferDurationUs(playerId)
override fun retainBackBufferFromKeyframe(playerId: PlayerId): Boolean = delegate.retainBackBufferFromKeyframe(playerId)
}
@@ -14,14 +14,99 @@ class BufferingStallPolicyTest {
elapsedMs: Long = BufferingStallPolicy.STALL_TIMEOUT_MS,
baselinePositionMs: Long = position,
currentPositionMs: Long = position,
bufferedAheadMs: Long = 30_000L
bufferedAheadMs: Long = 30_000L,
playbackSpeed: Float = 1f,
loadControlReady: Boolean? = null,
loading: Boolean = true
) = BufferingStallPolicy.evaluate(
elapsedMs = elapsedMs,
baselinePositionMs = baselinePositionMs,
currentPositionMs = currentPositionMs,
bufferedPositionMs = currentPositionMs + bufferedAheadMs
bufferedPositionMs = currentPositionMs + bufferedAheadMs,
playbackSpeed = playbackSpeed,
loadControlReady = loadControlReady,
loading = loading
)
// The loader's own state, for the case media3 never answers
@Test
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
// The #1790 shape on a high bitrate stream: the byte cap is full at a couple of seconds, the
// renderers never become ready so media3 never asks its load control, and nothing moves.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = true))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loading = false))
}
@Test
fun aLoaderStillFetchingWithATinyBufferIsAnOrdinaryRebuffer() {
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = 500L, loading = true))
}
@Test
fun aStaleNotYetFromTheLoadControlCannotVetoAStoppedLoader() {
// media3 stops asking the moment a renderer goes unready — the failure this watchdog exists for
// — so a `false` recorded before that must not outrank a loader that has since stopped.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
}
// The load control's own verdict
@Test
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
// The real failure this watchdog exists for, on a high bitrate stream: media3 released playback
// off its byte target with only a couple of seconds buffered, and the player still will not move.
val belowTheDurationBar = 2_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = belowTheDurationBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = belowTheDurationBar, loadControlReady = true))
}
@Test
fun aLoadControlHoldingPlaybackBackIsNotIndictedWhileTheLoaderStillWants() {
// Plenty buffered by duration, so the duration signal alone would indict; media3 saying "not
// yet" while its loader keeps fetching is an ordinary rebuffer.
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 30_000L, loadControlReady = false))
assertEquals(
"nothing says this player could start",
Verdict.STARVED,
evaluate(bufferedAheadMs = 1_000L, loading = true, loadControlReady = false)
)
}
@Test
fun progressOutranksTheLoadControlVerdict() {
assertEquals(
Verdict.HEALTHY,
evaluate(currentPositionMs = position + 5_000, loadControlReady = true)
)
}
// Playback speed
@Test
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
// Enough media to start at 1x, but the load control measures its bar in playout time, so at 2x
// this player is still legitimately rebuffering rather than stalled.
val justEnoughAtNormalSpeed = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS + 1_000L
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed))
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = justEnoughAtNormalSpeed, playbackSpeed = 2f))
}
@Test
fun aSlowMotionPlayerNeedsLessMediaBeforeItIsIndicted() {
val shortOfTheBar = BufferingStallPolicy.MIN_BUFFER_AHEAD_MS - 1_000L
assertEquals(Verdict.STARVED, evaluate(bufferedAheadMs = shortOfTheBar))
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = shortOfTheBar, playbackSpeed = 0.5f))
}
@Test
fun anImpossibleSpeedIsTreatedAsNormal() {
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = 0f))
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
}
// Progress
@Test
@@ -0,0 +1,125 @@
package com.edde746.plezy.exoplayer
import android.app.Activity
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
/**
* The wrapper has to be a complete [LoadControl], not a partial one.
*
* media3's interface gives almost every member a default implementation that throws
* `IllegalStateException`, so a forwarder this class forgets does not fail to compile — it takes the
* player down at construction, or the first time ExoPlayer asks. Building a real player is the only
* assertion that covers all of them at once, including methods a media3 upgrade adds later.
*/
@RunWith(RobolectricTestRunner::class)
class ObservingLoadControlTest {
private fun loadControl() = DefaultLoadControl.Builder().setTargetBufferBytes(4 * 1024 * 1024).build()
@Test
fun aRealPlayerCanBeBuiltAndReleasedThroughTheWrapper() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val player = ExoPlayer.Builder(activity).setLoadControl(ObservingLoadControl(loadControl())).build()
try {
// Construction alone already routes through getBackBufferDurationUs and getAllocator.
assertEquals(ExoPlayer.STATE_IDLE, player.playbackState)
} finally {
player.release()
}
}
@Test
fun theStartPlaybackVerdictIsRecordedAndResettable() {
// A fake delegate, because a fabricated `Parameters` cannot satisfy DefaultLoadControl's own
// timeline lookups. What matters here is that the wrapper reports what the delegate said.
val delegate = FakeLoadControl()
val observing = ObservingLoadControl(delegate)
assertNull("nothing asked yet", observing.startPlaybackVerdict)
delegate.startPlayback = true
assertTrue(observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(true, observing.startPlaybackVerdict)
delegate.startPlayback = false
assertEquals(false, observing.shouldStartPlayback(delegate.lastParameters))
assertEquals(false, observing.startPlaybackVerdict)
observing.reset()
assertNull("a fresh buffering window must not inherit the last verdict", observing.startPlaybackVerdict)
}
@Test
fun otherCallsReachTheDelegate() {
val delegate = loadControl()
val observing = ObservingLoadControl(delegate)
val playerId = androidx.media3.exoplayer.analytics.PlayerId.UNSET
observing.onPrepared(playerId)
try {
assertSame(delegate.getAllocator(playerId)::class.java, observing.getAllocator(playerId)::class.java)
assertEquals(delegate.getBackBufferDurationUs(playerId), observing.getBackBufferDurationUs(playerId))
assertEquals(delegate.retainBackBufferFromKeyframe(playerId), observing.retainBackBufferFromKeyframe(playerId))
} finally {
observing.onReleased(playerId)
}
}
}
/** Minimal complete [LoadControl], so the wrapper's recording can be exercised on its own. */
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
private class FakeLoadControl : LoadControl {
var startPlayback = false
val lastParameters: LoadControl.Parameters = LoadControl.Parameters(
androidx.media3.exoplayer.analytics.PlayerId.UNSET,
androidx.media3.common.Timeline.EMPTY,
LoadControl.EMPTY_MEDIA_PERIOD_ID,
/* playbackPositionUs= */
0L,
/* bufferedDurationUs= */
0L,
/* playbackSpeed= */
1f,
/* playWhenReady= */
true,
/* rebuffering= */
false,
/* targetLiveOffsetUs= */
androidx.media3.common.C.TIME_UNSET,
/* lastRebufferRealtimeMs= */
androidx.media3.common.C.TIME_UNSET
)
override fun shouldStartPlayback(parameters: LoadControl.Parameters): Boolean = startPlayback
override fun shouldContinueLoading(parameters: LoadControl.Parameters): Boolean = true
override fun onPrepared(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onTracksSelected(
parameters: LoadControl.Parameters,
trackGroups: androidx.media3.exoplayer.source.TrackGroupArray,
trackSelections: Array<out androidx.media3.exoplayer.trackselection.ExoTrackSelection>
) = Unit
override fun onStopped(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun onReleased(playerId: androidx.media3.exoplayer.analytics.PlayerId) = Unit
override fun getAllocator(
playerId: androidx.media3.exoplayer.analytics.PlayerId
): androidx.media3.exoplayer.upstream.Allocator = androidx.media3.exoplayer.upstream.DefaultAllocator(true, 64 * 1024)
override fun getBackBufferDurationUs(playerId: androidx.media3.exoplayer.analytics.PlayerId): Long = 0L
override fun retainBackBufferFromKeyframe(playerId: androidx.media3.exoplayer.analytics.PlayerId): Boolean = false
}