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 669480e5..99550e76 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 @@ -30,6 +30,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.Timeline import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks @@ -86,6 +87,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { * @return true if fallback was handled, false to emit error event to Flutter */ fun onFormatUnsupported( + mediaGeneration: Int, uri: String, headers: Map?, positionMs: Long, @@ -309,6 +311,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private var currentMediaUri: String? = null private var currentHeaders: Map? = null private var currentMediaIsLive: Boolean = false + private var currentMediaGeneration: Int = 0 + private var terminalErrorGeneration: Int? = null private var currentVisible: Boolean = false private var selectedAudioTrackId: String? = null private var selectedSubtitleTrackId: String? = null @@ -369,6 +373,39 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) } } + private fun emitPlaybackErrorOnce(mediaGeneration: Int, message: String, cause: String? = null) { + if (mediaGeneration != currentMediaGeneration || terminalErrorGeneration == mediaGeneration) return + terminalErrorGeneration = mediaGeneration + exoPlayer?.playWhenReady = false + delegate?.onPropertyChange("paused-for-cache", false) + delegate?.onPropertyChange("pause", true) + val data = mutableMapOf( + "reason" to "error", + "message" to message + ) + if (cause != null) data["cause"] = cause + delegate?.onEvent("end-file", data) + } + + private fun requestFormatFallback( + mediaGeneration: Int, + uri: String, + positionMs: Long, + playWhenReady: Boolean, + errorMessage: String + ): Boolean { + if (mediaGeneration != currentMediaGeneration) return true + val handled = delegate?.onFormatUnsupported( + mediaGeneration = mediaGeneration, + uri = uri, + headers = currentHeaders, + positionMs = positionMs, + playWhenReady = playWhenReady, + errorMessage = errorMessage + ) ?: false + if (!handled) emitPlaybackErrorOnce(mediaGeneration, errorMessage) + return handled + } private fun redactUri(uri: String): String { return try { @@ -935,14 +972,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { bitmapSubtitleView?.setCues(bitmapCues) } - override fun onIsPlayingChanged(isPlaying: Boolean) { + private fun handleIsPlayingChanged(isPlaying: Boolean) { Log.d(TAG, "onIsPlayingChanged: $isPlaying") + // A pause/resume can occur entirely between watchdog polls while the + // looper is stalled. Reset here so paused wall time can never make the + // first post-resume poll look like an eight-second render stall. + frameWatchdogStartTime = System.currentTimeMillis() if (isPlaying) pendingPlayWhenReady = null if (isPlaying) armResumeStallWatchdog() else cancelResumeStallWatchdog() delegate?.onPropertyChange("pause", !isPlaying) } - override fun onPlaybackStateChanged(state: Int) { + private fun handlePlaybackStateChanged(state: Int) { val stateStr = when (state) { Player.STATE_IDLE -> "idle" Player.STATE_BUFFERING -> "buffering" @@ -975,11 +1016,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { exoPlayer?.playWhenReady = pendingPlay } delegate?.onPropertyChange("paused-for-cache", false) - delegate?.onEvent("playback-restart", null) emitTrackList() - // Start frame watchdog to detect black screen (HDR tunneling issue) - startFrameWatchdog() + // Video becomes ready only when a frame renders. Audio-only media has + // no video callback, so STATE_READY 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") + stopFrameWatchdog() + delegate?.onEvent("playback-restart", null) + } } Player.STATE_ENDED -> { stopFrameWatchdog() @@ -991,20 +1039,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { override fun onTracksChanged(tracks: Tracks) { Log.d(TAG, "onTracksChanged") - // Detect video track present but deselected (unsupported codec — plays audio only) - val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } - val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } - if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { - // Try DV conversion before falling to MPV - if (retryWithDvConversion("video track not selected")) return - emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") - delegate?.onFormatUnsupported( - uri = currentMediaUri!!, - headers = currentHeaders, - positionMs = effectivePosition, - playWhenReady = exoPlayer?.playWhenReady ?: true, - errorMessage = "Video track present but no decoder available" - ) + // Player.Listener does not identify the originating MediaItem. A queued + // callback from the replaced item must not publish tracks for the new one; + // generation-bound unsupported-format handling lives in AnalyticsListener. + if (tracks != exoPlayer?.currentTracks) { + Log.d(TAG, "Ignoring stale track callback") return } @@ -1064,7 +1103,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { emitTrackList() } - override fun onPlayerError(error: PlaybackException) { + private fun handlePlayerError(error: PlaybackException, mediaGeneration: Int) { // Log full exception chain unminified — R8 mangles simpleName but not toString/message val causeChain = buildString { var t: Throwable? = error.cause @@ -1091,39 +1130,30 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { (error.message?.contains("Response code: 500") == true) if (isHttp500) { Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)") - delegate?.onEvent( - "end-file", - mapOf( - "reason" to "error", - "message" to (error.message ?: "HTTP 500"), - "cause" to "server-http-500" - ) + emitPlaybackErrorOnce( + mediaGeneration, + error.message ?: "HTTP 500", + cause = "server-http-500" ) return } if (retryAfterAudioTrackError(error, causeChain)) return - if (currentMediaUri != null) { + val uri = currentMediaUri + if (uri != null) { Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV") - val handled = delegate?.onFormatUnsupported( - uri = currentMediaUri!!, - headers = currentHeaders, + requestFormatFallback( + mediaGeneration = mediaGeneration, + uri = uri, positionMs = effectivePosition, playWhenReady = exoPlayer?.playWhenReady ?: true, errorMessage = error.message ?: "Unknown error" - ) ?: false - - if (handled) return + ) + return } - delegate?.onEvent( - "end-file", - mapOf( - "reason" to "error", - "message" to (error.message ?: "Unknown error") - ) - ) + emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error") } private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean { @@ -1339,6 +1369,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + val mediaGeneration = mediaItem?.mediaId?.toIntOrNull() ?: return + if (mediaGeneration != currentMediaGeneration) return Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason") delegate?.onEvent("file-loaded", null) delegate?.onPropertyChange("eof-reached", false) @@ -2290,6 +2322,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun buildMediaItem(uri: String): MediaItem { val mediaItemBuilder = MediaItem.Builder() + .setMediaId(currentMediaGeneration.toString()) .setUri(uri) // Every Live TV backend negotiates HLS before opening the native player. @@ -2357,18 +2390,73 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // If the decoder is initialized and fed input but never produces output, it's hung // (e.g. DV profile 7 on PowerVR GPUs that accept the format but never decode). + private fun mediaGenerationAt(eventTime: AnalyticsListener.EventTime): Int? { + val timeline = eventTime.timeline + val windowIndex = eventTime.windowIndex + if (windowIndex !in 0 until timeline.windowCount) return null + return timeline.getWindow(windowIndex, Timeline.Window()).mediaItem.mediaId.toIntOrNull() + } + + private fun requestFallbackForUnsupportedTracks(tracks: Tracks, mediaGeneration: Int) { + if (mediaGeneration != currentMediaGeneration) return + val uri = currentMediaUri ?: return + val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } + val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } + if (!hasAnyVideoGroup || hasSelectedVideo) return + + if (retryWithDvConversion("video track not selected")) return + emitLog("warn", "fallback", "Video track present but not selected (unsupported codec)") + requestFormatFallback( + mediaGeneration = mediaGeneration, + uri = uri, + positionMs = effectivePosition, + playWhenReady = exoPlayer?.playWhenReady ?: true, + errorMessage = "Video track present but no decoder available" + ) + } + + private fun claimPlaybackOutputReady(): Boolean { + if (firstFrameRendered) return false + firstFrameRendered = true + cancelDecoderHangCheck() + return true + } + private val decoderHangListener = object : AnalyticsListener { + override fun onIsPlayingChanged(eventTime: AnalyticsListener.EventTime, isPlaying: Boolean) { + if (mediaGenerationAt(eventTime) != currentMediaGeneration) return + handleIsPlayingChanged(isPlaying) + } + + override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) { + if (mediaGenerationAt(eventTime) != currentMediaGeneration) return + handlePlaybackStateChanged(state) + } + + override fun onPlayerError(eventTime: AnalyticsListener.EventTime, error: PlaybackException) { + val mediaGeneration = mediaGenerationAt(eventTime) ?: return + if (mediaGeneration != currentMediaGeneration) return + handlePlayerError(error, mediaGeneration) + } + + override fun onTracksChanged(eventTime: AnalyticsListener.EventTime, tracks: Tracks) { + val mediaGeneration = mediaGenerationAt(eventTime) ?: return + requestFallbackForUnsupportedTracks(tracks, mediaGeneration) + } + override fun onVideoDecoderInitialized( eventTime: AnalyticsListener.EventTime, decoderName: String, initializationDurationMs: Long ) { + val mediaGeneration = mediaGenerationAt(eventTime) ?: return + if (mediaGeneration != currentMediaGeneration) return decoderInitName = decoderName persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName) firstFrameRendered = false emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") logDolbyVisionPlaybackPathIfNeeded(decoderName) - startDecoderHangCheck(decoderName) + startDecoderHangCheck(decoderName, mediaGeneration) } override fun onAudioDecoderInitialized( @@ -2484,8 +2572,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { output: Any, renderTimeMs: Long ) { - firstFrameRendered = true - cancelDecoderHangCheck() + val mediaGeneration = mediaGenerationAt(eventTime) ?: return + if (mediaGeneration != currentMediaGeneration || !claimPlaybackOutputReady()) return emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") logNativeDvFirstFrameIfNeeded() logDolbyVisionPlaybackPathIfNeeded() @@ -2498,10 +2586,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - private fun startDecoderHangCheck(decoderName: String) { + private fun startDecoderHangCheck(decoderName: String, mediaGeneration: Int) { cancelDecoderHangCheck() - if (currentMediaUri == null) return + if (mediaGeneration != currentMediaGeneration || currentMediaUri == null) return decoderHangRunnable = Runnable { + if (mediaGeneration != currentMediaGeneration) return@Runnable if (firstFrameRendered) return@Runnable val uri = currentMediaUri ?: return@Runnable val player = exoPlayer ?: return@Runnable @@ -2518,9 +2607,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { stopFrameWatchdog() cancelDecoderHangCheck() if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable - delegate?.onFormatUnsupported( + requestFormatFallback( + mediaGeneration = mediaGeneration, uri = uri, - headers = currentHeaders, positionMs = effectivePosition, playWhenReady = player.playWhenReady, errorMessage = "Decoder hang: $decoderName accepted input but produced no output" @@ -2538,14 +2627,28 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Frame watchdog: detects when ExoPlayer plays audio but renders 0 video frames // (common with HDR tunneling on unsupported devices — black screen, no error) + private fun frameWatchdogElapsedMs(nowMs: Long, isPlaying: Boolean): Long { + if (!isPlaying) frameWatchdogStartTime = nowMs + return nowMs - frameWatchdogStartTime + } + private fun startFrameWatchdog() { stopFrameWatchdog() emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)") frameWatchdogStartTime = System.currentTimeMillis() + val mediaGeneration = currentMediaGeneration + val initialCounters = exoPlayer?.videoDecoderCounters + val initialRenderedFrames = initialCounters?.renderedOutputBufferCount ?: 0 frameWatchdogRunnable = object : Runnable { override fun run() { + if (mediaGeneration != currentMediaGeneration) return val player = exoPlayer ?: return - val renderedFrames = player.videoDecoderCounters?.renderedOutputBufferCount ?: 0 + val counters = player.videoDecoderCounters + val renderedFrames = if (counters === initialCounters) { + (counters?.renderedOutputBufferCount ?: 0) - initialRenderedFrames + } else { + counters?.renderedOutputBufferCount ?: 0 + } if (renderedFrames > 0) { emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared") @@ -2553,7 +2656,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return } - val elapsed = System.currentTimeMillis() - frameWatchdogStartTime + val elapsed = frameWatchdogElapsedMs(System.currentTimeMillis(), player.isPlaying) // Check if we have a video track selected val hasVideoTrack = player.currentTracks.groups.any { @@ -2569,9 +2672,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { stopFrameWatchdog() if (retryWithDvConversion("watchdog: video track deselected")) return val uri = currentMediaUri ?: return - delegate?.onFormatUnsupported( + requestFormatFallback( + mediaGeneration = mediaGeneration, uri = uri, - headers = currentHeaders, positionMs = player.currentPosition, playWhenReady = player.playWhenReady, errorMessage = "Video track present but no decoder available" @@ -2585,9 +2688,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return // Trigger fallback via the same delegate path as player errors val uri = currentMediaUri ?: return - delegate?.onFormatUnsupported( + requestFormatFallback( + mediaGeneration = mediaGeneration, uri = uri, - headers = currentHeaders, positionMs = player.currentPosition, playWhenReady = player.playWhenReady, errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" @@ -2731,6 +2834,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { headers: Map?, startPositionMs: Long, autoPlay: Boolean, + mediaGeneration: Int, isLive: Boolean = false, externalSubtitleList: List>? = null ) { @@ -2762,12 +2866,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { directAudioOutputBlockedAfterFailure.clear() loggedDirectAudioRecoveryBlocks.clear() currentVideoFormat = null + firstFrameRendered = false loggedNativeDvSelectionKey = null loggedNativeDvFirstFrame = false loggedDvPlaybackPathKey = null lastDvPlaybackInfo = null loggedDecodedPcmTunnelingGuard = false updateAudioDecoderPolicy("open") + currentMediaGeneration = mediaGeneration + terminalErrorGeneration = null currentMediaUri = uri currentHeaders = headers currentMediaIsLive = isLive @@ -3568,7 +3675,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val uri = currentMediaUri ?: return val player = exoPlayer val pos = player?.currentPosition ?: 0L - delegate?.onFormatUnsupported(uri, currentHeaders, pos, player?.playWhenReady ?: true, "debug: manual fallback trigger") + requestFormatFallback( + mediaGeneration = currentMediaGeneration, + uri = uri, + positionMs = pos, + playWhenReady = player?.playWhenReady ?: true, + errorMessage = "debug: manual fallback trigger" + ) } // Cleanup diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 72067723..1544feae 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -10,12 +10,15 @@ import com.edde746.plezy.mpv.completeMpvPropertyNotInitialized import com.edde746.plezy.mpv.completeMpvPropertyResult import com.edde746.plezy.shared.MpvContentUriResolver import com.edde746.plezy.shared.PlayerChannelBinding +import com.edde746.plezy.shared.PlayerDelegate +import com.edde746.plezy.shared.ResolvedMpvUri import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.atomic.AtomicBoolean class ExoPlayerPlugin : FlutterPlugin, @@ -26,6 +29,8 @@ class ExoPlayerPlugin : companion object { private const val TAG = "ExoPlayerPlugin" + private const val MPV_FALLBACK_INIT_TIMEOUT_MS = 10_000L + private const val MPV_OPEN_TIMEOUT_MS = 10_000L private const val METHOD_CHANNEL = "com.plezy/exo_player" } @@ -36,6 +41,7 @@ class ExoPlayerPlugin : private var mpvCore: MpvPlayerCore? = null // MPV fallback player private var usingMpvFallback: Boolean = false private var fallbackInProgress: Boolean = false + private var backendSwitchPending: Boolean = false private var activity: Activity? = null private var activityBinding: ActivityPluginBinding? = null @@ -43,19 +49,77 @@ class ExoPlayerPlugin : // fallback can re-observe exactly what Dart asked for instead of // maintaining a parallel hard-coded list. private data class ObservedProperty(val id: Int, val format: String) + private class MediaOpenRequest( + val mediaGeneration: Int, + val uri: String, + val headers: Map?, + val startPositionMs: Long, + val hasStartPosition: Boolean, + val autoPlay: Boolean, + val isLive: Boolean, + val externalSubtitles: List>?, + private val result: MethodChannel.Result? + ) { + private val completed = AtomicBoolean(false) + + fun success() { + if (completed.compareAndSet(false, true)) result?.success(null) + } + + fun error(code: String, message: String) { + if (completed.compareAndSet(false, true)) result?.error(code, message, null) + } + } + + private sealed class PendingMpvSignal { + data class Property(val id: Int, val value: Any?) : PendingMpvSignal() + data class Event(val name: String, val data: Map?) : PendingMpvSignal() + } + + private data class MpvSignalGate( + val core: MpvPlayerCore, + val mediaGeneration: Int, + val signals: MutableList = mutableListOf() + ) + + private inner class MpvDelegate(private val core: MpvPlayerCore) : PlayerDelegate { + override fun onPropertyChange(name: String, value: Any?) { + runOnMain { forwardMpvProperty(core, name, value) } + } + + override fun onEvent(name: String, data: Map?) { + val snapshot = data?.toMap() + runOnMain { forwardMpvEvent(core, name, snapshot) } + } + } + private val observedProperties = LinkedHashMap() private var configuredBufferSizeBytes: Int? = null private var sessionGeneration = 0 + private var mediaGeneration = 0 + private var fallbackMediaGeneration: Int? = null + private var terminalEventGeneration: Int? = null + private var pendingOpen: MediaOpenRequest? = null + private var inFlightOpen: MediaOpenRequest? = null + private var mpvForwardGeneration: Int? = null + private var mpvSignalGate: MpvSignalGate? = null + private var mpvCoreNeedsReplacement = false + internal var createMpvCore: (Activity) -> MpvPlayerCore = { MpvPlayerCore(it) } + internal var initializeMpvCore: (MpvPlayerCore, (Boolean) -> Unit) -> Unit = { core, onInitialized -> + core.initialize(onInitialized) + } + internal var resolveMpvUri: (String, Activity, (ResolvedMpvUri) -> Unit) -> Unit = { uri, act, onResolved -> + MpvContentUriResolver.resolve(uri, act.contentResolver, mainHandler, onResolved) + } private var debugLoggingEnabled: Boolean = false // mpv properties set while ExoPlayer is active (including before // initialize — Dart queues its startup properties first), replayed into a // fallback MPV core. Keyed by property name (last write wins) and cleared - // only at real session boundaries (dispose, engine detach, open while the - // fallback is already active) so one playback's properties never leak into - // the next session's fallback. + // only at real session boundaries so settings can be replayed if a + // superseded load requires a fresh MPV core. private val pendingMpvProperties = LinkedHashMap() private var currentExternalSubtitles: List>? = null @@ -72,12 +136,23 @@ class ExoPlayerPlugin : private fun teardownSession(clearActivity: Boolean) { ++sessionGeneration + ++mediaGeneration val exoCore = playerCore val fallbackCore = mpvCore playerCore = null mpvCore = null usingMpvFallback = false fallbackInProgress = false + backendSwitchPending = false + mpvForwardGeneration = null + mpvSignalGate = null + mpvCoreNeedsReplacement = false + fallbackMediaGeneration = null + terminalEventGeneration = null + pendingOpen?.error("NOT_INITIALIZED", "Player session ended before media could open") + pendingOpen = null + inFlightOpen?.error("NOT_INITIALIZED", "Player session ended before media could open") + inFlightOpen = null currentExternalSubtitles = null pendingMpvProperties.clear() if (clearActivity) { @@ -191,8 +266,8 @@ class ExoPlayerPlugin : } val requestGeneration = sessionGeneration - if (playerCore?.isInitialized == true) { - Log.d(TAG, "Already initialized") + if (playerCore?.isInitialized == true || mpvCore?.isInitialized == true || fallbackInProgress) { + Log.d(TAG, "Already initialized or switching backend") result.success(true) return } @@ -286,63 +361,389 @@ class ExoPlayerPlugin : result.error("NO_ACTIVITY", "Activity not available", null) return } - val externalSubtitleSnapshot = externalSubtitles?.map { it.toMap() } - currentExternalSubtitles = externalSubtitleSnapshot - // Only clear pending MPV state when MPV is the active backend. A same-core - // MPV reload must not inherit a fallback switch that was armed for the - // previous load. When ExoPlayer is active, keep queued properties for a - // potential ExoPlayer→MPV fallback. + val request = MediaOpenRequest( + mediaGeneration = ++mediaGeneration, + uri = uri, + headers = headers, + startPositionMs = startPositionMs, + hasStartPosition = hasStartPosition, + autoPlay = autoPlay, + isLive = isLive, + externalSubtitles = externalSubtitles?.map { it.toMap() }, + result = result + ) + terminalEventGeneration = null + currentExternalSubtitles = request.externalSubtitles + + if (fallbackInProgress) { + pendingOpen?.let(::completeSupersededOpen) + pendingOpen = request + Log.i(TAG, "Queued media generation ${request.mediaGeneration} until MPV fallback is ready") + return + } + if (usingMpvFallback) { - pendingMpvProperties.clear() - val generation = sessionGeneration - MpvContentUriResolver.resolve(uri, currentActivity.contentResolver, mainHandler) { source -> - if (generation != sessionGeneration || activity !== currentActivity || !usingMpvFallback) { - source.closeIfUnused() - result.success(null) - return@resolve - } - loadMpvMedia( - source.value, - headers, - startPositionMs, - hasStartPosition, - autoPlay, - externalSubtitleSnapshot - ) - result.success(null) + val core = mpvCore + if (core?.isInitialized != true) { + request.error("NOT_INITIALIZED", "Compatible player is not initialized") + return + } + val activeOpen = inFlightOpen + if (activeOpen != null) { + completeSupersededOpen(activeOpen) + mpvCoreNeedsReplacement = true + pendingOpen?.let(::completeSupersededOpen) + pendingOpen = request + Log.i(TAG, "Queued media generation ${request.mediaGeneration} behind the active MPV open") + return + } + pendingOpen?.let(::completeSupersededOpen) + if (mpvCoreNeedsReplacement) { + pendingOpen = request + dispatchPendingMpvOpen(core, currentActivity, sessionGeneration) + } else { + pendingOpen = null + dispatchMpvOpen(request, core, currentActivity, sessionGeneration) } return } currentActivity.runOnUiThread { - playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitleSnapshot) - result.success(null) + if (request.mediaGeneration != mediaGeneration) { + completeSupersededOpen(request) + return@runOnUiThread + } + if (activity !== currentActivity) { + request.error("NO_ACTIVITY", "Activity is no longer available") + return@runOnUiThread + } + val core = playerCore + if (core?.isInitialized != true) { + request.error("NOT_INITIALIZED", "ExoPlayer is not initialized") + return@runOnUiThread + } + core.open( + uri = uri, + headers = headers, + startPositionMs = startPositionMs, + autoPlay = autoPlay, + mediaGeneration = request.mediaGeneration, + isLive = isLive, + externalSubtitleList = request.externalSubtitles + ) + request.success() } } private fun loadMpvMedia( + core: MpvPlayerCore, uri: String, headers: Map?, startPositionMs: Long, hasStartPosition: Boolean, autoPlay: Boolean, - externalSubtitles: List>? + externalSubtitles: List>?, + onComplete: (Boolean) -> Unit ) { val startSeconds = startPositionMs / 1000.0 val options = mutableListOf() options.add(if (hasStartPosition && startPositionMs > 0L) "start=$startSeconds" else "start=none") - if (!autoPlay) options.add("pause=yes") + options.add(if (autoPlay) "pause=no" else "pause=yes") options.add("sid=no") options.add("secondary-sid=no") appendExternalSubtitleOptions(options, externalSubtitles) appendHttpHeaderOptions(options, headers) val optionsStr = options.joinToString(",") - val core = mpvCore ?: return - core.setPauseIntentForLoad(paused = !autoPlay) - core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success -> - if (success && autoPlay) { - core.setProperty("pause", "no") + core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr), onComplete) + } + + private fun completeSupersededOpen(request: MediaOpenRequest) { + request.error("OPEN_SUPERSEDED", "A newer media open replaced this request") + } + + private fun forwardMpvProperty(core: MpvPlayerCore, name: String, value: Any?) { + if (mpvCore !== core || !usingMpvFallback) return + val propId = observedProperties[name]?.id ?: return + val gate = mpvSignalGate + if (gate != null) { + if (gate.core === core && gate.mediaGeneration == mediaGeneration) { + gate.signals.add(PendingMpvSignal.Property(propId, value)) + } + return + } + if (mpvForwardGeneration == mediaGeneration) channels.emitProperty(propId, value) + } + + private fun forwardMpvEvent(core: MpvPlayerCore, name: String, data: Map?) { + if (mpvCore !== core || !usingMpvFallback) return + val gate = mpvSignalGate + if (gate != null) { + if (gate.core === core && gate.mediaGeneration == mediaGeneration) { + gate.signals.add(PendingMpvSignal.Event(name, data)) + } + return + } + if (mpvForwardGeneration == mediaGeneration) channels.emitEvent(name, data) + } + + private fun flushMpvSignals(core: MpvPlayerCore, request: MediaOpenRequest) { + val gate = mpvSignalGate ?: return + if (gate.core !== core || gate.mediaGeneration != request.mediaGeneration) return + mpvSignalGate = null + for (signal in gate.signals) { + when (signal) { + is PendingMpvSignal.Property -> channels.emitProperty(signal.id, signal.value) + is PendingMpvSignal.Event -> channels.emitEvent(signal.name, signal.data) + } + } + } + + private fun discardMpvSignals(core: MpvPlayerCore, request: MediaOpenRequest) { + val gate = mpvSignalGate ?: return + if (gate.core === core && gate.mediaGeneration == request.mediaGeneration) { + mpvSignalGate = null + } + } + + private fun dispatchPendingMpvOpen(core: MpvPlayerCore, act: Activity, generation: Int) { + runOnMain { + if (inFlightOpen != null) return@runOnMain + val next = pendingOpen ?: return@runOnMain + pendingOpen = null + if (generation != sessionGeneration || activity !== act || !usingMpvFallback || mpvCore !== core) { + next.error("NOT_INITIALIZED", "Compatible player is no longer available") + return@runOnMain + } + if (mpvCoreNeedsReplacement) { + replaceMpvCoreForOpen(next, core, act, generation) + } else { + dispatchMpvOpen(next, core, act, generation) + } + } + } + + private fun replaceMpvCoreForOpen( + request: MediaOpenRequest, + oldCore: MpvPlayerCore, + act: Activity, + generation: Int + ) { + fallbackInProgress = true + pendingOpen = request + mpvForwardGeneration = null + mpvSignalGate = null + if (mpvCore === oldCore) mpvCore = null + oldCore.dispose() + + val replacementCore = try { + createMpvCore(act) + } catch (error: Exception) { + failMpvReplacementInitialization( + request.mediaGeneration, + null, + "Failed to recreate compatible player", + error + ) + return + } + replacementCore.delegate = MpvDelegate(replacementCore) + mpvCore = replacementCore + + val settled = AtomicBoolean(false) + val timeout = Runnable { + if (!settled.compareAndSet(false, true)) return@Runnable + failMpvReplacementInitialization( + request.mediaGeneration, + replacementCore, + "Timed out recreating compatible player" + ) + } + mainHandler.postDelayed(timeout, MPV_FALLBACK_INIT_TIMEOUT_MS) + + try { + initializeMpvCore(replacementCore) { success -> + runOnMain { + if (!settled.compareAndSet(false, true)) return@runOnMain + mainHandler.removeCallbacks(timeout) + if ( + generation != sessionGeneration || + activity !== act || + !usingMpvFallback || + mpvCore !== replacementCore + ) { + replacementCore.dispose() + return@runOnMain + } + if (!success) { + failMpvReplacementInitialization( + request.mediaGeneration, + replacementCore, + "Failed to recreate compatible player" + ) + return@runOnMain + } + + fallbackInProgress = false + mpvCoreNeedsReplacement = false + val next = pendingOpen + pendingOpen = null + if (next == null) return@runOnMain + replacementCore.setPauseIntentForLoad(paused = !next.autoPlay) + prepareMpvFallback(replacementCore) + dispatchMpvOpen(next, replacementCore, act, generation) + } + } + } catch (error: Exception) { + if (settled.compareAndSet(false, true)) { + mainHandler.removeCallbacks(timeout) + failMpvReplacementInitialization( + request.mediaGeneration, + replacementCore, + "Failed to recreate compatible player", + error + ) + } + } + } + + private fun failMpvReplacementInitialization( + mediaGeneration: Int, + core: MpvPlayerCore?, + logMessage: String, + error: Throwable? = null + ) { + if (error == null) { + Log.e(TAG, logMessage) + } else { + Log.e(TAG, logMessage, error) + } + if (core != null && mpvCore !== core) return + + fallbackInProgress = false + usingMpvFallback = false + backendSwitchPending = false + mpvCoreNeedsReplacement = false + mpvForwardGeneration = null + mpvSignalGate = null + mpvCore = null + core?.dispose() + val failed = pendingOpen + pendingOpen = null + failed?.error("FALLBACK_FAILED", "Compatible player failed to initialize") + emitFallbackErrorOnce(failed?.mediaGeneration ?: mediaGeneration, "Compatible player failed to initialize") + } + + private fun dispatchMpvOpen( + request: MediaOpenRequest, + core: MpvPlayerCore, + act: Activity, + generation: Int + ) { + inFlightOpen = request + val settled = AtomicBoolean(false) + val timeout = Runnable { + if (!settled.compareAndSet(false, true)) return@Runnable + val wasActive = inFlightOpen === request + if (wasActive) inFlightOpen = null + discardMpvSignals(core, request) + if (mpvForwardGeneration == request.mediaGeneration) mpvForwardGeneration = null + request.error("OPEN_TIMEOUT", "Timed out opening media with compatible player") + + val fallbackIsCurrent = + wasActive && + request.mediaGeneration == mediaGeneration && + generation == sessionGeneration && + activity === act && + usingMpvFallback && + mpvCore === core + if (fallbackIsCurrent) { + failActiveFallback(request.mediaGeneration, "Timed out opening media with MPV fallback") + } else if (generation == sessionGeneration && activity === act && usingMpvFallback && mpvCore === core) { + dispatchPendingMpvOpen(core, act, generation) + } + } + mainHandler.postDelayed(timeout, MPV_OPEN_TIMEOUT_MS) + + resolveMpvUri(request.uri, act) { source -> + if (settled.get()) { + source.closeIfUnused() + return@resolveMpvUri + } + if (generation != sessionGeneration || activity !== act || !usingMpvFallback || mpvCore !== core) { + if (!settled.compareAndSet(false, true)) { + source.closeIfUnused() + return@resolveMpvUri + } + mainHandler.removeCallbacks(timeout) + source.closeIfUnused() + if (inFlightOpen === request) inFlightOpen = null + request.error("NOT_INITIALIZED", "Compatible player is no longer available") + return@resolveMpvUri + } + if (request.mediaGeneration != mediaGeneration) { + if (!settled.compareAndSet(false, true)) { + source.closeIfUnused() + return@resolveMpvUri + } + mainHandler.removeCallbacks(timeout) + source.closeIfUnused() + if (inFlightOpen === request) inFlightOpen = null + completeSupersededOpen(request) + dispatchPendingMpvOpen(core, act, generation) + return@resolveMpvUri + } + + core.setPauseIntentForLoad(paused = !request.autoPlay) + mpvForwardGeneration = request.mediaGeneration + mpvSignalGate = MpvSignalGate(core, request.mediaGeneration) + loadMpvMedia( + core = core, + uri = source.value, + headers = request.headers, + startPositionMs = request.startPositionMs, + hasStartPosition = request.hasStartPosition, + autoPlay = request.autoPlay, + externalSubtitles = request.externalSubtitles + ) { success -> + if (!settled.compareAndSet(false, true)) { + if (!success) source.closeIfUnused() + return@loadMpvMedia + } + mainHandler.removeCallbacks(timeout) + if (inFlightOpen === request) inFlightOpen = null + + if (request.mediaGeneration != mediaGeneration) { + if (!success) source.closeIfUnused() + discardMpvSignals(core, request) + if (mpvForwardGeneration == request.mediaGeneration) mpvForwardGeneration = null + completeSupersededOpen(request) + dispatchPendingMpvOpen(core, act, generation) + return@loadMpvMedia + } + if (generation != sessionGeneration || activity !== act || !usingMpvFallback || mpvCore !== core) { + if (!success) source.closeIfUnused() + discardMpvSignals(core, request) + if (mpvForwardGeneration == request.mediaGeneration) mpvForwardGeneration = null + request.error("NOT_INITIALIZED", "Compatible player is no longer available") + return@loadMpvMedia + } + if (!success) { + source.closeIfUnused() + discardMpvSignals(core, request) + if (mpvForwardGeneration == request.mediaGeneration) mpvForwardGeneration = null + request.error("OPEN_FAILED", "Compatible player failed to open media") + failActiveFallback(request.mediaGeneration, "Failed to open media with MPV fallback") + return@loadMpvMedia + } + + if (backendSwitchPending) { + backendSwitchPending = false + notifyBackendSwitched() + Log.i(TAG, "Successfully switched to MPV fallback") + } + flushMpvSignals(core, request) + request.success() } } } @@ -371,6 +772,7 @@ class ExoPlayerPlugin : completeMpvPropertyNotInitialized(result) return@runOnUiThread } + pendingMpvProperties[name] = value core.setProperty(name, value) { outcome -> val currentOutcome = if ( usingMpvFallback && @@ -883,100 +1285,110 @@ class ExoPlayerPlugin : * the media at the handoff position. Runs in MpvPlayerCore.initialize's * completion callback on the main thread. */ - private fun setupMpvFallback( - core: MpvPlayerCore, - act: Activity, - uri: String, - headers: Map?, - positionMs: Long, - externalSubtitles: List>?, - playWhenReady: Boolean, - generation: Int - ) { - // Snapshot Dart-registered state on main thread before clearing. + private fun prepareMpvFallback(core: MpvPlayerCore) { val pendingProps = pendingMpvProperties.toList() - pendingMpvProperties.clear() val observedProps = observedProperties.toList() val bufferSize = configuredBufferSizeBytes - MpvContentUriResolver.resolve(uri, act.contentResolver, mainHandler) { source -> - if (generation != sessionGeneration || mpvCore !== core || activity !== act || !usingMpvFallback) { - source.closeIfUnused() - core.dispose() - return@resolve - } + core.setProperty("hwdec", "mediacodec,mediacodec-copy") + core.setProperty("vo", "gpu") + core.setProperty("ao", "audiotrack") - // Configure basic MPV properties for Plex playback. - core.setProperty("hwdec", "mediacodec,mediacodec-copy") - core.setProperty("vo", "gpu") - core.setProperty("ao", "audiotrack") - - if (bufferSize != null && bufferSize > 0) { - core.setProperty("demuxer-max-bytes", bufferSize.toString()) - } - - for ((propName, propValue) in pendingProps) { - core.setProperty(propName, propValue) { outcome -> - if (outcome.isFailure) { - Log.w(TAG, "Failed to replay queued MPV property") - } - } - } - - // Re-observe exactly what Dart registered via observeProperty, so the - // event stream keeps flowing for every property the Dart side consumes. - for ((propName, observed) in observedProps) { - core.observeProperty(propName, observed.format) - } - - core.setVisible(true) - - val startSeconds = positionMs / 1000.0 - val options = mutableListOf() - options.add(if (positionMs > 0L) "start=$startSeconds" else "start=none") - if (!playWhenReady) options.add("pause=yes") - options.add("sid=no") - options.add("secondary-sid=no") - appendExternalSubtitleOptions(options, externalSubtitles) - appendHttpHeaderOptions(options, headers) - val optionsStr = options.joinToString(",") - core.setPauseIntentForLoad(paused = !playWhenReady) - notifyBackendSwitched() - core.command(arrayOf("loadfile", source.value, "replace", "-1", optionsStr)) - - // On GPUs without compute shaders, MPV can't do dynamic peak detection - // and spline tone-mapping produces dim/washed-out results with extreme - // static HDR peak metadata. Use reinhard which handles this better. - Thread { - val peakDetection = core.getProperty("hdr-compute-peak") - if (peakDetection == "no") { - Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") - core.setProperty("tone-mapping", "reinhard") - core.setProperty("tone-mapping-param", "0.7") - core.setProperty("tone-mapping-mode", "luma") - } - }.start() - - core.requestAudioFocus() - Log.i(TAG, "Successfully switched to MPV fallback") + if (bufferSize != null && bufferSize > 0) { + core.setProperty("demuxer-max-bytes", bufferSize.toString()) } + + for ((propName, propValue) in pendingProps) { + core.setProperty(propName, propValue) { outcome -> + if (outcome.isFailure) { + Log.w(TAG, "Failed to replay queued MPV property") + } + } + } + + for ((propName, observed) in observedProps) { + core.observeProperty(propName, observed.format) + } + + core.setVisible(true) + + Thread { + val peakDetection = core.getProperty("hdr-compute-peak") + if (peakDetection == "no") { + Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") + core.setProperty("tone-mapping", "reinhard") + core.setProperty("tone-mapping-param", "0.7") + core.setProperty("tone-mapping-mode", "luma") + } + }.start() + + core.requestAudioFocus() + } + + private fun emitFallbackErrorOnce(mediaGeneration: Int, message: String) { + if (mediaGeneration != this.mediaGeneration || terminalEventGeneration == mediaGeneration) return + terminalEventGeneration = mediaGeneration + onEvent("end-file", mapOf("reason" to "error", "message" to message)) + } + + private fun failActiveFallback(fallbackGeneration: Int, logMessage: String, error: Throwable? = null) { + if (error == null) { + Log.e(TAG, logMessage) + } else { + Log.e(TAG, logMessage, error) + } + val core = mpvCore + mpvCore = null + core?.dispose() + usingMpvFallback = false + fallbackInProgress = false + backendSwitchPending = false + mpvForwardGeneration = null + mpvCoreNeedsReplacement = false + mpvSignalGate = null + fallbackMediaGeneration = null + + val pending = pendingOpen + pendingOpen = null + val inFlight = inFlightOpen + inFlightOpen = null + val activeGeneration = pending?.mediaGeneration ?: inFlight?.mediaGeneration ?: fallbackGeneration + pending?.error("FALLBACK_FAILED", "Compatible player failed to initialize") + inFlight?.error("FALLBACK_FAILED", "Compatible player failed to initialize") + emitFallbackErrorOnce(activeGeneration, "Compatible player failed to initialize") } override fun onFormatUnsupported( + mediaGeneration: Int, uri: String, headers: Map?, positionMs: Long, playWhenReady: Boolean, errorMessage: String ): Boolean { + if (mediaGeneration != this.mediaGeneration) { + Log.d(TAG, "Ignoring stale fallback request for media generation $mediaGeneration") + return true + } if (usingMpvFallback || fallbackInProgress) { - Log.w(TAG, "Fallback already active/in-progress, ignoring duplicate request") + Log.w(TAG, "Fallback already active/in-progress, coalescing duplicate request") return true } val currentActivity = activity ?: return false - val fallbackExternalSubtitles = currentExternalSubtitles?.map { it.toMap() } + val fallbackRequest = MediaOpenRequest( + mediaGeneration = mediaGeneration, + uri = uri, + headers = headers, + startPositionMs = positionMs, + hasStartPosition = positionMs > 0L, + autoPlay = playWhenReady, + isLive = false, + externalSubtitles = currentExternalSubtitles?.map { it.toMap() }, + result = null + ) fallbackInProgress = true + fallbackMediaGeneration = mediaGeneration Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") if (debugLoggingEnabled) { @@ -990,72 +1402,81 @@ class ExoPlayerPlugin : ) } + onPropertyChange("paused-for-cache", true) + onPropertyChange("pause", true) + currentActivity.runOnUiThread { try { - // Dispose ExoPlayer playerCore?.dispose() playerCore = null mpvCore?.dispose() mpvCore = null - usingMpvFallback = false // Clear before handoff + usingMpvFallback = false val generation = sessionGeneration - mainHandler.post { - if (generation != sessionGeneration) { - fallbackInProgress = false - return@post - } - val act = activity - if (act == null) { - fallbackInProgress = false - return@post - } + if (generation != sessionGeneration) return@post + val act = activity ?: return@post try { - val core = MpvPlayerCore(act).apply { - delegate = this@ExoPlayerPlugin + val core = createMpvCore(act) + core.delegate = MpvDelegate(core) + mpvCore = core + + val initializationSettled = AtomicBoolean(false) + val timeout = Runnable { + if (!initializationSettled.compareAndSet(false, true)) return@Runnable + if (generation != sessionGeneration || mpvCore !== core) { + if (mpvCore === core) mpvCore = null + core.dispose() + return@Runnable + } + failActiveFallback(mediaGeneration, "Timed out initializing MPV fallback") } - mpvCore = core // publish so dispose/init can reach it + mainHandler.postDelayed(timeout, MPV_FALLBACK_INIT_TIMEOUT_MS) - core.initialize { success -> - if (generation != sessionGeneration) { - if (mpvCore === core) { + try { + initializeMpvCore(core) onInitialized@{ success -> + if (!initializationSettled.compareAndSet(false, true)) return@onInitialized + mainHandler.removeCallbacks(timeout) + if (generation != sessionGeneration || mpvCore !== core) { + if (mpvCore === core) mpvCore = null core.dispose() - mpvCore = null + return@onInitialized } - fallbackInProgress = false - return@initialize - } - if (!success) { - if (mpvCore === core) { - core.dispose() - mpvCore = null + if (!success) { + failActiveFallback(mediaGeneration, "Failed to initialize MPV fallback") + return@onInitialized } + + usingMpvFallback = true fallbackInProgress = false - Log.e(TAG, "Failed to initialize MPV fallback") - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage")) - return@initialize + fallbackMediaGeneration = null + backendSwitchPending = true + val request = pendingOpen?.also { pendingOpen = null } ?: fallbackRequest + if (request.mediaGeneration != this.mediaGeneration) { + completeSupersededOpen(request) + return@onInitialized + } + core.setPauseIntentForLoad(paused = !request.autoPlay) + prepareMpvFallback(core) + dispatchMpvOpen(request, core, act, generation) + } + } catch (e: Exception) { + if (initializationSettled.compareAndSet(false, true)) { + mainHandler.removeCallbacks(timeout) + failActiveFallback(mediaGeneration, "Failed to initialize MPV fallback", e) } - - usingMpvFallback = true - fallbackInProgress = false - - setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady, generation) } } catch (e: Exception) { - fallbackInProgress = false - Log.e(TAG, "Failed to switch to MPV fallback", e) - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) + failActiveFallback(mediaGeneration, "Failed to switch to MPV fallback", e) } } } catch (e: Exception) { - fallbackInProgress = false - Log.e(TAG, "Failed to switch to MPV fallback", e) - onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) + failActiveFallback(mediaGeneration, "Failed to switch to MPV fallback", e) } } - return true // Fallback is being handled + return 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 new file mode 100644 index 00000000..0d3b4684 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerFallbackTerminalTest.kt @@ -0,0 +1,281 @@ +package com.edde746.plezy.exoplayer + +import android.app.Activity +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.SinglePeriodTimeline +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ExoPlayerFallbackTerminalTest { + + @Test + fun unhandledFallbackPausesPlaybackAndEmitsOneTerminalError() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + val delegate = RecordingDelegate(handlesFallback = false) + core.delegate = delegate + + try { + assertFalse(requestFallback(core, mediaGeneration = 0)) + assertFalse(requestFallback(core, mediaGeneration = 0)) + + assertEquals(2, delegate.fallbackRequests) + assertEquals(listOf("paused-for-cache" to false, "pause" to true), delegate.properties) + assertEquals(1, delegate.events.size) + assertEquals("end-file", delegate.events.single().first) + assertEquals("error", delegate.events.single().second?.get("reason")) + } finally { + core.dispose() + } + } + + @Test + fun handledFallbackLeavesTerminalOutcomeToThePlugin() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + val delegate = RecordingDelegate(handlesFallback = true) + core.delegate = delegate + + try { + assertTrue(requestFallback(core, mediaGeneration = 0)) + + assertEquals(1, delegate.fallbackRequests) + assertTrue(delegate.properties.isEmpty()) + assertTrue(delegate.events.isEmpty()) + } finally { + core.dispose() + } + } + + @Test + fun staleFallbackCallbackCannotAffectTheActiveMedia() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + val delegate = RecordingDelegate(handlesFallback = false) + core.delegate = delegate + + try { + assertTrue(requestFallback(core, mediaGeneration = 1)) + + assertEquals(0, delegate.fallbackRequests) + assertTrue(delegate.properties.isEmpty()) + assertTrue(delegate.events.isEmpty()) + } finally { + core.dispose() + } + } + + @Test + fun newOpenRearmsFirstFrameAndTerminalStateForItsMediaGeneration() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + setField(core, "isInitialized", true) + setField(core, "firstFrameRendered", true) + setField(core, "terminalErrorGeneration", 2) + + try { + core.open( + uri = "https://example.test/next.mkv", + headers = null, + startPositionMs = 0L, + autoPlay = true, + mediaGeneration = 3 + ) + + assertEquals(false, getField(core, "firstFrameRendered")) + assertEquals(3, getField(core, "currentMediaGeneration")) + assertEquals(null, getField(core, "terminalErrorGeneration")) + assertEquals("3", mediaItemId(core)) + } finally { + core.dispose() + } + } + + @Test + fun videoPlaybackRestartComesFromTheRenderedFrameCallback() { + 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 { + assertTrue(delegate.events.isEmpty()) + analytics.onRenderedFirstFrame(eventTime, Any(), 0L) + + assertEquals(listOf("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()) + val delegate = RecordingDelegate(handlesFallback = false) + core.delegate = delegate + + try { + invokePlaybackState(core, Player.STATE_READY) + + assertEquals(listOf("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()) + setField(core, "frameWatchdogStartTime", 100L) + + try { + assertEquals(0L, frameWatchdogElapsed(core, nowMs = 10_000L, isPlaying = false)) + assertEquals(250L, frameWatchdogElapsed(core, nowMs = 10_250L, isPlaying = true)) + } finally { + core.dispose() + } + } + + @Test + fun pauseAndResumeTransitionsResetTheFrameWatchdogBaselineWithoutAPoll() { + val core = ExoPlayerCore(Robolectric.buildActivity(Activity::class.java).setup().get()) + + try { + setField(core, "frameWatchdogStartTime", 100L) + invokeIsPlayingChanged(core, false) + assertTrue((getField(core, "frameWatchdogStartTime") as Long) > 100L) + + setField(core, "frameWatchdogStartTime", 100L) + invokeIsPlayingChanged(core, true) + assertTrue((getField(core, "frameWatchdogStartTime") as Long) > 100L) + } finally { + core.dispose() + } + } + + private fun setField(target: Any, name: String, value: Any?) { + target.javaClass.getDeclaredField(name).apply { + isAccessible = true + set(target, value) + } + } + + 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 { + val method = ExoPlayerCore::class.java.getDeclaredMethod( + "requestFormatFallback", + Int::class.javaPrimitiveType, + String::class.java, + Long::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + String::class.java + ) + method.isAccessible = true + return method.invoke( + core, + mediaGeneration, + "https://example.test/video.mkv", + 0L, + true, + "unsupported video" + ) as Boolean + } + + private fun invokePlaybackState(core: ExoPlayerCore, state: Int) { + ExoPlayerCore::class.java.getDeclaredMethod( + "handlePlaybackStateChanged", + Int::class.javaPrimitiveType + ).apply { + isAccessible = true + invoke(core, state) + } + } + + private fun invokeIsPlayingChanged(core: ExoPlayerCore, isPlaying: Boolean) { + ExoPlayerCore::class.java.getDeclaredMethod( + "handleIsPlayingChanged", + Boolean::class.javaPrimitiveType + ).apply { + isAccessible = true + invoke(core, isPlaying) + } + } + + private fun frameWatchdogElapsed(core: ExoPlayerCore, nowMs: Long, isPlaying: Boolean): Long { + val method = ExoPlayerCore::class.java.getDeclaredMethod( + "frameWatchdogElapsedMs", + Long::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType + ) + method.isAccessible = true + return method.invoke(core, nowMs, isPlaying) as Long + } + + private fun mediaItemId(core: ExoPlayerCore): String { + val method = ExoPlayerCore::class.java.getDeclaredMethod("buildMediaItem", String::class.java) + method.isAccessible = true + val item = method.invoke(core, "https://example.test/next.mkv") as androidx.media3.common.MediaItem + return item.mediaId + } + + private class RecordingDelegate( + private val handlesFallback: Boolean + ) : ExoPlayerDelegate { + var fallbackRequests = 0 + val properties = mutableListOf>() + val events = mutableListOf?>>() + + override fun onFormatUnsupported( + mediaGeneration: Int, + uri: String, + headers: Map?, + positionMs: Long, + playWhenReady: Boolean, + errorMessage: String + ): Boolean { + fallbackRequests++ + return handlesFallback + } + + override fun onPropertyChange(name: String, value: Any?) { + properties += name to value + } + + override fun onEvent(name: String, data: Map?) { + events += name to data + } + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt index 26c13bc7..d5996342 100644 --- a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPluginTest.kt @@ -165,6 +165,314 @@ class ExoPlayerPluginTest { assertEquals(null, second.errorCode) } + @Test + fun openDuringFallbackDispatchesOnlyTheNewestMediaGeneration() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val exoCore = ExoPlayerCore(activity) + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + val sink = RecordingEventSink() + plugin.onListen(null, sink) + var initializeCallback: ((Boolean) -> Unit)? = null + setField(plugin, "activity", activity) + setField(plugin, "playerCore", exoCore) + plugin.createMpvCore = { mpvCore } + plugin.initializeMpvCore = { _, callback -> initializeCallback = callback } + + assertTrue( + plugin.onFormatUnsupported( + mediaGeneration = 0, + uri = "https://example.test/failed.mkv", + headers = null, + positionMs = 1234L, + playWhenReady = true, + errorMessage = "unsupported" + ) + ) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(initializeCallback != null) + + val superseded = RecordingResult() + val active = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-2.mkv", "autoPlay" to true)), + superseded + ) + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-3.mkv", "autoPlay" to true)), + active + ) + + assertEquals(1, superseded.completionCount) + assertEquals("OPEN_SUPERSEDED", superseded.errorCode) + assertEquals(0, active.completionCount) + + initializeCallback!!(true) + mpvCore.delegate?.onEvent("file-loaded", null) + awaitCompletion(active) + + assertEquals(null, active.errorCode) + assertEquals(true, getField(plugin, "usingMpvFallback")) + assertEquals(false, getField(plugin, "fallbackInProgress")) + assertNull(getField(plugin, "pendingOpen")) + assertEquals(listOf("backend-switched", "file-loaded"), sink.eventNames) + + val disposeResult = RecordingResult() + plugin.onMethodCall(MethodCall("dispose", null), disposeResult) + awaitCompletion(disposeResult) + } + + @Test + fun repeatedInitializeDuringFallbackKeepsTheQueuedOpenOwnedByThatSession() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val exoCore = ExoPlayerCore(activity) + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + var initializeCallback: ((Boolean) -> Unit)? = null + setField(plugin, "activity", activity) + setField(plugin, "playerCore", exoCore) + plugin.createMpvCore = { mpvCore } + plugin.initializeMpvCore = { _, callback -> initializeCallback = callback } + + assertTrue( + plugin.onFormatUnsupported( + mediaGeneration = 0, + uri = "https://example.test/failed.mkv", + headers = null, + positionMs = 0L, + playWhenReady = true, + errorMessage = "unsupported" + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + val open = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-2.mkv", "autoPlay" to true)), + open + ) + val repeatedInitialize = RecordingResult() + plugin.onMethodCall(MethodCall("initialize", emptyMap()), repeatedInitialize) + awaitCompletion(repeatedInitialize) + + assertEquals(true, repeatedInitialize.successValue) + assertEquals(0, open.completionCount) + assertTrue(getField(plugin, "pendingOpen") != null) + assertEquals(true, getField(plugin, "fallbackInProgress")) + + initializeCallback!!(false) + awaitCompletion(open) + assertEquals("FALLBACK_FAILED", open.errorCode) + } + + @Test + fun fallbackInitializationFailureTerminatesTheActiveQueuedOpenOnce() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val exoCore = ExoPlayerCore(activity) + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + val sink = RecordingEventSink() + plugin.onListen(null, sink) + var initializeCallback: ((Boolean) -> Unit)? = null + setField(plugin, "activity", activity) + setField(plugin, "playerCore", exoCore) + plugin.createMpvCore = { mpvCore } + plugin.initializeMpvCore = { _, callback -> initializeCallback = callback } + + assertTrue( + plugin.onFormatUnsupported( + mediaGeneration = 0, + uri = "https://example.test/failed.mkv", + headers = null, + positionMs = 0L, + playWhenReady = true, + errorMessage = "unsupported" + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + val active = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-2.mkv", "autoPlay" to true)), + active + ) + + initializeCallback!!(false) + awaitCompletion(active) + initializeCallback!!(false) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(1, active.completionCount) + assertEquals("FALLBACK_FAILED", active.errorCode) + assertEquals(1, getField(plugin, "terminalEventGeneration")) + assertEquals(false, getField(plugin, "fallbackInProgress")) + assertNull(getField(plugin, "pendingOpen")) + assertEquals(listOf("end-file"), sink.eventNames) + } + + @Test + fun fallbackInitializationTimeoutTerminatesTheActiveQueuedOpen() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val exoCore = ExoPlayerCore(activity) + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + setField(plugin, "activity", activity) + setField(plugin, "playerCore", exoCore) + plugin.createMpvCore = { mpvCore } + plugin.initializeMpvCore = { _, _ -> Unit } + + assertTrue( + plugin.onFormatUnsupported( + mediaGeneration = 0, + uri = "https://example.test/failed.mkv", + headers = null, + positionMs = 0L, + playWhenReady = true, + errorMessage = "unsupported" + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + val active = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-2.mkv", "autoPlay" to true)), + active + ) + + shadowOf(Looper.getMainLooper()).idleFor(10, TimeUnit.SECONDS) + awaitCompletion(active) + + assertEquals(1, active.completionCount) + assertEquals("FALLBACK_FAILED", active.errorCode) + assertEquals(1, getField(plugin, "terminalEventGeneration")) + assertEquals(false, getField(plugin, "fallbackInProgress")) + assertNull(getField(plugin, "pendingOpen")) + } + + @Test + fun fallbackMediaResolutionTimeoutTerminatesTheOpenAndPlayback() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + val sink = RecordingEventSink() + plugin.onListen(null, sink) + setField(plugin, "activity", activity) + setField(plugin, "mpvCore", mpvCore) + setField(plugin, "usingMpvFallback", true) + plugin.resolveMpvUri = { _, _, _ -> Unit } + + val open = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "content://example.test/blocked", "autoPlay" to true)), + open + ) + + shadowOf(Looper.getMainLooper()).idleFor(10, TimeUnit.SECONDS) + awaitCompletion(open) + + assertEquals("OPEN_TIMEOUT", open.errorCode) + assertEquals(false, getField(plugin, "usingMpvFallback")) + assertNull(getField(plugin, "inFlightOpen")) + assertEquals(listOf("end-file"), sink.eventNames) + } + + @Test + fun supersededActiveOpenRecreatesMpvAndDispatchesOnlyTheLatestQueuedRequest() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val originalCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val replacementCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = reusedFallbackPlugin(activity, originalCore) + var replacementInitialize: ((Boolean) -> Unit)? = null + var replacementCount = 0 + plugin.createMpvCore = { + replacementCount++ + replacementCore + } + plugin.initializeMpvCore = { _, callback -> replacementInitialize = callback } + plugin.resolveMpvUri = { _, _, _ -> Unit } + + val first = RecordingResult() + val supersededPending = RecordingResult() + val latest = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "content://example.test/first", "autoPlay" to true)), + first + ) + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "content://example.test/second", "autoPlay" to true)), + supersededPending + ) + assertEquals("OPEN_SUPERSEDED", first.errorCode) + + shadowOf(Looper.getMainLooper()).idleFor(10, TimeUnit.SECONDS) + assertEquals(1, replacementCount) + assertTrue(replacementInitialize != null) + assertEquals(0, supersededPending.completionCount) + + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "content://example.test/latest", "autoPlay" to true)), + latest + ) + assertEquals("OPEN_SUPERSEDED", supersededPending.errorCode) + + replacementInitialize!!(true) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(0, latest.completionCount) + assertNull(getField(plugin, "pendingOpen")) + assertTrue(getField(plugin, "inFlightOpen") != null) + assertEquals(replacementCore, getField(plugin, "mpvCore")) + + val dispose = RecordingResult() + plugin.onMethodCall(MethodCall("dispose", null), dispose) + awaitCompletion(dispose) + assertEquals("NOT_INITIALIZED", latest.errorCode) + } + + @Test + fun fallbackLoadFailureReturnsAnErrorAndTerminatesTheActiveMedia() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val exoCore = ExoPlayerCore(activity) + val mpvCore = MpvPlayerCore(activity, true) { _, _ -> Unit } + val plugin = ExoPlayerPlugin() + val sink = RecordingEventSink() + plugin.onListen(null, sink) + var initializeCallback: ((Boolean) -> Unit)? = null + setField(plugin, "activity", activity) + setField(plugin, "playerCore", exoCore) + plugin.createMpvCore = { mpvCore } + plugin.initializeMpvCore = { _, callback -> initializeCallback = callback } + + assertTrue( + plugin.onFormatUnsupported( + mediaGeneration = 0, + uri = "https://example.test/failed.mkv", + headers = null, + positionMs = 0L, + playWhenReady = true, + errorMessage = "unsupported" + ) + ) + shadowOf(Looper.getMainLooper()).idle() + + val active = RecordingResult() + plugin.onMethodCall( + MethodCall("open", mapOf("uri" to "https://example.test/episode-2.mkv", "autoPlay" to true)), + active + ) + + mpvCore.dispose() + initializeCallback!!(true) + awaitCompletion(active) + + assertEquals(1, active.completionCount) + assertEquals("OPEN_FAILED", active.errorCode) + assertEquals(1, getField(plugin, "terminalEventGeneration")) + assertEquals(false, getField(plugin, "usingMpvFallback")) + assertNull(getField(plugin, "inFlightOpen")) + assertEquals(listOf("end-file"), sink.eventNames) + } + @Test fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() @@ -178,7 +486,7 @@ class ExoPlayerPluginTest { core.setPrivateField("deferredResumeRequested", true) val plugin = initialFallbackPlugin(activity, core) - invokeSetupMpvFallback(plugin, core, activity, playWhenReady = false) + invokeSetupMpvFallback(plugin, core, playWhenReady = false) invokeAutoResume(core, "audio focus gain") invokeAutoResume(core, "surface attached") @@ -203,7 +511,7 @@ class ExoPlayerPluginTest { core.setPrivateField("resumeBlockedByPublicPause", true) val plugin = initialFallbackPlugin(activity, core) - invokeSetupMpvFallback(plugin, core, activity, playWhenReady = true) + invokeSetupMpvFallback(plugin, core, playWhenReady = true) assertEquals(false, core.getPrivateField("desiredPaused")) assertEquals(false, core.getPrivateField("cachedPaused")) @@ -234,6 +542,7 @@ class ExoPlayerPluginTest { result ) invokeAutoResume(core, "surface attached") + awaitCompletion(result) assertEquals(1, result.completionCount) assertNull(result.errorCode) @@ -247,7 +556,7 @@ class ExoPlayerPluginTest { } @Test - fun reusedAutoplayFallbackClearsIntentBeforeLoadAndClearsPersistedNativePauseAfterSuccess() { + fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() { val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val writes = ConcurrentLinkedQueue>() val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value } @@ -264,15 +573,15 @@ class ExoPlayerPluginTest { ), result ) + awaitCompletion(result) assertEquals(1, result.completionCount) assertNull(result.errorCode) assertEquals(false, core.getPrivateField("desiredPaused")) assertEquals(false, core.getPrivateField("cachedPaused")) assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause")) - assertTrue(awaitQueueEntry(writes, "pause" to "no")) - assertFalse(awaitPauseWriteCount(writes, 2)) - assertEquals(listOf("pause" to "no"), writes.filter { it.first == "pause" }) + assertFalse(awaitPauseWriteCount(writes, 1)) + assertEquals(emptyList>(), writes.filter { it.first == "pause" }) core.dispose() } @@ -444,32 +753,15 @@ class ExoPlayerPluginTest { private fun invokeSetupMpvFallback( plugin: ExoPlayerPlugin, core: MpvPlayerCore, - activity: Activity, playWhenReady: Boolean ) { + core.setPauseIntentForLoad(paused = !playWhenReady) ExoPlayerPlugin::class.java.getDeclaredMethod( - "setupMpvFallback", - MpvPlayerCore::class.java, - Activity::class.java, - String::class.java, - Map::class.java, - java.lang.Long.TYPE, - List::class.java, - java.lang.Boolean.TYPE, - java.lang.Integer.TYPE + "prepareMpvFallback", + MpvPlayerCore::class.java ).apply { isAccessible = true - invoke( - plugin, - core, - activity, - "https://example.test/video.mkv", - null, - 0L, - null, - playWhenReady, - 0 - ) + invoke(plugin, core) } } @@ -577,9 +869,16 @@ class ExoPlayerPluginTest { private class RecordingEventSink : EventChannel.EventSink { var successValue: Any? = null + val successValues = mutableListOf() + val eventNames: List + get() = successValues.mapNotNull { event -> + val envelope = event as? Map<*, *> ?: return@mapNotNull null + if (envelope["type"] == "event") envelope["name"] as? String else null + } override fun success(event: Any?) { successValue = event + successValues += event } override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 518e090f..aaa1dbe2 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -157,37 +157,52 @@ class PlayerAndroid extends PlayerBase { await _ensureInitialized(); final startPosition = media.start ?? Duration.zero; final hasStartPosition = media.start != null && startPosition > Duration.zero; + final previousState = state; + final previousPosition = currentPosition; + final previousTimelineDuration = configuredTimelineDuration; + final previousExternalSubtitleMetadata = snapshotExternalSubtitleMetadata(); configureTimeline(duration: timelineDuration); clearTracks(); setExternalSubtitleMetadata(externalSubtitles); + resetPlaybackProgress(startPosition); setSeekable(false); - // Show the video layer - await setVisible(true); + try { + // Show the video layer + await setVisible(true); - await invoke('open', { - 'uri': media.uri, - 'headers': media.headers, - 'startPositionMs': startPosition.inMilliseconds, - 'hasStartPosition': hasStartPosition, - 'autoPlay': play, - 'isLive': isLive, - if (externalSubtitles != null && externalSubtitles.isNotEmpty) - 'externalSubtitles': externalSubtitles - .where((s) => s.uri != null) - .map( - (s) => { - 'uri': s.uri, - 'title': s.title, - 'language': s.language, - 'codec': s.codec, - 'isDefault': s.isDefault, - 'isForced': s.isForced, - }, - ) - .toList(), - }); - resetPlaybackProgress(media.start ?? Duration.zero); + await invoke('open', { + 'uri': media.uri, + 'headers': media.headers, + 'startPositionMs': startPosition.inMilliseconds, + 'hasStartPosition': hasStartPosition, + 'autoPlay': play, + 'isLive': isLive, + if (externalSubtitles != null && externalSubtitles.isNotEmpty) + 'externalSubtitles': externalSubtitles + .where((s) => s.uri != null) + .map( + (s) => { + 'uri': s.uri, + 'title': s.title, + 'language': s.language, + 'codec': s.codec, + 'isDefault': s.isDefault, + 'isForced': s.isForced, + }, + ) + .toList(), + }); + } catch (_) { + if (!disposed) { + configureTimeline(duration: previousTimelineDuration); + restorePlaybackProgress(previousState, position: previousPosition); + restoreTracks(previousState); + restoreExternalSubtitleMetadata(previousExternalSubtitleMetadata); + setSeekable(previousState.seekable); + } + rethrow; + } } @override diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 501e21a6..87ff9068 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -609,6 +609,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _externalSubtitleMetadataByUri = metadataByUri; } + @protected + Map snapshotExternalSubtitleMetadata() => + Map.of(_externalSubtitleMetadataByUri); + + @protected + void restoreExternalSubtitleMetadata(Map snapshot) { + _externalSubtitleMetadataByUri = Map.of(snapshot); + } + @protected void setVolumeState(double volume) { if (_state.volume == volume) return; @@ -628,6 +637,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _timelineDuration = duration; } + @protected + Duration? get configuredTimelineDuration => _timelineDuration; + @protected void resetPlaybackProgress(Duration sourcePosition) { final position = sourcePosition; @@ -646,6 +658,31 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { bufferRangesController.add(const []); } + @protected + void restoreTracks(PlayerState snapshot) { + _state = _state.copyWith(tracks: snapshot.tracks, track: snapshot.track); + tracksController.add(snapshot.tracks); + trackController.add(snapshot.track); + } + + @protected + void restorePlaybackProgress(PlayerState snapshot, {Duration? position}) { + final restoredPosition = position ?? snapshot.position; + _positionMs = restoredPosition.inMilliseconds; + _state = _state.copyWith( + completed: snapshot.completed, + position: restoredPosition, + duration: snapshot.duration, + buffer: snapshot.buffer, + bufferRanges: snapshot.bufferRanges, + ); + completedController.add(snapshot.completed); + positionController.add(restoredPosition); + durationController.add(snapshot.duration); + bufferController.add(snapshot.buffer); + bufferRangesController.add(snapshot.bufferRanges); + } + @protected Future invoke(String method, [dynamic args]) async { if (_disposed) return null; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index f62a41a6..2822d68d 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -412,7 +412,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { try { final currentPlayer = existingPlayer; final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true); - bool isCurrentReload() => attempt.isCurrent; + bool isCurrentReload() => attempt.isCurrent && !_hasFatalPlaybackError && !_isExiting.value; // The session itself swaps atomically at the open boundary, so the only // rollback state is the eagerly-set identity (shown by the loading UI) @@ -421,6 +421,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this); final previousPartId = _currentMediaInfo?.partId; final previousHasFirstFrame = _hasFirstFrame.value; + final previousHasRenderedFirstFrame = _hasRenderedFirstFrame; + final previousHasFatalPlaybackError = _hasFatalPlaybackError; + _hasFatalPlaybackError = false; final isItemChange = previousMetadata.globalKey != metadata.globalKey; final currentAudioTrack = preserveCurrentTrackSelection @@ -645,6 +648,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: isCurrentReload, + onOpening: () { + _hasRenderedFirstFrame = false; + }, onOpened: () { // The player now owns the new file — publish the session at the // same boundary so identity and source state flip together. @@ -779,6 +785,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity); } _hasFirstFrame.value = previousHasFirstFrame; + _hasRenderedFirstFrame = previousHasRenderedFirstFrame; + _hasFatalPlaybackError = previousHasFatalPlaybackError; // If the stop report already went out, un-latch the tracker so the // resumed session keeps reporting (and its eventual real stop sends). _progressTracker?.resumeAfterStoppedReport(); diff --git a/lib/screens/video_player/parts/errors.dart b/lib/screens/video_player/parts/errors.dart index e122eb55..b150d047 100644 --- a/lib/screens/video_player/parts/errors.dart +++ b/lib/screens/video_player/parts/errors.dart @@ -16,7 +16,9 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. if (err.cause == PlayerError.serverHttp500 || _sawServer500) { - _showServerLimitDialog(); + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); + unawaited(_showServerLimitDialog()); return; } @@ -39,8 +41,10 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState { } } + _hasFatalPlaybackError = true; + _progressTracker?.stopTracking(); showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); - _handleBackButton(); + unawaited(_handleBackButton()); } void _onPlayerLog(PlayerLog log) { diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index ac2a1b9a..98bb67f3 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -269,6 +269,8 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _liveSeek.cancel(); final previousSession = _live.session; + final previousHasFirstFrame = _hasFirstFrame.value; + final previousHasRenderedFirstFrame = _hasRenderedFirstFrame; final channel = channels[newIndex]; appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); @@ -298,7 +300,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { return; } - _setPlayerState(() => _hasFirstFrame.value = false); + _setPlayerState(() { + _hasFirstFrame.value = false; + _hasRenderedFirstFrame = false; + }); replacementOpenStarted = true; await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); if (!isCurrentChannelSwitch()) { @@ -336,7 +341,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan); if (!isCurrentChannelSwitch()) return; if (replacementOpenStarted && mounted && _live.session == previousSession) { - _setPlayerState(() => _hasFirstFrame.value = true); + _setPlayerState(() { + _hasFirstFrame.value = previousHasFirstFrame; + _hasRenderedFirstFrame = previousHasRenderedFirstFrame; + }); } appLogger.e('Failed to switch channel', error: e); if (mounted) showErrorSnackBar(context, e.toString()); diff --git a/lib/screens/video_player/parts/playback_open.dart b/lib/screens/video_player/parts/playback_open.dart index 72a76130..dd55ff5a 100644 --- a/lib/screens/video_player/parts/playback_open.dart +++ b/lib/screens/video_player/parts/playback_open.dart @@ -593,7 +593,9 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { required bool play, List? externalSubtitlesAtOpen, bool Function()? shouldContinue, + void Function()? onOpening, void Function()? onOpened, + void Function(bool available)? onMediaAvailabilityChanged, }) async { await _applyNetworkStreamTuning( player: player, @@ -606,6 +608,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { final media = Media(videoUrl, start: timing.mediaStart, headers: headers); final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen); Future openMedia({required bool shouldPlay, List? externalSubtitles}) { + onOpening?.call(); return player.open( media, play: shouldPlay, @@ -617,6 +620,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { try { await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen); onOpened?.call(); + onMediaAvailabilityChanged?.call(true); } catch (_) { await sidecarOpenGuard?.dispose(); rethrow; @@ -633,10 +637,12 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState { return const _MediaOpenResult(didOpen: true); } await player.stop(); + onMediaAvailabilityChanged?.call(false); if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true); // Respect a pause requested while mpv was waiting on the sidecar. A // startup gate encoded by [play] remains authoritative when it is false. await openMedia(shouldPlay: play && _playbackIntentShouldPlay); + onMediaAvailabilityChanged?.call(true); sidecarFallbackUsed = true; if (mounted && (shouldContinue == null || shouldContinue())) { showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback); diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index a9242088..4b327042 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -37,10 +37,15 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { } Future _markFirstFrameReady(Player currentPlayer, SettingsService settingsService) async { - if (!mounted || player != currentPlayer || _hasFirstFrame.value) return; + if (!mounted || player != currentPlayer || _hasRenderedFirstFrame || _hasFatalPlaybackError) return; + _hasRenderedFirstFrame = true; _hasFirstFrame.value = true; unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'))); + final progressTracker = _progressTracker; + if (progressTracker != null && currentPlayer.state.isActive) { + unawaited(progressTracker.sendProgress('playing')); + } if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) { await _applyFrameRateMatching(); @@ -68,6 +73,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (_positionSubscription != null) _positionSubscription!.cancel(), ]); if (!mounted || player != currentPlayer) return; + int? lastObservedPositionMs; _playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged); @@ -78,6 +84,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // completed=false while parked at EOF must NOT re-arm, or the position // listener would immediately re-fire the Play Next prompt. if (!done) { + lastObservedPositionMs = null; final durMs = currentPlayer.state.duration.inMilliseconds; final posMs = currentPlayer.state.position.inMilliseconds; if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) { @@ -145,18 +152,16 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { await markFirstFrameReady; }); - int? lastObservedPositionMs; _positionSubscription = currentPlayer.streams.position.listen((position) { final activePlayer = player; if (activePlayer == null || activePlayer != currentPlayer) return; - // Fallback for cases where playbackRestart doesn't fire (observed on - // some offline Android playback flows). Prevents a permanent loading - // spinner. Checking `position > 0` was broken for resume playback — - // the native layer sets position to the resume offset before the first - // frame renders, so the fallback tripped immediately. Requiring a - // position *change* ensures we only fire when playback is advancing. - if (!_hasFirstFrame.value) { + // Fallback for MPV backends whose playbackRestart event is unavailable. + // Android ExoPlayer position can advance on its standalone clock without + // a renderer, so it may infer readiness only after switching to MPV. + final canInferRenderedFrameFromPosition = + !(Platform.isAndroid && useExoPlayer) || (currentPlayer is PlayerAndroid && currentPlayer.usingMpvFallback); + if (canInferRenderedFrameFromPosition && !_hasRenderedFirstFrame) { if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) { unawaited(_markFirstFrameReady(currentPlayer, settingsService)); } @@ -305,6 +310,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { if (mounted) { _isBuffering.value = false; _hasFirstFrame.value = false; + _hasRenderedFirstFrame = false; } } @@ -326,6 +332,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { MediaSourceInfo? mediaInfo, }) { final currentPlayer = player; + if (_hasFatalPlaybackError) return; + if (currentPlayer == null) return; _rebindProgressTracker( @@ -371,6 +379,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { MediaSourceInfo? mediaInfo, }) { final currentPlayer = player; + if (_hasFatalPlaybackError) return; + if (currentPlayer == null) return; // Local media still reports live when its server is online; only queue @@ -390,6 +400,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { playMethod: effectivePlayMethod, playSessionId: playSessionId, mediaInfo: mediaInfo, + canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError, + hasRenderedPlayback: () => _hasRenderedFirstFrame, onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode' ? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId) : null, @@ -414,6 +426,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { player: currentPlayer, isOffline: true, offlineWatchService: offlineWatchService, + canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError, + hasRenderedPlayback: () => _hasRenderedFirstFrame, ); _progressTracker!.startTracking(); } @@ -422,7 +436,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { /// Initialize the service layer Future _initializeServices() async { final currentPlayer = player; - if (!mounted || currentPlayer == null) return; + if (!mounted || currentPlayer == null || _hasFatalPlaybackError) return; // Live TV: send timeline heartbeats to keep transcode session alive if (widget.isLive) { diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 37e785be..4ece6685 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -5,6 +5,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { final currentPlayer = player; if (!mounted || currentPlayer == null) return; final attempt = _beginPlaybackAttempt(currentPlayer); + _hasRenderedFirstFrame = false; + _hasFatalPlaybackError = false; // Live TV mode: bypass standard playback initialization if (widget.isLive) { @@ -97,6 +99,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { // Capture providers before async gaps final offlineWatchService = context.read(); + var primaryMediaOpened = false; try { PlaybackContext playbackContext; @@ -255,6 +258,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { play: shouldAutoPlay, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: () => attempt.isCurrent, + onMediaAvailabilityChanged: (available) => primaryMediaOpened = available, ); if (!openResult.didOpen || !attempt.isCurrent) return; if (openResult.sidecarFallbackUsed) { @@ -376,14 +380,20 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } } on PlaybackException catch (e, st) { appLogger.w('Playback initialization failed', error: e, stackTrace: st); - if (mounted) { - _hasFirstFrame.value = true; // Hide spinner on error + if (attempt.isCurrent && mounted) { + if (!primaryMediaOpened) { + _hasFatalPlaybackError = true; + } + _hasFirstFrame.value = true; // Hide spinner on every current startup failure showErrorSnackBar(context, e.message); } } catch (e, st) { appLogger.e('Failed to start playback', error: e, stackTrace: st); - if (mounted) { - _hasFirstFrame.value = true; // Hide spinner on error + if (attempt.isCurrent && mounted) { + if (!primaryMediaOpened) { + _hasFatalPlaybackError = true; + } + _hasFirstFrame.value = true; // Hide spinner on every current startup failure showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9fcea3ee..8ad82a5d 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -722,6 +722,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin final ValueNotifier _isBuffering = ValueNotifier(false); final ValueNotifier _hasFirstFrame = ValueNotifier(false); + // UI readiness may be forced true to hide the loading spinner after a + // startup failure. Reporting readiness is stricter: only a renderer event + // (or the established non-ExoPlayer position fallback) sets this latch. + bool _hasRenderedFirstFrame = false; + bool _hasFatalPlaybackError = false; + final ValueNotifier _isExiting = ValueNotifier(false); final PlayerChromeController _chromeController = PlayerChromeController(); late final PlayerNavigationCoordinator _playerNavigationCoordinator; diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index c6cacf29..d37f050c 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -71,6 +71,17 @@ class PlaybackProgressTracker { /// while paused. Best-effort; failures are the callee's to swallow. final Future Function()? onPausedKeepalive; + /// Whether non-terminal playback reports reflect real playback output. + /// Video passes first-frame readiness so a native clock cannot create + /// progress before any renderer produces a frame. Other callers default to + /// ready. + final bool Function()? canReportPlayback; + + /// Whether this item has produced real playback output at least once. + /// A stopped report still terminates the backend session when false, but + /// must not turn an unrendered native clock position into watched progress. + final bool Function()? hasRenderedPlayback; + /// Timer for periodic progress updates Timer? _progressTimer; @@ -94,6 +105,7 @@ class PlaybackProgressTracker { Future? _stoppedProgressFuture; Duration? _lastProgressNotifiedPosition; + Duration? _lastReportablePosition; static const Duration _progressNotifyDelta = Duration(seconds: 30); @@ -111,6 +123,8 @@ class PlaybackProgressTracker { this.mediaInfo, this.onScrobbled, this.onPausedKeepalive, + this.canReportPlayback, + this.hasRenderedPlayback, this.updateInterval = const Duration(seconds: 10), }) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'), assert(isOffline || client != null, 'client is required when isOffline is false'), @@ -195,8 +209,17 @@ class PlaybackProgressTracker { Duration? attemptedPosition; Duration? attemptedDuration; try { + final canReport = canReportPlayback?.call() ?? true; + final hasRenderedOutput = hasRenderedPlayback?.call() ?? canReport; + if (state != 'stopped' && !canReport) return; + final isSuppressedStop = state == 'stopped' && !canReport; final duration = player.state.duration; - final position = _clampPosition(positionOverride ?? player.state.position, duration); + final positionSource = isSuppressedStop + ? _lastReportablePosition ?? Duration(milliseconds: metadata.viewOffsetMs ?? 0) + : positionOverride ?? player.state.position; + final position = _clampPosition(positionSource, duration); + if (canReport && hasRenderedOutput) _lastReportablePosition = position; + final canCommitStoppedProgress = hasRenderedOutput && (!isSuppressedStop || _lastReportablePosition != null); attemptedPosition = position; attemptedDuration = duration; @@ -206,14 +229,19 @@ class PlaybackProgressTracker { } if (isOffline) { - // Queue progress update for later sync + // There is no backend session to terminate offline. Do not turn a + // resume offset into a fresh queued update when this run rendered + // nothing. + if (!canCommitStoppedProgress) return; await _sendOfflineProgress(position, duration); _notifyProgressIfNeeded(position, duration, force: state == 'stopped'); } else if (state == 'stopped') { - // Stopped must complete before disposal - final accepted = await _sendOnlineProgress(state, position, duration); + // Stopped must complete before disposal. When reporting was disabled + // by a fatal error, use the last position captured while output was + // healthy rather than the still-advancing native media clock. + final accepted = await _sendOnlineProgress(state, position, duration, allowScrobble: canCommitStoppedProgress); _resetBackoff(); - if (accepted) { + if (accepted && canCommitStoppedProgress) { _notifyProgressIfNeeded(position, duration, force: true); } } else { @@ -305,7 +333,12 @@ class PlaybackProgressTracker { /// Send progress update to the active server through the unified /// [MediaServerClient.reportPlayback*] surface. - Future _sendOnlineProgress(String state, Duration position, Duration duration) async { + Future _sendOnlineProgress( + String state, + Duration position, + Duration duration, { + bool allowScrobble = true, + }) async { final c = client; final session = _reportSession; if (c == null || session == null) return false; @@ -321,7 +354,7 @@ class PlaybackProgressTracker { ), ); - if (accepted) { + if (accepted && allowScrobble) { await _maybeScrobble(c, position, duration); } return accepted; diff --git a/test/mpv/player_open_test.dart b/test/mpv/player_open_test.dart index b3829d73..7b5822eb 100644 --- a/test/mpv/player_open_test.dart +++ b/test/mpv/player_open_test.dart @@ -46,6 +46,119 @@ void main() { ); }); + test('ExoPlayer clears stale timeline state before dispatching a new open', () async { + late PlayerAndroid player; + Duration? positionAtNativeOpen; + Duration? durationAtNativeOpen; + + await withMockPlayerChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) { + switch (call.method) { + case 'initialize': + return Future.value(true); + case 'open': + positionAtNativeOpen = player.state.position; + durationAtNativeOpen = player.state.duration; + return Future.value(null); + default: + return Future.value(null); + } + }, + testBody: () async { + player = PlayerAndroid(); + try { + player.handlePropertyChange('time-pos', 188.0); + player.handlePropertyChange('duration', 439.968); + + await player.open(Media('https://example.test/next.mkv')); + + expect(positionAtNativeOpen, Duration.zero); + expect(durationAtNativeOpen, Duration.zero); + + player.handlePropertyChange('duration', 401.0); + expect(player.state.duration, const Duration(seconds: 401)); + } finally { + await player.dispose(); + } + }, + ); + }); + + test('ExoPlayer restores the previous timeline when native open is rejected', () async { + await withMockPlayerChannels( + methodChannelName: 'com.plezy/exo_player', + eventChannelName: 'com.plezy/exo_player/events', + methodHandler: (call) { + if (call.method == 'initialize') return Future.value(true); + if (call.method == 'open') { + throw PlatformException(code: 'OPEN_FAILED', message: 'rejected'); + } + return Future.value(null); + }, + testBody: () async { + final player = _TestPlayerAndroid(); + try { + _seedTracks(player); + player.seedExternalSubtitleMetadata(const [ + SubtitleTrack( + id: 'old-external', + title: 'Old sidecar', + language: 'eng', + codec: 'srt', + isDefault: false, + isForced: true, + isExternal: true, + uri: 'https://example.test/old.srt', + ), + ]); + player.handlePropertyChange('time-pos', 188.0); + player.handlePropertyChange('duration', 439.968); + + await expectLater( + player.open( + Media('https://example.test/rejected.mkv', start: const Duration(seconds: 12)), + timelineDuration: const Duration(seconds: 401), + externalSubtitles: const [ + SubtitleTrack( + id: 'rejected-external', + title: 'Rejected sidecar', + language: 'spa', + codec: 'ass', + isDefault: false, + isForced: false, + isExternal: true, + uri: 'https://example.test/rejected.ass', + ), + ], + ), + throwsA(isA()), + ); + + expect(player.state.position, const Duration(seconds: 188)); + expect(player.state.duration, const Duration(milliseconds: 439968)); + expect(player.state.tracks.audio.single.title, 'English'); + expect(player.state.tracks.subtitle.single.title, 'English'); + + player.handlePropertyChange('track-list', const [ + { + 'type': 'sub', + 'id': 'restored-external', + 'external': true, + 'external-filename': 'https://example.test/old.srt', + 'selected': true, + }, + ]); + expect(player.state.tracks.subtitle.single.title, 'Old sidecar'); + expect(player.state.tracks.subtitle.single.isForced, isTrue); + } finally { + await player.dispose(); + } + }, + ); + }); + test('ExoPlayer applies audio settings queued before initialization', () async { final calls = []; await withMockPlayerChannels( @@ -826,6 +939,12 @@ void main() { }); } +class _TestPlayerAndroid extends PlayerAndroid { + void seedExternalSubtitleMetadata(List subtitles) { + setExternalSubtitleMetadata(subtitles); + } +} + void _seedTracks(dynamic player) { player.handlePropertyChange('track-list', const [ {'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true}, diff --git a/test/mpv/property_observation_test.dart b/test/mpv/property_observation_test.dart index e663e192..128187ac 100644 --- a/test/mpv/property_observation_test.dart +++ b/test/mpv/property_observation_test.dart @@ -79,6 +79,30 @@ void main() { } }); + test('PlayerAndroid forwards only explicit playback restart events', () 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 = PlayerAndroid(); + var restartCount = 0; + final subscription = player.streams.playbackRestart.listen((_) => restartCount++); + addTearDown(() async { + await subscription.cancel(); + await player.dispose(); + }); + + player.handlePropertyChange('paused-for-cache', false); + player.handlePropertyChange('time-pos', 12.0); + await Future.delayed(Duration.zero); + expect(restartCount, 0); + + player.handlePlayerEvent('playback-restart', null); + await Future.delayed(Duration.zero); + expect(restartCount, 1); + }); + test('mpv registers the core properties (plus its track/device extras)', () async { final player = PlayerNative(); final observations = await capturedObservations( diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index afb1039b..684dfd4e 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -355,14 +355,19 @@ class _StopMarksWatchedClient extends _FakePlexClient { const Object _defaultServerId = Object(); -MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => - testMediaItem( - id: ratingKey, - backend: MediaBackend.plex, - kind: MediaKind.fromString(type), - title: 'Test Item', - serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, - ); +MediaItem _meta({ + String ratingKey = '42', + Object? serverId = _defaultServerId, + String? type = 'movie', + int? viewOffsetMs, +}) => testMediaItem( + id: ratingKey, + backend: MediaBackend.plex, + kind: MediaKind.fromString(type), + title: 'Test Item', + serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, + viewOffsetMs: viewOffsetMs, +); void main() { setUp(resetSharedPreferencesForTest); @@ -404,6 +409,82 @@ void main() { }); }); + group('sendProgress: playback readiness', () { + test('blocks non-terminal reports until playback output is ready', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 5), duration: const Duration(seconds: 100)); + var ready = false; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + canReportPlayback: () => ready, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls, isEmpty); + await tracker.sendProgress('paused'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls, isEmpty); + + ready = true; + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + expect(client.updateProgressCalls.map((call) => call.state), ['playing']); + }); + + test('stopped before rendered output terminates at known progress without scrobbling', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 99), duration: const Duration(seconds: 100)); + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(viewOffsetMs: 12000), + player: player, + isOffline: false, + canReportPlayback: () => false, + hasRenderedPlayback: () => false, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('stopped', positionOverride: player.state.duration); + + expect(client.updateProgressCalls, hasLength(1)); + expect(client.updateProgressCalls.single.state, 'stopped'); + expect(client.updateProgressCalls.single.time, 12000); + expect(client.markWatchedCalls, isEmpty); + }); + + test('fatal stop uses the last reportable position instead of the advancing native clock', () async { + final client = _FakePlexClient(); + final player = _FakePlayer(position: const Duration(seconds: 50), duration: const Duration(seconds: 100)); + var canReport = true; + final tracker = PlaybackProgressTracker( + client: client, + metadata: _meta(), + player: player, + isOffline: false, + canReportPlayback: () => canReport, + hasRenderedPlayback: () => true, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + await Future.delayed(Duration.zero); + canReport = false; + player.position = const Duration(seconds: 95); + await tracker.sendProgress('stopped'); + + expect(client.updateProgressCalls.map((call) => (call.time, call.state)), [ + (50000, 'playing'), + (50000, 'stopped'), + ]); + expect(client.markWatchedCalls, isEmpty); + }); + }); + // ============================================================ // sendProgress: online routing // ============================================================ @@ -962,6 +1043,34 @@ void main() { expect(action.duration, 60000); }); + test('does not queue offline playing progress before playback output is ready', () async { + final (svc: svc, db: db, mgr: mgr) = await makeOfflineService(); + addTearDown(() async { + svc.dispose(); + mgr.dispose(); + await db.close(); + }); + + final player = _FakePlayer(position: const Duration(seconds: 12), duration: const Duration(seconds: 60)); + var ready = false; + final tracker = PlaybackProgressTracker( + client: null, + metadata: _meta(ratingKey: '42', serverId: ServerId('srv')), + player: player, + isOffline: true, + offlineWatchService: svc, + canReportPlayback: () => ready, + ); + addTearDown(tracker.dispose); + + await tracker.sendProgress('playing'); + expect(await db.getLatestWatchAction('srv:42'), isNull); + + ready = true; + await tracker.sendProgress('playing'); + expect(await db.getLatestWatchAction('srv:42'), isNotNull); + }); + test('offline + null serverId is a no-op (does NOT throw, does NOT queue)', () async { final (svc: svc, db: db, mgr: mgr) = await makeOfflineService(); addTearDown(() async {