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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+67
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user