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.
This commit is contained in:
edde746
2026-07-27 17:44:52 +02:00
parent 088501513a
commit ad3af474e9
2 changed files with 83 additions and 7 deletions
@@ -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)
}
}
@@ -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())