diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicy.kt index 78aa430b..47b49366 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicy.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicy.kt @@ -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 + } } /** diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 9ba4ad7b..f1ff08c4 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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. diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 5250aa72..1a2865b8 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -61,7 +61,13 @@ class ExoPlayerPlugin : val headers: Map?, 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>?, 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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControl.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControl.kt new file mode 100644 index 00000000..cb7aceec --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControl.kt @@ -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 + ) = 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) +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicyTest.kt index 55187b89..2376e6dd 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicyTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/BufferingStallPolicyTest.kt @@ -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 diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControlTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControlTest.kt new file mode 100644 index 00000000..38510ab9 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ObservingLoadControlTest.kt @@ -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 + ) = 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 +}