From ad3af474e9a8647e6a695b464380cb5d3189a47d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:52 +0200 Subject: [PATCH] fix(android): emit playback-restart after every seek Separate the playback-restart signal from the one-shot decoder-hang latch. A seek flushes the codec without re-initializing it, so the claimed latch swallowed the post-seek first frame and Watch Together guests sat in correcting for the full settle timeout. --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 23 +++++-- .../ExoPlayerFallbackTerminalTest.kt | 67 +++++++++++++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) 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 29dbc1f1..7b3f501a 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 @@ -1035,12 +1035,15 @@ class ExoPlayerCore(private val activity: Activity) : emitTrackList() // Video becomes ready only when a frame renders. Audio-only media has - // no video callback, so STATE_READY is its renderer-ready boundary. + // no video callback, so re-entering STATE_READY (load, and the + // re-buffer that follows a seek) is its renderer-ready boundary. val hasVideoGroup = exoPlayer?.currentTracks?.groups?.any { it.type == C.TRACK_TYPE_VIDEO } == true if (hasVideoGroup) { startFrameWatchdog() - } else if (claimPlaybackOutputReady()) { - emitLog("debug", "audio", "Audio-only playback ready") + } else { + if (claimPlaybackOutputReady()) { + emitLog("debug", "audio", "Audio-only playback ready") + } stopFrameWatchdog() delegate?.onEvent("playback-restart", null) } @@ -2708,15 +2711,21 @@ class ExoPlayerCore(private val activity: Activity) : val mediaGeneration = mediaGenerationAt(eventTime) ?: return if (mediaGeneration != currentMediaGeneration) return hasRenderedVideoFrameForMedia = true - if (!claimPlaybackOutputReady()) return - emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") - logNativeDvFirstFrameIfNeeded() - logDolbyVisionPlaybackPathIfNeeded() + if (claimPlaybackOutputReady()) { + emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") + logNativeDvFirstFrameIfNeeded() + logDolbyVisionPlaybackPathIfNeeded() + } // STATE_READY fires when the player has enough buffered to start, but // the first frame may not be on screen yet (decoder init + keyframe // decode). The MPV-parity `playback-restart` event consumers (Dart // first-frame detection, frame-rate matching) want the moment the // pixel actually hits the screen, which is here. + // The claim above is a one-shot decoder-hang latch and must not gate the + // event: ExoPlayer re-arms its first-frame state on every position reset, + // so this callback also fires after each seek. That is exactly MPV's + // MPV_EVENT_PLAYBACK_RESTART contract — first frame after load *and* + // after every seek — which the Dart consumers rely on. delegate?.onEvent("playback-restart", null) } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt index 57b8396a..47218c36 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt @@ -147,6 +147,52 @@ class ExoPlayerFallbackTerminalTest { } } + @Test + fun videoPlaybackRestartRepeatsAfterSeekWithoutDecoderReinit() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + val delegate = RecordingDelegate(handlesFallback = false) + core.delegate = delegate + setField(core, "currentMediaGeneration", 7) + val mediaItem = MediaItem.Builder() + .setMediaId("7") + .setUri("https://example.test/video.mkv") + .build() + val timeline = SinglePeriodTimeline( + 1_000_000L, + true, + false, + false, + null, + mediaItem + ) + val eventTime = AnalyticsListener.EventTime( + 0L, + timeline, + 0, + null, + 0L, + timeline, + 0, + null, + 0L, + 0L + ) + val analytics = getField(core, "decoderHangListener") as AnalyticsListener + + try { + analytics.onRenderedFirstFrame(eventTime, Any(), 0L) + analytics.onRenderedFirstFrame(eventTime, Any(), 0L) + + assertEquals( + listOf("playback-restart", "playback-restart"), + delegate.events.map { it.first } + ) + assertEquals(true, getField(core, "firstFrameRendered")) + } finally { + core.dispose() + } + } + @Test fun audioOnlyReadyEmitsPlaybackRestartWithoutAFrameCallback() { val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) @@ -163,6 +209,27 @@ class ExoPlayerFallbackTerminalTest { } } + @Test + fun audioOnlyPlaybackRestartRepeatsOnReentryToReady() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + val delegate = RecordingDelegate(handlesFallback = false) + core.delegate = delegate + + try { + invokePlaybackState(core, Player.STATE_READY) + invokePlaybackState(core, Player.STATE_BUFFERING) + invokePlaybackState(core, Player.STATE_READY) + + assertEquals( + listOf("playback-restart", "playback-restart"), + delegate.events.map { it.first } + ) + assertEquals(true, getField(core, "firstFrameRendered")) + } finally { + core.dispose() + } + } + @Test fun pausedTimeDoesNotConsumeTheFrameWatchdogTimeout() { val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get())