diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicy.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicy.kt new file mode 100644 index 00000000..b7d4bc64 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicy.kt @@ -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)) + } +} 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 7b3f501a..ccc23335 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 @@ -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 diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicyTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicyTest.kt new file mode 100644 index 00000000..7653825c --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/EndOfStreamPolicyTest.kt @@ -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) + ) + } +} 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 47218c36..26dd22c7 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 @@ -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>() val events = mutableListOf?>>() @@ -506,6 +631,7 @@ class ExoPlayerFallbackTerminalTest { errorMessage: String ): Boolean { fallbackRequests++ + lastFallbackPositionMs = positionMs return handlesFallback } diff --git a/test/screens/video_player/end_of_stream_completion_test.dart b/test/screens/video_player/end_of_stream_completion_test.dart new file mode 100644 index 00000000..0c8131ac --- /dev/null +++ b/test/screens/video_player/end_of_stream_completion_test.dart @@ -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 = []; + 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.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, + ); + }); +} diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 565f8f9c..3573bb4a 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -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 // ============================================================