fix(android): finish the item when ExoPlayer never ends playback

media3 reports STUCK_PLAYING_NOT_ENDING when the player sits in STATE_READY
past the declared duration with no renderer ending. On a tunneled MTK decoder
the clock ran a full minute past the last frame behind a black screen, so the
item never completed: no Play Next, no auto-play, and a "playing" timeline the
server kept extrapolating past the item duration.

Treat that report as the end of the file when the rendered-frame counter has
stopped as well, which separates a finished file from a container that
under-declares its duration and is still painting. The terminal event is shaped
like the STATE_ENDED one and pins the timeline at the duration first, so the
completion flow cannot mistake it for a stream that died mid-file. Shorten the
detection window to media3's stuck-playing default, and clamp a backend
hand-off to just inside the media so a fallback can no longer resume MPV past
the last frame and park there without reporting it.

close #1673
This commit is contained in:
edde746
2026-07-28 23:53:52 +02:00
parent 41ffaa7f2b
commit 726dfc6507
6 changed files with 576 additions and 5 deletions
@@ -0,0 +1,77 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
/**
* Decision logic for media the player refuses to end (#1673).
*
* media3 reports `StuckPlayerException.STUCK_PLAYING_NOT_ENDING` when the player
* sits in STATE_READY past the declared duration while no renderer ever reports
* itself ended. Observed on a tunneled MTK decoder (Fire TV 4K Max): the clock
* ran a full minute past the last frame behind a black screen, so nothing ever
* completed the item — no Play Next, no auto-play, and a "playing" timeline the
* server kept extrapolating past the item duration. The leading hypothesis is
* that the tunneled end-of-stream buffer never arrives; what the evidence
* establishes is only that the renderers never end.
*
* The stuck report alone cannot mean "finished": the same condition holds for a
* container that under-declares its duration and is legitimately still playing.
* The rendered-frame counter separates the two — a finished file has stopped
* painting, an under-declared one has not.
*
* Inputs are deliberately the core's own bookkeeping rather than live player
* state: media3 has already stopped the player by the time the report arrives,
* so its timeline and track selection may be gone.
*/
internal object EndOfStreamPolicy {
/**
* Window media3 waits before reporting a player that is past its duration and
* still not ending. media3's own not-ending default is a full minute; this
* matches its stuck-*playing* default instead, which [isFinishedFile] can
* afford because it also requires the picture to be gone.
*/
const val STALL_TIMEOUT_MS = 10_000
/**
* How long the rendered-frame counter must sit still before a player stuck
* past its duration counts as a finished file rather than a long tail.
*/
const val FRAME_STALL_MS = 5_000L
/**
* Distance from the end a fallback backend may resume at. MPV opened at or
* past the last frame seeks straight into EOF and parks there without ever
* reporting it, which is how a failed hand-off turned into a second stall.
*/
const val FALLBACK_END_GUARD_MS = 1_000L
/**
* Whether a player stuck past its duration has actually reached the end of the
* file.
*
* [hasPlaybackOutput] keeps start-up failures — which the fallback ladder owns
* — out of this path. [hasVideoOutput] says whether the rendered-frame counter
* means anything for this media: audio-only playback never moves it, so there
* the stuck timeout stands alone. [frameStallMs] is the age of the last
* counter change.
*/
fun isFinishedFile(
hasPlaybackOutput: Boolean,
hasVideoOutput: Boolean,
isLive: Boolean,
durationMs: Long,
positionMs: Long,
frameStallMs: Long
): Boolean = hasPlaybackOutput &&
!isLive &&
durationMs != C.TIME_UNSET &&
durationMs > 0L &&
positionMs >= durationMs &&
(!hasVideoOutput || frameStallMs >= FRAME_STALL_MS)
/** Keep a backend hand-off strictly inside the media, mirroring the seek clamp. */
fun fallbackStartPositionMs(positionMs: Long, durationMs: Long, isLive: Boolean): Long {
if (isLive || durationMs == C.TIME_UNSET || durationMs <= 0L) return positionMs.coerceAtLeast(0L)
return positionMs.coerceIn(0L, (durationMs - FALLBACK_END_GUARD_MS).coerceAtLeast(0L))
}
}
@@ -38,6 +38,7 @@ import androidx.media3.common.VideoSize
import androidx.media3.common.audio.ChannelMixingMatrix
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.StuckPlayerException
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DefaultDataSource
@@ -285,6 +286,13 @@ class ExoPlayerCore(private val activity: Activity) :
private var lastTrueHdDirectOutputLogKey: String? = null
private var loggedDecodedPcmTunnelingGuard: Boolean = false
private var hasRenderedVideoFrameForMedia: Boolean = false
// Rendered-frame progress, sampled by the position loop. handlePlayerError
// reads it to tell "the renderer is done and the player will not end" (#1673)
// apart from a container that under-declares its duration and is still
// painting frames past it.
private var lastRenderedFrameCount: Int = -1
private var lastRenderedFrameChangeMs: Long = 0L
private var videoDecoderRecoveryConsecutiveAttempts: Int = 0
private var videoDecoderRecoveryTotalAttempts: Int = 0
private var videoDecoderRecoveryPositionMs: Long? = null
@@ -401,6 +409,31 @@ class ExoPlayerCore(private val activity: Activity) :
delegate?.onEvent("end-file", data)
}
/**
* Terminal end-of-file for media the player will not end on its own (#1673).
*
* Shaped exactly like the [Player.STATE_ENDED] branch so the Dart completion
* flow (stop report, Play Next / auto-play) cannot tell a synthesized end from
* a real one, and sharing [terminalErrorGeneration] so one media generation
* never emits both an error and an EOF.
*
* The timeline is pinned to [positionMs] first: Dart classifies an EOF by
* position against duration — a stream that dies mid-file reports the same
* event — and media3 has already stopped the player by the time we get here.
*/
private fun emitPlaybackEofOnce(mediaGeneration: Int, positionMs: Long) {
if (mediaGeneration != currentMediaGeneration || terminalErrorGeneration == mediaGeneration) return
terminalErrorGeneration = mediaGeneration
exoPlayer?.playWhenReady = false
pendingPlayWhenReady = null
lastPosition = positionMs
delegate?.onPropertyChange("time-pos", positionMs / 1000.0)
delegate?.onPropertyChange("paused-for-cache", false)
delegate?.onPropertyChange("pause", true)
delegate?.onPropertyChange("eof-reached", true)
delegate?.onEvent("end-file", mapOf("reason" to "eof"))
}
private fun requestFormatFallback(
mediaGeneration: Int,
uri: String,
@@ -413,7 +446,7 @@ class ExoPlayerCore(private val activity: Activity) :
mediaGeneration = mediaGeneration,
uri = uri,
headers = currentHeaders,
positionMs = positionMs,
positionMs = fallbackStartPositionMs(positionMs),
playWhenReady = playWhenReady,
errorMessage = errorMessage
) ?: false
@@ -421,6 +454,17 @@ class ExoPlayerCore(private val activity: Activity) :
return handled
}
/**
* Keeps a backend hand-off inside the media — see [EndOfStreamPolicy] (#1673).
* Uses the last published duration, not the player's: an error fallback runs
* after media3 has stopped the player, when its timeline may already be gone.
*/
private fun fallbackStartPositionMs(positionMs: Long): Long = EndOfStreamPolicy.fallbackStartPositionMs(
positionMs = positionMs,
durationMs = lastDuration,
isLive = currentMediaIsLive
)
private fun redactUri(uri: String): String {
return try {
val parsed = Uri.parse(uri)
@@ -719,6 +763,9 @@ class ExoPlayerCore(private val activity: Activity) :
.setAudioAttributes(audioAttributes, false) // We handle audio focus manually
.setMediaSourceFactory(mediaSourceFactory)
.setRenderersFactory(wrappedRenderersFactory)
// Cut the wait before media3 reports a player that runs past its duration
// without ending (#1673) — see EndOfStreamPolicy.STALL_TIMEOUT_MS.
.setStuckPlayingNotEndingTimeoutMs(EndOfStreamPolicy.STALL_TIMEOUT_MS)
.build()
// Add ASS overlay view to the full-screen surfaceContainer (NOT the zoom-scaled
@@ -901,9 +948,13 @@ class ExoPlayerCore(private val activity: Activity) :
val duration = player.duration
val bufferedPosition = player.bufferedPosition
updateVideoDecoderRecoveryHealth(currentPosition, player.isPlaying)
updateRenderedFrameProgress(player)
// Emit position changes (every 250ms update)
if (currentPosition != lastPosition) {
// Emit position changes (every 250ms update). A media generation that
// already reported its terminal event keeps the position it ended on:
// media3 can still be running a clock nobody can see (#1673), and Dart
// classifies an EOF by position against duration.
if (currentPosition != lastPosition && terminalErrorGeneration != currentMediaGeneration) {
lastPosition = currentPosition
delegate?.onPropertyChange("time-pos", currentPosition / 1000.0)
}
@@ -932,6 +983,18 @@ class ExoPlayerCore(private val activity: Activity) :
positionUpdateRunnable = null
}
/**
* Sample the video renderer's output counter. Any change — including the reset
* that comes with a re-created decoder — counts as progress; the timestamp is
* what [handleEndOfStreamStall] reads.
*/
private fun updateRenderedFrameProgress(player: ExoPlayer) {
val renderedFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: return
if (renderedFrames == lastRenderedFrameCount) return
lastRenderedFrameCount = renderedFrames
lastRenderedFrameChangeMs = System.currentTimeMillis()
}
private fun resetPlaybackProgress(startPositionMs: Long) {
lastPosition = startPositionMs
lastDuration = 0L
@@ -1157,6 +1220,8 @@ class ExoPlayerCore(private val activity: Activity) :
return
}
if (handleEndOfStreamStall(error, mediaGeneration)) return
if (retryAfterAudioTrackError(error, causeChain)) return
val rendererFormat = (error as? ExoPlaybackException)?.rendererFormat
@@ -1184,6 +1249,62 @@ class ExoPlayerCore(private val activity: Activity) :
emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error")
}
/**
* media3 reports [StuckPlayerException.STUCK_PLAYING_NOT_ENDING] when the
* player sits in STATE_READY past the declared duration without any renderer
* ending (#1673). When the picture is gone as well, that is the end of the
* file: report it as one so the normal completion flow runs, instead of
* leaving a black screen behind a clock that keeps ticking. A player that is
* still painting frames past an under-declared duration keeps the existing
* recovery ladder, which hands the tail to MPV. See [EndOfStreamPolicy].
*
* media3 stops the player before reporting, so the last duration and position
* this core published are used rather than re-reading a cleared timeline.
*/
private fun handleEndOfStreamStall(error: PlaybackException, mediaGeneration: Int): Boolean {
if (!isStuckPlayingNotEnding(error)) return false
val durationMs = lastDuration
val positionMs = maxOf(exoPlayer?.currentPosition ?: 0L, lastPosition)
val frameStallMs = System.currentTimeMillis() - lastRenderedFrameChangeMs
val finished = EndOfStreamPolicy.isFinishedFile(
hasPlaybackOutput = firstFrameRendered,
hasVideoOutput = hasRenderedVideoFrameForMedia,
isLive = currentMediaIsLive,
durationMs = durationMs,
positionMs = positionMs,
frameStallMs = frameStallMs
)
if (!finished) {
emitLog(
"warn",
"eos",
"Player stuck at ${positionMs}ms of ${durationMs}ms but the file is not finished " +
"(last rendered frame ${frameStallMs}ms ago) — keeping the normal recovery"
)
return false
}
emitLog(
"warn",
"eos",
"Player never ended ${positionMs - durationMs}ms past the ${durationMs}ms duration " +
"(no rendered frame for ${frameStallMs}ms) — reporting end of file"
)
emitPlaybackEofOnce(mediaGeneration, durationMs)
return true
}
private fun isStuckPlayingNotEnding(error: PlaybackException): Boolean {
var cause: Throwable? = error.cause
while (cause != null) {
if (cause is StuckPlayerException && cause.stuckType == StuckPlayerException.STUCK_PLAYING_NOT_ENDING) {
return true
}
cause = cause.cause
}
return false
}
private fun retryVideoDecoderInPlace(reason: String): Boolean {
if (!VideoDecoderRecoveryPolicy.canRetryRuntimeFailure(
hasRenderedVideoFrameForMedia,
@@ -3014,6 +3135,8 @@ class ExoPlayerCore(private val activity: Activity) :
currentVideoFormat = null
firstFrameRendered = false
hasRenderedVideoFrameForMedia = false
lastRenderedFrameCount = -1
lastRenderedFrameChangeMs = System.currentTimeMillis()
videoDecoderRecoveryConsecutiveAttempts = 0
videoDecoderRecoveryTotalAttempts = 0
videoDecoderRecoveryPositionMs = null
@@ -0,0 +1,110 @@
package com.edde746.plezy.exoplayer
import androidx.media3.common.C
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class EndOfStreamPolicyTest {
private fun isFinishedFile(
hasPlaybackOutput: Boolean = true,
hasVideoOutput: Boolean = true,
isLive: Boolean = false,
durationMs: Long = 60_000L,
positionMs: Long = 60_000L,
frameStallMs: Long = EndOfStreamPolicy.FRAME_STALL_MS
): Boolean = EndOfStreamPolicy.isFinishedFile(
hasPlaybackOutput = hasPlaybackOutput,
hasVideoOutput = hasVideoOutput,
isLive = isLive,
durationMs = durationMs,
positionMs = positionMs,
frameStallMs = frameStallMs
)
// isFinishedFile
@Test
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
// The reported failure: the clock ran a minute past the last frame.
assertTrue(isFinishedFile(positionMs = 119_816L, frameStallMs = 59_816L))
// And the moment the overrun begins, once the picture is already gone.
assertTrue(isFinishedFile(positionMs = 60_000L, frameStallMs = 5_000L))
}
@Test
fun stillPaintingFramesMeansTheDurationIsUnderDeclaredNotFinished() {
assertFalse(isFinishedFile(positionMs = 90_000L, frameStallMs = 4_999L))
assertFalse(isFinishedFile(positionMs = 90_000L, frameStallMs = 0L))
}
@Test
fun audioOnlyMediaHasNoFrameCounterAndRidesTheTimeoutAlone() {
assertTrue(isFinishedFile(hasVideoOutput = false, frameStallMs = 0L))
}
@Test
fun playbackShortOfTheDurationIsNeverFinished() {
assertFalse(isFinishedFile(positionMs = 59_999L))
}
@Test
fun unknownOrAbsentDurationIsNeverFinished() {
assertFalse(isFinishedFile(durationMs = C.TIME_UNSET, positionMs = 120_000L))
assertFalse(isFinishedFile(durationMs = 0L, positionMs = 120_000L))
assertFalse(isFinishedFile(durationMs = -5L, positionMs = 120_000L))
}
@Test
fun liveAndOutputlessSessionsKeepTheNormalRecovery() {
// Live has no meaningful end; a session that never produced output is a
// start-up failure, which the fallback ladder already owns.
assertFalse(isFinishedFile(isLive = true))
assertFalse(isFinishedFile(hasPlaybackOutput = false))
}
// fallbackStartPositionMs
@Test
fun fallbackResumesStrictlyInsideTheMedia() {
// The observed hand-off asked MPV to start 59_816ms past the end.
assertEquals(
2_622_668L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = 2_683_484L, durationMs = 2_623_668L, isLive = false)
)
assertEquals(
59_000L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = 60_000L, durationMs = 60_000L, isLive = false)
)
}
@Test
fun fallbackKeepsAMidFilePositionUntouched() {
assertEquals(
30_000L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = 30_000L, durationMs = 60_000L, isLive = false)
)
}
@Test
fun fallbackNeverGoesNegativeOnShortOrUnknownMedia() {
assertEquals(
0L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = 400L, durationMs = 500L, isLive = false)
)
assertEquals(
0L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = -1L, durationMs = C.TIME_UNSET, isLive = false)
)
}
@Test
fun liveFallbackKeepsItsPositionBecauseTheEndKeepsMoving() {
assertEquals(
2_683_484L,
EndOfStreamPolicy.fallbackStartPositionMs(positionMs = 2_683_484L, durationMs = 2_623_668L, isLive = true)
)
}
}
@@ -9,6 +9,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.StuckPlayerException
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.source.SinglePeriodTimeline
@@ -355,6 +356,129 @@ class ExoPlayerFallbackTerminalTest {
}
}
@Test
@Config(sdk = [28])
fun playerStuckPastItsDurationWithoutAPictureReportsEndOfFile() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
arrangeStalledEndOfStream(core, frameStallMs = 60_000L)
invokePlayerError(core, stuckError(StuckPlayerException.STUCK_PLAYING_NOT_ENDING), mediaGeneration = 7)
assertEquals(0, delegate.fallbackRequests)
val endFiles = delegate.events.filter { it.first == "end-file" }
assertEquals(1, endFiles.size)
assertEquals("eof", endFiles.single().second?.get("reason"))
assertTrue(delegate.properties.contains("eof-reached" to true))
assertTrue(delegate.properties.contains("pause" to true))
// The timeline is pinned at the end: Dart classifies an EOF by position
// against duration, so an overrun would read as a mid-file stream death.
assertEquals(2623.668, delegate.properties.last { it.first == "time-pos" }.second)
assertEquals(2_623_668L, getField(core, "lastPosition"))
// A repeat report for the same media stays silent.
invokePlayerError(core, stuckError(StuckPlayerException.STUCK_PLAYING_NOT_ENDING), mediaGeneration = 7)
assertEquals(1, delegate.events.count { it.first == "end-file" })
assertEquals(0, delegate.fallbackRequests)
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
@Test
@Config(sdk = [28])
fun playerStuckPastAnUnderDeclaredDurationKeepsTheNormalRecovery() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
// Frames are still reaching the screen, so the file is not over — the
// container simply understates its length. MPV gets the tail.
arrangeStalledEndOfStream(core, frameStallMs = 0L)
invokePlayerError(core, stuckError(StuckPlayerException.STUCK_PLAYING_NOT_ENDING), mediaGeneration = 7)
assertEquals(1, delegate.fallbackRequests)
assertTrue(delegate.events.none { it.first == "end-file" })
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
@Test
@Config(sdk = [28])
fun otherStuckReportsAreNotTreatedAsEndOfFile() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
activity.setContentView(FrameLayout(activity))
val core = ExoPlayerCore(activity)
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
try {
assertTrue(core.initialize())
// A frozen clock at the same position is a stalled stream, not a finished
// file: it keeps the fallback so the other backend can rebuild it.
arrangeStalledEndOfStream(core, frameStallMs = 60_000L)
invokePlayerError(core, stuckError(StuckPlayerException.STUCK_PLAYING_NO_PROGRESS), mediaGeneration = 7)
assertEquals(1, delegate.fallbackRequests)
assertTrue(delegate.events.none { it.first == "end-file" })
} finally {
core.dispose()
shadowOf(Looper.getMainLooper()).idle()
}
}
@Test
fun fallbackNeverResumesTheOtherBackendAtOrPastTheEnd() {
val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get())
val delegate = RecordingDelegate(handlesFallback = true)
core.delegate = delegate
setField(core, "lastDuration", 2_623_668L)
try {
// The observed hand-off asked MPV to start a minute past the last frame,
// which opens straight into EOF and parks there.
assertTrue(requestFallback(core, mediaGeneration = 0, positionMs = 2_683_484L))
assertEquals(2_622_668L, delegate.lastFallbackPositionMs)
assertTrue(requestFallback(core, mediaGeneration = 0, positionMs = 30_000L))
assertEquals(30_000L, delegate.lastFallbackPositionMs)
} finally {
core.dispose()
}
}
/** Media that rendered, reached its declared end, and stopped painting. */
private fun arrangeStalledEndOfStream(core: ExoPlayerCore, frameStallMs: Long) {
setField(core, "currentMediaGeneration", 7)
setField(core, "currentMediaUri", "https://example.test/episode.mkv")
setField(core, "firstFrameRendered", true)
setField(core, "hasRenderedVideoFrameForMedia", true)
setField(core, "lastDuration", 2_623_668L)
setField(core, "lastPosition", 2_683_484L)
setField(core, "lastRenderedFrameChangeMs", System.currentTimeMillis() - frameStallMs)
}
private fun stuckError(stuckType: Int): ExoPlaybackException = ExoPlaybackException.createForUnexpected(
StuckPlayerException(stuckType, EndOfStreamPolicy.STALL_TIMEOUT_MS),
PlaybackException.ERROR_CODE_TIMEOUT
)
private fun setField(target: Any, name: String, value: Any?) {
target.javaClass.getDeclaredField(name).apply {
isAccessible = true
@@ -364,7 +488,7 @@ class ExoPlayerFallbackTerminalTest {
private fun getField(target: Any, name: String): Any? = target.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(target)
private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int): Boolean {
private fun requestFallback(core: ExoPlayerCore, mediaGeneration: Int, positionMs: Long = 0L): Boolean {
val method = ExoPlayerCore::class.java.getDeclaredMethod(
"requestFormatFallback",
Int::class.javaPrimitiveType,
@@ -378,7 +502,7 @@ class ExoPlayerFallbackTerminalTest {
core,
mediaGeneration,
"https://example.test/video.mkv",
0L,
positionMs,
true,
"unsupported video"
) as Boolean
@@ -494,6 +618,7 @@ class ExoPlayerFallbackTerminalTest {
private val handlesFallback: Boolean
) : ExoPlayerDelegate {
var fallbackRequests = 0
var lastFallbackPositionMs = -1L
val properties = mutableListOf<Pair<String, Any?>>()
val events = mutableListOf<Pair<String, Map<String, Any>?>>()
@@ -506,6 +631,7 @@ class ExoPlayerFallbackTerminalTest {
errorMessage: String
): Boolean {
fallbackRequests++
lastFallbackPositionMs = positionMs
return handlesFallback
}
@@ -0,0 +1,101 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/player/platform/player_android.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
import 'package:plezy/services/settings_service.dart';
import '../../test_helpers/prefs.dart';
/// Publishes a starting timeline the way `open()` does, without a platform
/// round-trip. The position stream is throttled to 4Hz, so the seed also keeps
/// the test off the wall clock.
class _SeededPlayerAndroid extends PlayerAndroid {
void seedPosition(Duration position) => resetPlaybackProgress(position);
}
/// End-to-end contract for #1673: a player that runs past its duration without
/// ending is reported by the native side as an ordinary end of file, and the
/// Dart completion flow has to read it as the *real* end so Play Next / auto-play
/// runs. A misread routes into dead-stream recovery instead, leaving the black
/// screen and the "playing" timeline the issue is about.
///
/// The event sequence below is exactly what `ExoPlayerCore.emitPlaybackEofOnce`
/// sends, including the timeline pin that precedes the terminal event.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const durationMs = 2623668;
const duration = Duration(milliseconds: durationMs);
setUp(() async {
resetSharedPreferencesForTest();
SettingsService.resetForTesting();
await SettingsService.getInstance();
});
test('a synthesized end of file completes the item at its duration', () async {
final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
const channel = MethodChannel('com.plezy/exo_player');
messenger.setMockMethodCallHandler(channel, (_) async => null);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final player = _SeededPlayerAndroid();
final completions = <bool>[];
final subscription = player.streams.completed.listen(completions.add);
addTearDown(() async {
await subscription.cancel();
await player.dispose();
});
// Direct play a few seconds from the end: duration comes from the native
// property, as it does when nothing overrides the timeline.
player.seedPosition(const Duration(milliseconds: 2620000));
player.handlePropertyChange('duration', durationMs / 1000.0);
player.handlePropertyChange('pause', false);
expect(player.state.duration, duration);
expect(player.state.playing, isTrue);
// The renderers never ended; the native side pins the timeline at the end
// and reports the file as finished.
player.handlePropertyChange('time-pos', durationMs / 1000.0);
player.handlePropertyChange('paused-for-cache', false);
player.handlePropertyChange('pause', true);
player.handlePropertyChange('eof-reached', true);
player.handlePlayerEvent('end-file', const {'reason': 'eof'});
await Future<void>.delayed(Duration.zero);
// The pin lands on the unthrottled position immediately; the 4Hz state
// snapshot may still carry the previous tick, which is why the classifier
// below has to hold for both.
expect(player.currentPosition, duration);
expect(player.state.playing, isFalse);
expect(player.state.completed, isTrue);
expect(completions.last, isTrue);
// What the screen does with that state: the EOF is genuine, so the item is
// stopped at its duration and the next episode is presented.
expect(
classifyEofSignal(
positionMs: player.state.position.inMilliseconds,
playerDurationMs: player.state.duration.inMilliseconds,
metadataDurationMs: durationMs,
),
EofSignalClass.genuine,
);
expect(
completionNavigationAction(hasNext: true, adjacentStatus: QueueNavigationStatus.found),
CompletionNavigationAction.presentNext,
);
});
test('the same event from a stale mid-file position stays a dead-stream signal', () {
// Why the terminal path publishes the end position and the position loop
// freezes after it: media3 has already stopped the player, and an EOF
// carrying a stale position routes into spurious-EOF recovery instead.
expect(
classifyEofSignal(positionMs: 1200000, playerDurationMs: durationMs, metadataDurationMs: durationMs),
EofSignalClass.spurious,
);
});
}
@@ -1334,6 +1334,40 @@ void main() {
expect(client.updateProgressCalls.map((call) => call.state), ['stopped', 'playing', 'stopped']);
});
test('a stopped report ends the session: a clock that runs past the end reports nothing', () {
// #1673: the native clock can keep advancing after the file is over. Once
// the completion flow has stopped the item, no later tick may reach the
// server — a repeated `playing` at the end is what servers extrapolate into
// a ghost session running past the item duration.
fakeAsync((async) {
final client = _FakePlexClient();
final player = _FakePlayer(position: const Duration(seconds: 50), duration: const Duration(seconds: 100));
final tracker = PlaybackProgressTracker(
client: client,
metadata: _meta(),
player: player,
isOffline: false,
updateInterval: const Duration(seconds: 1),
);
tracker.startTracking();
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing']);
unawaited(tracker.sendStoppedProgressOnce(positionOverride: const Duration(seconds: 100)));
async.flushMicrotasks();
player.position = const Duration(seconds: 160);
async.elapse(const Duration(seconds: 10));
async.flushMicrotasks();
expect(client.updateProgressCalls.map((call) => call.state), ['playing', 'stopped']);
expect(client.updateProgressCalls.last.time, 100000);
tracker.dispose();
});
});
// ============================================================
// startTracking / stopTracking / dispose lifecycle
// ============================================================