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:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
+128
-2
@@ -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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user