fix(android): prevent ghost playback after autoplay failures

close #1673
This commit is contained in:
edde746
2026-07-25 04:12:54 +02:00
parent b7d5922b0a
commit 0643787fbe
17 changed files with 1799 additions and 292 deletions
@@ -30,6 +30,7 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.TrackGroup import androidx.media3.common.TrackGroup
import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks 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 * @return true if fallback was handled, false to emit error event to Flutter
*/ */
fun onFormatUnsupported( fun onFormatUnsupported(
mediaGeneration: Int,
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
positionMs: Long, positionMs: Long,
@@ -309,6 +311,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private var currentMediaUri: String? = null private var currentMediaUri: String? = null
private var currentHeaders: Map<String, String>? = null private var currentHeaders: Map<String, String>? = null
private var currentMediaIsLive: Boolean = false private var currentMediaIsLive: Boolean = false
private var currentMediaGeneration: Int = 0
private var terminalErrorGeneration: Int? = null
private var currentVisible: Boolean = false private var currentVisible: Boolean = false
private var selectedAudioTrackId: String? = null private var selectedAudioTrackId: String? = null
private var selectedSubtitleTrackId: 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<String, Any>(
"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 { private fun redactUri(uri: String): String {
return try { return try {
@@ -935,14 +972,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
bitmapSubtitleView?.setCues(bitmapCues) bitmapSubtitleView?.setCues(bitmapCues)
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { private fun handleIsPlayingChanged(isPlaying: Boolean) {
Log.d(TAG, "onIsPlayingChanged: $isPlaying") 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) pendingPlayWhenReady = null
if (isPlaying) armResumeStallWatchdog() else cancelResumeStallWatchdog() if (isPlaying) armResumeStallWatchdog() else cancelResumeStallWatchdog()
delegate?.onPropertyChange("pause", !isPlaying) delegate?.onPropertyChange("pause", !isPlaying)
} }
override fun onPlaybackStateChanged(state: Int) { private fun handlePlaybackStateChanged(state: Int) {
val stateStr = when (state) { val stateStr = when (state) {
Player.STATE_IDLE -> "idle" Player.STATE_IDLE -> "idle"
Player.STATE_BUFFERING -> "buffering" Player.STATE_BUFFERING -> "buffering"
@@ -975,11 +1016,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
exoPlayer?.playWhenReady = pendingPlay exoPlayer?.playWhenReady = pendingPlay
} }
delegate?.onPropertyChange("paused-for-cache", false) delegate?.onPropertyChange("paused-for-cache", false)
delegate?.onEvent("playback-restart", null)
emitTrackList() emitTrackList()
// Start frame watchdog to detect black screen (HDR tunneling issue) // 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() startFrameWatchdog()
} else if (claimPlaybackOutputReady()) {
emitLog("debug", "audio", "Audio-only playback ready")
stopFrameWatchdog()
delegate?.onEvent("playback-restart", null)
}
} }
Player.STATE_ENDED -> { Player.STATE_ENDED -> {
stopFrameWatchdog() stopFrameWatchdog()
@@ -991,20 +1039,11 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
override fun onTracksChanged(tracks: Tracks) { override fun onTracksChanged(tracks: Tracks) {
Log.d(TAG, "onTracksChanged") Log.d(TAG, "onTracksChanged")
// Detect video track present but deselected (unsupported codec — plays audio only) // Player.Listener does not identify the originating MediaItem. A queued
val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO } // callback from the replaced item must not publish tracks for the new one;
val hasSelectedVideo = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO && it.isSelected } // generation-bound unsupported-format handling lives in AnalyticsListener.
if (hasAnyVideoGroup && !hasSelectedVideo && currentMediaUri != null) { if (tracks != exoPlayer?.currentTracks) {
// Try DV conversion before falling to MPV Log.d(TAG, "Ignoring stale track callback")
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"
)
return return
} }
@@ -1064,7 +1103,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
emitTrackList() 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 // Log full exception chain unminified — R8 mangles simpleName but not toString/message
val causeChain = buildString { val causeChain = buildString {
var t: Throwable? = error.cause var t: Throwable? = error.cause
@@ -1091,39 +1130,30 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
(error.message?.contains("Response code: 500") == true) (error.message?.contains("Response code: 500") == true)
if (isHttp500) { if (isHttp500) {
Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)") Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)")
delegate?.onEvent( emitPlaybackErrorOnce(
"end-file", mediaGeneration,
mapOf( error.message ?: "HTTP 500",
"reason" to "error", cause = "server-http-500"
"message" to (error.message ?: "HTTP 500"),
"cause" to "server-http-500"
)
) )
return return
} }
if (retryAfterAudioTrackError(error, causeChain)) 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") Log.w(TAG, "ExoPlayer error (code ${error.errorCode}) - attempting fallback to MPV")
val handled = delegate?.onFormatUnsupported( requestFormatFallback(
uri = currentMediaUri!!, mediaGeneration = mediaGeneration,
headers = currentHeaders, uri = uri,
positionMs = effectivePosition, positionMs = effectivePosition,
playWhenReady = exoPlayer?.playWhenReady ?: true, playWhenReady = exoPlayer?.playWhenReady ?: true,
errorMessage = error.message ?: "Unknown error" errorMessage = error.message ?: "Unknown error"
) ?: false )
return
if (handled) return
} }
delegate?.onEvent( emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error")
"end-file",
mapOf(
"reason" to "error",
"message" to (error.message ?: "Unknown error")
)
)
} }
private fun retryAfterAudioTrackError(error: PlaybackException, causeChain: String): Boolean { 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) { 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") Log.d(TAG, "onMediaItemTransition: ${mediaItem?.mediaId}, reason: $reason")
delegate?.onEvent("file-loaded", null) delegate?.onEvent("file-loaded", null)
delegate?.onPropertyChange("eof-reached", false) delegate?.onPropertyChange("eof-reached", false)
@@ -2290,6 +2322,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
private fun buildMediaItem(uri: String): MediaItem { private fun buildMediaItem(uri: String): MediaItem {
val mediaItemBuilder = MediaItem.Builder() val mediaItemBuilder = MediaItem.Builder()
.setMediaId(currentMediaGeneration.toString())
.setUri(uri) .setUri(uri)
// Every Live TV backend negotiates HLS before opening the native player. // 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 // 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). // (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 { 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( override fun onVideoDecoderInitialized(
eventTime: AnalyticsListener.EventTime, eventTime: AnalyticsListener.EventTime,
decoderName: String, decoderName: String,
initializationDurationMs: Long initializationDurationMs: Long
) { ) {
val mediaGeneration = mediaGenerationAt(eventTime) ?: return
if (mediaGeneration != currentMediaGeneration) return
decoderInitName = decoderName decoderInitName = decoderName
persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName) persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName)
firstFrameRendered = false firstFrameRendered = false
emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
logDolbyVisionPlaybackPathIfNeeded(decoderName) logDolbyVisionPlaybackPathIfNeeded(decoderName)
startDecoderHangCheck(decoderName) startDecoderHangCheck(decoderName, mediaGeneration)
} }
override fun onAudioDecoderInitialized( override fun onAudioDecoderInitialized(
@@ -2484,8 +2572,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
output: Any, output: Any,
renderTimeMs: Long renderTimeMs: Long
) { ) {
firstFrameRendered = true val mediaGeneration = mediaGenerationAt(eventTime) ?: return
cancelDecoderHangCheck() if (mediaGeneration != currentMediaGeneration || !claimPlaybackOutputReady()) return
emitLog("debug", "decoder-hang", "First frame rendered — decoder OK") emitLog("debug", "decoder-hang", "First frame rendered — decoder OK")
logNativeDvFirstFrameIfNeeded() logNativeDvFirstFrameIfNeeded()
logDolbyVisionPlaybackPathIfNeeded() 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() cancelDecoderHangCheck()
if (currentMediaUri == null) return if (mediaGeneration != currentMediaGeneration || currentMediaUri == null) return
decoderHangRunnable = Runnable { decoderHangRunnable = Runnable {
if (mediaGeneration != currentMediaGeneration) return@Runnable
if (firstFrameRendered) return@Runnable if (firstFrameRendered) return@Runnable
val uri = currentMediaUri ?: return@Runnable val uri = currentMediaUri ?: return@Runnable
val player = exoPlayer ?: return@Runnable val player = exoPlayer ?: return@Runnable
@@ -2518,9 +2607,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog() stopFrameWatchdog()
cancelDecoderHangCheck() cancelDecoderHangCheck()
if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable if (retryWithDvConversion("decoder hang: $decoderName")) return@Runnable
delegate?.onFormatUnsupported( requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri, uri = uri,
headers = currentHeaders,
positionMs = effectivePosition, positionMs = effectivePosition,
playWhenReady = player.playWhenReady, playWhenReady = player.playWhenReady,
errorMessage = "Decoder hang: $decoderName accepted input but produced no output" 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 // Frame watchdog: detects when ExoPlayer plays audio but renders 0 video frames
// (common with HDR tunneling on unsupported devices — black screen, no error) // (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() { private fun startFrameWatchdog() {
stopFrameWatchdog() stopFrameWatchdog()
emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)") emitLog("debug", "watchdog", "Started (timeout=${WATCHDOG_TIMEOUT_MS}ms)")
frameWatchdogStartTime = System.currentTimeMillis() frameWatchdogStartTime = System.currentTimeMillis()
val mediaGeneration = currentMediaGeneration
val initialCounters = exoPlayer?.videoDecoderCounters
val initialRenderedFrames = initialCounters?.renderedOutputBufferCount ?: 0
frameWatchdogRunnable = object : Runnable { frameWatchdogRunnable = object : Runnable {
override fun run() { override fun run() {
if (mediaGeneration != currentMediaGeneration) return
val player = exoPlayer ?: 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) { if (renderedFrames > 0) {
emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared") emitLog("debug", "watchdog", "$renderedFrames frames rendered, cleared")
@@ -2553,7 +2656,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
return return
} }
val elapsed = System.currentTimeMillis() - frameWatchdogStartTime val elapsed = frameWatchdogElapsedMs(System.currentTimeMillis(), player.isPlaying)
// Check if we have a video track selected // Check if we have a video track selected
val hasVideoTrack = player.currentTracks.groups.any { val hasVideoTrack = player.currentTracks.groups.any {
@@ -2569,9 +2672,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
stopFrameWatchdog() stopFrameWatchdog()
if (retryWithDvConversion("watchdog: video track deselected")) return if (retryWithDvConversion("watchdog: video track deselected")) return
val uri = currentMediaUri ?: return val uri = currentMediaUri ?: return
delegate?.onFormatUnsupported( requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri, uri = uri,
headers = currentHeaders,
positionMs = player.currentPosition, positionMs = player.currentPosition,
playWhenReady = player.playWhenReady, playWhenReady = player.playWhenReady,
errorMessage = "Video track present but no decoder available" 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 if (retryWithDvConversion("watchdog: black screen after ${elapsed}ms")) return
// Trigger fallback via the same delegate path as player errors // Trigger fallback via the same delegate path as player errors
val uri = currentMediaUri ?: return val uri = currentMediaUri ?: return
delegate?.onFormatUnsupported( requestFormatFallback(
mediaGeneration = mediaGeneration,
uri = uri, uri = uri,
headers = currentHeaders,
positionMs = player.currentPosition, positionMs = player.currentPosition,
playWhenReady = player.playWhenReady, playWhenReady = player.playWhenReady,
errorMessage = "Black screen detected: 0 video frames rendered after ${elapsed}ms" 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<String, String>?, headers: Map<String, String>?,
startPositionMs: Long, startPositionMs: Long,
autoPlay: Boolean, autoPlay: Boolean,
mediaGeneration: Int,
isLive: Boolean = false, isLive: Boolean = false,
externalSubtitleList: List<Map<String, Any?>>? = null externalSubtitleList: List<Map<String, Any?>>? = null
) { ) {
@@ -2762,12 +2866,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
directAudioOutputBlockedAfterFailure.clear() directAudioOutputBlockedAfterFailure.clear()
loggedDirectAudioRecoveryBlocks.clear() loggedDirectAudioRecoveryBlocks.clear()
currentVideoFormat = null currentVideoFormat = null
firstFrameRendered = false
loggedNativeDvSelectionKey = null loggedNativeDvSelectionKey = null
loggedNativeDvFirstFrame = false loggedNativeDvFirstFrame = false
loggedDvPlaybackPathKey = null loggedDvPlaybackPathKey = null
lastDvPlaybackInfo = null lastDvPlaybackInfo = null
loggedDecodedPcmTunnelingGuard = false loggedDecodedPcmTunnelingGuard = false
updateAudioDecoderPolicy("open") updateAudioDecoderPolicy("open")
currentMediaGeneration = mediaGeneration
terminalErrorGeneration = null
currentMediaUri = uri currentMediaUri = uri
currentHeaders = headers currentHeaders = headers
currentMediaIsLive = isLive currentMediaIsLive = isLive
@@ -3568,7 +3675,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
val uri = currentMediaUri ?: return val uri = currentMediaUri ?: return
val player = exoPlayer val player = exoPlayer
val pos = player?.currentPosition ?: 0L 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 // Cleanup
@@ -10,12 +10,15 @@ import com.edde746.plezy.mpv.completeMpvPropertyNotInitialized
import com.edde746.plezy.mpv.completeMpvPropertyResult import com.edde746.plezy.mpv.completeMpvPropertyResult
import com.edde746.plezy.shared.MpvContentUriResolver import com.edde746.plezy.shared.MpvContentUriResolver
import com.edde746.plezy.shared.PlayerChannelBinding 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.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.atomic.AtomicBoolean
class ExoPlayerPlugin : class ExoPlayerPlugin :
FlutterPlugin, FlutterPlugin,
@@ -26,6 +29,8 @@ class ExoPlayerPlugin :
companion object { companion object {
private const val TAG = "ExoPlayerPlugin" 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" 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 mpvCore: MpvPlayerCore? = null // MPV fallback player
private var usingMpvFallback: Boolean = false private var usingMpvFallback: Boolean = false
private var fallbackInProgress: Boolean = false private var fallbackInProgress: Boolean = false
private var backendSwitchPending: Boolean = false
private var activity: Activity? = null private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null private var activityBinding: ActivityPluginBinding? = null
@@ -43,19 +49,77 @@ class ExoPlayerPlugin :
// fallback can re-observe exactly what Dart asked for instead of // fallback can re-observe exactly what Dart asked for instead of
// maintaining a parallel hard-coded list. // maintaining a parallel hard-coded list.
private data class ObservedProperty(val id: Int, val format: String) private data class ObservedProperty(val id: Int, val format: String)
private class MediaOpenRequest(
val mediaGeneration: Int,
val uri: String,
val headers: Map<String, String>?,
val startPositionMs: Long,
val hasStartPosition: Boolean,
val autoPlay: Boolean,
val isLive: Boolean,
val externalSubtitles: List<Map<String, Any?>>?,
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<String, Any>?) : PendingMpvSignal()
}
private data class MpvSignalGate(
val core: MpvPlayerCore,
val mediaGeneration: Int,
val signals: MutableList<PendingMpvSignal> = 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<String, Any>?) {
val snapshot = data?.toMap()
runOnMain { forwardMpvEvent(core, name, snapshot) }
}
}
private val observedProperties = LinkedHashMap<String, ObservedProperty>() private val observedProperties = LinkedHashMap<String, ObservedProperty>()
private var configuredBufferSizeBytes: Int? = null private var configuredBufferSizeBytes: Int? = null
private var sessionGeneration = 0 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 private var debugLoggingEnabled: Boolean = false
// mpv properties set while ExoPlayer is active (including before // mpv properties set while ExoPlayer is active (including before
// initialize — Dart queues its startup properties first), replayed into a // initialize — Dart queues its startup properties first), replayed into a
// fallback MPV core. Keyed by property name (last write wins) and cleared // fallback MPV core. Keyed by property name (last write wins) and cleared
// only at real session boundaries (dispose, engine detach, open while the // only at real session boundaries so settings can be replayed if a
// fallback is already active) so one playback's properties never leak into // superseded load requires a fresh MPV core.
// the next session's fallback.
private val pendingMpvProperties = LinkedHashMap<String, String>() private val pendingMpvProperties = LinkedHashMap<String, String>()
private var currentExternalSubtitles: List<Map<String, Any?>>? = null private var currentExternalSubtitles: List<Map<String, Any?>>? = null
@@ -72,12 +136,23 @@ class ExoPlayerPlugin :
private fun teardownSession(clearActivity: Boolean) { private fun teardownSession(clearActivity: Boolean) {
++sessionGeneration ++sessionGeneration
++mediaGeneration
val exoCore = playerCore val exoCore = playerCore
val fallbackCore = mpvCore val fallbackCore = mpvCore
playerCore = null playerCore = null
mpvCore = null mpvCore = null
usingMpvFallback = false usingMpvFallback = false
fallbackInProgress = 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 currentExternalSubtitles = null
pendingMpvProperties.clear() pendingMpvProperties.clear()
if (clearActivity) { if (clearActivity) {
@@ -191,8 +266,8 @@ class ExoPlayerPlugin :
} }
val requestGeneration = sessionGeneration val requestGeneration = sessionGeneration
if (playerCore?.isInitialized == true) { if (playerCore?.isInitialized == true || mpvCore?.isInitialized == true || fallbackInProgress) {
Log.d(TAG, "Already initialized") Log.d(TAG, "Already initialized or switching backend")
result.success(true) result.success(true)
return return
} }
@@ -286,63 +361,389 @@ class ExoPlayerPlugin :
result.error("NO_ACTIVITY", "Activity not available", null) result.error("NO_ACTIVITY", "Activity not available", null)
return return
} }
val externalSubtitleSnapshot = externalSubtitles?.map { it.toMap() }
currentExternalSubtitles = externalSubtitleSnapshot
// Only clear pending MPV state when MPV is the active backend. A same-core val request = MediaOpenRequest(
// MPV reload must not inherit a fallback switch that was armed for the mediaGeneration = ++mediaGeneration,
// previous load. When ExoPlayer is active, keep queued properties for a uri = uri,
// potential ExoPlayer→MPV fallback. headers = headers,
if (usingMpvFallback) { startPositionMs = startPositionMs,
pendingMpvProperties.clear() hasStartPosition = hasStartPosition,
val generation = sessionGeneration autoPlay = autoPlay,
MpvContentUriResolver.resolve(uri, currentActivity.contentResolver, mainHandler) { source -> isLive = isLive,
if (generation != sessionGeneration || activity !== currentActivity || !usingMpvFallback) { externalSubtitles = externalSubtitles?.map { it.toMap() },
source.closeIfUnused() result = result
result.success(null)
return@resolve
}
loadMpvMedia(
source.value,
headers,
startPositionMs,
hasStartPosition,
autoPlay,
externalSubtitleSnapshot
) )
result.success(null) 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) {
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 return
} }
currentActivity.runOnUiThread { currentActivity.runOnUiThread {
playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitleSnapshot) if (request.mediaGeneration != mediaGeneration) {
result.success(null) 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( private fun loadMpvMedia(
core: MpvPlayerCore,
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
startPositionMs: Long, startPositionMs: Long,
hasStartPosition: Boolean, hasStartPosition: Boolean,
autoPlay: Boolean, autoPlay: Boolean,
externalSubtitles: List<Map<String, Any?>>? externalSubtitles: List<Map<String, Any?>>?,
onComplete: (Boolean) -> Unit
) { ) {
val startSeconds = startPositionMs / 1000.0 val startSeconds = startPositionMs / 1000.0
val options = mutableListOf<String>() val options = mutableListOf<String>()
options.add(if (hasStartPosition && startPositionMs > 0L) "start=$startSeconds" else "start=none") 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("sid=no")
options.add("secondary-sid=no") options.add("secondary-sid=no")
appendExternalSubtitleOptions(options, externalSubtitles) appendExternalSubtitleOptions(options, externalSubtitles)
appendHttpHeaderOptions(options, headers) appendHttpHeaderOptions(options, headers)
val optionsStr = options.joinToString(",") val optionsStr = options.joinToString(",")
val core = mpvCore ?: return core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr), onComplete)
core.setPauseIntentForLoad(paused = !autoPlay) }
core.command(arrayOf("loadfile", uri, "replace", "-1", optionsStr)) { success ->
if (success && autoPlay) { private fun completeSupersededOpen(request: MediaOpenRequest) {
core.setProperty("pause", "no") 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<String, Any>?) {
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) completeMpvPropertyNotInitialized(result)
return@runOnUiThread return@runOnUiThread
} }
pendingMpvProperties[name] = value
core.setProperty(name, value) { outcome -> core.setProperty(name, value) { outcome ->
val currentOutcome = if ( val currentOutcome = if (
usingMpvFallback && usingMpvFallback &&
@@ -883,30 +1285,11 @@ class ExoPlayerPlugin :
* the media at the handoff position. Runs in MpvPlayerCore.initialize's * the media at the handoff position. Runs in MpvPlayerCore.initialize's
* completion callback on the main thread. * completion callback on the main thread.
*/ */
private fun setupMpvFallback( private fun prepareMpvFallback(core: MpvPlayerCore) {
core: MpvPlayerCore,
act: Activity,
uri: String,
headers: Map<String, String>?,
positionMs: Long,
externalSubtitles: List<Map<String, Any?>>?,
playWhenReady: Boolean,
generation: Int
) {
// Snapshot Dart-registered state on main thread before clearing.
val pendingProps = pendingMpvProperties.toList() val pendingProps = pendingMpvProperties.toList()
pendingMpvProperties.clear()
val observedProps = observedProperties.toList() val observedProps = observedProperties.toList()
val bufferSize = configuredBufferSizeBytes val bufferSize = configuredBufferSizeBytes
MpvContentUriResolver.resolve(uri, act.contentResolver, mainHandler) { source ->
if (generation != sessionGeneration || mpvCore !== core || activity !== act || !usingMpvFallback) {
source.closeIfUnused()
core.dispose()
return@resolve
}
// Configure basic MPV properties for Plex playback.
core.setProperty("hwdec", "mediacodec,mediacodec-copy") core.setProperty("hwdec", "mediacodec,mediacodec-copy")
core.setProperty("vo", "gpu") core.setProperty("vo", "gpu")
core.setProperty("ao", "audiotrack") core.setProperty("ao", "audiotrack")
@@ -923,30 +1306,12 @@ class ExoPlayerPlugin :
} }
} }
// 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) { for ((propName, observed) in observedProps) {
core.observeProperty(propName, observed.format) core.observeProperty(propName, observed.format)
} }
core.setVisible(true) core.setVisible(true)
val startSeconds = positionMs / 1000.0
val options = mutableListOf<String>()
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 { Thread {
val peakDetection = core.getProperty("hdr-compute-peak") val peakDetection = core.getProperty("hdr-compute-peak")
if (peakDetection == "no") { if (peakDetection == "no") {
@@ -958,25 +1323,72 @@ class ExoPlayerPlugin :
}.start() }.start()
core.requestAudioFocus() core.requestAudioFocus()
Log.i(TAG, "Successfully switched to MPV fallback")
} }
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( override fun onFormatUnsupported(
mediaGeneration: Int,
uri: String, uri: String,
headers: Map<String, String>?, headers: Map<String, String>?,
positionMs: Long, positionMs: Long,
playWhenReady: Boolean, playWhenReady: Boolean,
errorMessage: String errorMessage: String
): Boolean { ): Boolean {
if (mediaGeneration != this.mediaGeneration) {
Log.d(TAG, "Ignoring stale fallback request for media generation $mediaGeneration")
return true
}
if (usingMpvFallback || fallbackInProgress) { 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 return true
} }
val currentActivity = activity ?: return false 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 fallbackInProgress = true
fallbackMediaGeneration = mediaGeneration
Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage") Log.i(TAG, "ExoPlayer error, switching to MPV fallback at ${positionMs}ms: $errorMessage")
if (debugLoggingEnabled) { if (debugLoggingEnabled) {
@@ -990,72 +1402,81 @@ class ExoPlayerPlugin :
) )
} }
onPropertyChange("paused-for-cache", true)
onPropertyChange("pause", true)
currentActivity.runOnUiThread { currentActivity.runOnUiThread {
try { try {
// Dispose ExoPlayer
playerCore?.dispose() playerCore?.dispose()
playerCore = null playerCore = null
mpvCore?.dispose() mpvCore?.dispose()
mpvCore = null mpvCore = null
usingMpvFallback = false // Clear before handoff usingMpvFallback = false
val generation = sessionGeneration val generation = sessionGeneration
mainHandler.post { mainHandler.post {
if (generation != sessionGeneration) { if (generation != sessionGeneration) return@post
fallbackInProgress = false val act = activity ?: return@post
return@post
}
val act = activity
if (act == null) {
fallbackInProgress = false
return@post
}
try { try {
val core = MpvPlayerCore(act).apply { val core = createMpvCore(act)
delegate = this@ExoPlayerPlugin core.delegate = MpvDelegate(core)
} mpvCore = core
mpvCore = core // publish so dispose/init can reach it
core.initialize { success -> val initializationSettled = AtomicBoolean(false)
if (generation != sessionGeneration) { val timeout = Runnable {
if (mpvCore === core) { if (!initializationSettled.compareAndSet(false, true)) return@Runnable
if (generation != sessionGeneration || mpvCore !== core) {
if (mpvCore === core) mpvCore = null
core.dispose() core.dispose()
mpvCore = null return@Runnable
} }
fallbackInProgress = false failActiveFallback(mediaGeneration, "Timed out initializing MPV fallback")
return@initialize }
mainHandler.postDelayed(timeout, MPV_FALLBACK_INIT_TIMEOUT_MS)
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()
return@onInitialized
} }
if (!success) { if (!success) {
if (mpvCore === core) { failActiveFallback(mediaGeneration, "Failed to initialize MPV fallback")
core.dispose() return@onInitialized
mpvCore = null
}
fallbackInProgress = false
Log.e(TAG, "Failed to initialize MPV fallback")
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: $errorMessage"))
return@initialize
} }
usingMpvFallback = true usingMpvFallback = true
fallbackInProgress = false fallbackInProgress = false
fallbackMediaGeneration = null
setupMpvFallback(core, act, uri, headers, positionMs, fallbackExternalSubtitles, playWhenReady, generation) 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) { } catch (e: Exception) {
fallbackInProgress = false if (initializationSettled.compareAndSet(false, true)) {
Log.e(TAG, "Failed to switch to MPV fallback", e) mainHandler.removeCallbacks(timeout)
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) failActiveFallback(mediaGeneration, "Failed to initialize MPV fallback", e)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
fallbackInProgress = false failActiveFallback(mediaGeneration, "Failed to switch to MPV fallback", e)
Log.e(TAG, "Failed to switch to MPV fallback", e) }
onEvent("end-file", mapOf("reason" to "error", "message" to "Fallback failed: ${e.message}")) }
} catch (e: Exception) {
failActiveFallback(mediaGeneration, "Failed to switch to MPV fallback", e)
} }
} }
return true // Fallback is being handled return true
} }
} }
@@ -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<Pair<String, Any?>>()
val events = mutableListOf<Pair<String, Map<String, Any>?>>()
override fun onFormatUnsupported(
mediaGeneration: Int,
uri: String,
headers: Map<String, String>?,
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<String, Any>?) {
events += name to data
}
}
}
@@ -165,6 +165,314 @@ class ExoPlayerPluginTest {
assertEquals(null, second.errorCode) 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<String, Any?>()), 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 @Test
fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() { fun initialHeldFallbackSynchronouslyBlocksFocusAndSurfaceResumeWithoutPausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -178,7 +486,7 @@ class ExoPlayerPluginTest {
core.setPrivateField("deferredResumeRequested", true) core.setPrivateField("deferredResumeRequested", true)
val plugin = initialFallbackPlugin(activity, core) val plugin = initialFallbackPlugin(activity, core)
invokeSetupMpvFallback(plugin, core, activity, playWhenReady = false) invokeSetupMpvFallback(plugin, core, playWhenReady = false)
invokeAutoResume(core, "audio focus gain") invokeAutoResume(core, "audio focus gain")
invokeAutoResume(core, "surface attached") invokeAutoResume(core, "surface attached")
@@ -203,7 +511,7 @@ class ExoPlayerPluginTest {
core.setPrivateField("resumeBlockedByPublicPause", true) core.setPrivateField("resumeBlockedByPublicPause", true)
val plugin = initialFallbackPlugin(activity, core) 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("desiredPaused"))
assertEquals(false, core.getPrivateField("cachedPaused")) assertEquals(false, core.getPrivateField("cachedPaused"))
@@ -234,6 +542,7 @@ class ExoPlayerPluginTest {
result result
) )
invokeAutoResume(core, "surface attached") invokeAutoResume(core, "surface attached")
awaitCompletion(result)
assertEquals(1, result.completionCount) assertEquals(1, result.completionCount)
assertNull(result.errorCode) assertNull(result.errorCode)
@@ -247,7 +556,7 @@ class ExoPlayerPluginTest {
} }
@Test @Test
fun reusedAutoplayFallbackClearsIntentBeforeLoadAndClearsPersistedNativePauseAfterSuccess() { fun reusedAutoplayFallbackClearsIntentBeforeLoadWithoutLatePausePropertyWrite() {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get() val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val writes = ConcurrentLinkedQueue<Pair<String, String>>() val writes = ConcurrentLinkedQueue<Pair<String, String>>()
val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value } val core = MpvPlayerCore(activity, true) { name, value -> writes += name to value }
@@ -264,15 +573,15 @@ class ExoPlayerPluginTest {
), ),
result result
) )
awaitCompletion(result)
assertEquals(1, result.completionCount) assertEquals(1, result.completionCount)
assertNull(result.errorCode) assertNull(result.errorCode)
assertEquals(false, core.getPrivateField("desiredPaused")) assertEquals(false, core.getPrivateField("desiredPaused"))
assertEquals(false, core.getPrivateField("cachedPaused")) assertEquals(false, core.getPrivateField("cachedPaused"))
assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause")) assertEquals(false, core.getPrivateField("resumeBlockedByPublicPause"))
assertTrue(awaitQueueEntry(writes, "pause" to "no")) assertFalse(awaitPauseWriteCount(writes, 1))
assertFalse(awaitPauseWriteCount(writes, 2)) assertEquals(emptyList<Pair<String, String>>(), writes.filter { it.first == "pause" })
assertEquals(listOf("pause" to "no"), writes.filter { it.first == "pause" })
core.dispose() core.dispose()
} }
@@ -444,32 +753,15 @@ class ExoPlayerPluginTest {
private fun invokeSetupMpvFallback( private fun invokeSetupMpvFallback(
plugin: ExoPlayerPlugin, plugin: ExoPlayerPlugin,
core: MpvPlayerCore, core: MpvPlayerCore,
activity: Activity,
playWhenReady: Boolean playWhenReady: Boolean
) { ) {
core.setPauseIntentForLoad(paused = !playWhenReady)
ExoPlayerPlugin::class.java.getDeclaredMethod( ExoPlayerPlugin::class.java.getDeclaredMethod(
"setupMpvFallback", "prepareMpvFallback",
MpvPlayerCore::class.java, 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
).apply { ).apply {
isAccessible = true isAccessible = true
invoke( invoke(plugin, core)
plugin,
core,
activity,
"https://example.test/video.mkv",
null,
0L,
null,
playWhenReady,
0
)
} }
} }
@@ -577,9 +869,16 @@ class ExoPlayerPluginTest {
private class RecordingEventSink : EventChannel.EventSink { private class RecordingEventSink : EventChannel.EventSink {
var successValue: Any? = null var successValue: Any? = null
val successValues = mutableListOf<Any?>()
val eventNames: List<String>
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?) { override fun success(event: Any?) {
successValue = event successValue = event
successValues += event
} }
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) = Unit
+16 -1
View File
@@ -157,11 +157,17 @@ class PlayerAndroid extends PlayerBase {
await _ensureInitialized(); await _ensureInitialized();
final startPosition = media.start ?? Duration.zero; final startPosition = media.start ?? Duration.zero;
final hasStartPosition = media.start != null && startPosition > 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); configureTimeline(duration: timelineDuration);
clearTracks(); clearTracks();
setExternalSubtitleMetadata(externalSubtitles); setExternalSubtitleMetadata(externalSubtitles);
resetPlaybackProgress(startPosition);
setSeekable(false); setSeekable(false);
try {
// Show the video layer // Show the video layer
await setVisible(true); await setVisible(true);
@@ -187,7 +193,16 @@ class PlayerAndroid extends PlayerBase {
) )
.toList(), .toList(),
}); });
resetPlaybackProgress(media.start ?? Duration.zero); } catch (_) {
if (!disposed) {
configureTimeline(duration: previousTimelineDuration);
restorePlaybackProgress(previousState, position: previousPosition);
restoreTracks(previousState);
restoreExternalSubtitleMetadata(previousExternalSubtitleMetadata);
setSeekable(previousState.seekable);
}
rethrow;
}
} }
@override @override
+37
View File
@@ -609,6 +609,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_externalSubtitleMetadataByUri = metadataByUri; _externalSubtitleMetadataByUri = metadataByUri;
} }
@protected
Map<String, SubtitleTrack> snapshotExternalSubtitleMetadata() =>
Map<String, SubtitleTrack>.of(_externalSubtitleMetadataByUri);
@protected
void restoreExternalSubtitleMetadata(Map<String, SubtitleTrack> snapshot) {
_externalSubtitleMetadataByUri = Map<String, SubtitleTrack>.of(snapshot);
}
@protected @protected
void setVolumeState(double volume) { void setVolumeState(double volume) {
if (_state.volume == volume) return; if (_state.volume == volume) return;
@@ -628,6 +637,9 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
_timelineDuration = duration; _timelineDuration = duration;
} }
@protected
Duration? get configuredTimelineDuration => _timelineDuration;
@protected @protected
void resetPlaybackProgress(Duration sourcePosition) { void resetPlaybackProgress(Duration sourcePosition) {
final position = sourcePosition; final position = sourcePosition;
@@ -646,6 +658,31 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
bufferRangesController.add(const []); 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 @protected
Future<T?> invoke<T>(String method, [dynamic args]) async { Future<T?> invoke<T>(String method, [dynamic args]) async {
if (_disposed) return null; if (_disposed) return null;
@@ -412,7 +412,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
try { try {
final currentPlayer = existingPlayer; final currentPlayer = existingPlayer;
final attempt = _beginPlaybackAttempt(currentPlayer, isMediaReload: true); 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 // The session itself swaps atomically at the open boundary, so the only
// rollback state is the eagerly-set identity (shown by the loading UI) // 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 previousLaunchIdentity = VideoPlayerScreenState._activeRouteGuard.identityFor(this);
final previousPartId = _currentMediaInfo?.partId; final previousPartId = _currentMediaInfo?.partId;
final previousHasFirstFrame = _hasFirstFrame.value; final previousHasFirstFrame = _hasFirstFrame.value;
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
final previousHasFatalPlaybackError = _hasFatalPlaybackError;
_hasFatalPlaybackError = false;
final isItemChange = previousMetadata.globalKey != metadata.globalKey; final isItemChange = previousMetadata.globalKey != metadata.globalKey;
final currentAudioTrack = preserveCurrentTrackSelection final currentAudioTrack = preserveCurrentTrackSelection
@@ -645,6 +648,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup, play: shouldAutoStart && !frameRatePlan.holdPlaybackStart && externalSubtitlePlan.canStartBeforeTrackSetup,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: isCurrentReload, shouldContinue: isCurrentReload,
onOpening: () {
_hasRenderedFirstFrame = false;
},
onOpened: () { onOpened: () {
// The player now owns the new file — publish the session at the // The player now owns the new file — publish the session at the
// same boundary so identity and source state flip together. // same boundary so identity and source state flip together.
@@ -779,6 +785,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity); VideoPlayerScreenState._activeRouteGuard.update(this, previousLaunchIdentity);
} }
_hasFirstFrame.value = previousHasFirstFrame; _hasFirstFrame.value = previousHasFirstFrame;
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
_hasFatalPlaybackError = previousHasFatalPlaybackError;
// If the stop report already went out, un-latch the tracker so the // If the stop report already went out, un-latch the tracker so the
// resumed session keeps reporting (and its eventual real stop sends). // resumed session keeps reporting (and its eventual real stop sends).
_progressTracker?.resumeAfterStoppedReport(); _progressTracker?.resumeAfterStoppedReport();
+6 -2
View File
@@ -16,7 +16,9 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
// Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar.
if (err.cause == PlayerError.serverHttp500 || _sawServer500) { if (err.cause == PlayerError.serverHttp500 || _sawServer500) {
_showServerLimitDialog(); _hasFatalPlaybackError = true;
_progressTracker?.stopTracking();
unawaited(_showServerLimitDialog());
return; return;
} }
@@ -39,8 +41,10 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
} }
} }
_hasFatalPlaybackError = true;
_progressTracker?.stopTracking();
showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message)); showGlobalErrorSnackBar(_redactPlayerError(_lastLogError ?? err.message));
_handleBackButton(); unawaited(_handleBackButton());
} }
void _onPlayerLog(PlayerLog log) { void _onPlayerLog(PlayerLog log) {
+10 -2
View File
@@ -269,6 +269,8 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
_liveSeek.cancel(); _liveSeek.cancel();
final previousSession = _live.session; final previousSession = _live.session;
final previousHasFirstFrame = _hasFirstFrame.value;
final previousHasRenderedFirstFrame = _hasRenderedFirstFrame;
final channel = channels[newIndex]; final channel = channels[newIndex];
appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})'); appLogger.d('Switching to channel: ${channel.displayName} (${channel.key})');
@@ -298,7 +300,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
return; return;
} }
_setPlayerState(() => _hasFirstFrame.value = false); _setPlayerState(() {
_hasFirstFrame.value = false;
_hasRenderedFirstFrame = false;
});
replacementOpenStarted = true; replacementOpenStarted = true;
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true); await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
if (!isCurrentChannelSwitch()) { if (!isCurrentChannelSwitch()) {
@@ -336,7 +341,10 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan); if (orphan != null && _live.session != orphan) _abandonLiveSession(orphan);
if (!isCurrentChannelSwitch()) return; if (!isCurrentChannelSwitch()) return;
if (replacementOpenStarted && mounted && _live.session == previousSession) { if (replacementOpenStarted && mounted && _live.session == previousSession) {
_setPlayerState(() => _hasFirstFrame.value = true); _setPlayerState(() {
_hasFirstFrame.value = previousHasFirstFrame;
_hasRenderedFirstFrame = previousHasRenderedFirstFrame;
});
} }
appLogger.e('Failed to switch channel', error: e); appLogger.e('Failed to switch channel', error: e);
if (mounted) showErrorSnackBar(context, e.toString()); if (mounted) showErrorSnackBar(context, e.toString());
@@ -593,7 +593,9 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
required bool play, required bool play,
List<SubtitleTrack>? externalSubtitlesAtOpen, List<SubtitleTrack>? externalSubtitlesAtOpen,
bool Function()? shouldContinue, bool Function()? shouldContinue,
void Function()? onOpening,
void Function()? onOpened, void Function()? onOpened,
void Function(bool available)? onMediaAvailabilityChanged,
}) async { }) async {
await _applyNetworkStreamTuning( await _applyNetworkStreamTuning(
player: player, player: player,
@@ -606,6 +608,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
final media = Media(videoUrl, start: timing.mediaStart, headers: headers); final media = Media(videoUrl, start: timing.mediaStart, headers: headers);
final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen); final sidecarOpenGuard = MpvSidecarOpenGuard.armIfNeeded(player: player, subtitles: externalSubtitlesAtOpen);
Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) { Future<void> openMedia({required bool shouldPlay, List<SubtitleTrack>? externalSubtitles}) {
onOpening?.call();
return player.open( return player.open(
media, media,
play: shouldPlay, play: shouldPlay,
@@ -617,6 +620,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
try { try {
await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen); await openMedia(shouldPlay: play, externalSubtitles: externalSubtitlesAtOpen);
onOpened?.call(); onOpened?.call();
onMediaAvailabilityChanged?.call(true);
} catch (_) { } catch (_) {
await sidecarOpenGuard?.dispose(); await sidecarOpenGuard?.dispose();
rethrow; rethrow;
@@ -633,10 +637,12 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
return const _MediaOpenResult(didOpen: true); return const _MediaOpenResult(didOpen: true);
} }
await player.stop(); await player.stop();
onMediaAvailabilityChanged?.call(false);
if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true); if (shouldContinue != null && !shouldContinue()) return const _MediaOpenResult(didOpen: true);
// Respect a pause requested while mpv was waiting on the sidecar. A // Respect a pause requested while mpv was waiting on the sidecar. A
// startup gate encoded by [play] remains authoritative when it is false. // startup gate encoded by [play] remains authoritative when it is false.
await openMedia(shouldPlay: play && _playbackIntentShouldPlay); await openMedia(shouldPlay: play && _playbackIntentShouldPlay);
onMediaAvailabilityChanged?.call(true);
sidecarFallbackUsed = true; sidecarFallbackUsed = true;
if (mounted && (shouldContinue == null || shouldContinue())) { if (mounted && (shouldContinue == null || shouldContinue())) {
showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback); showErrorSnackBar(context, t.videoControls.subtitleUnavailableFallback);
@@ -37,10 +37,15 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
} }
Future<void> _markFirstFrameReady(Player currentPlayer, SettingsService settingsService) async { Future<void> _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; _hasFirstFrame.value = true;
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'First frame ready', category: 'player'))); 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)) { if (Platform.isAndroid && settingsService.read(SettingsService.matchContentFrameRate)) {
await _applyFrameRateMatching(); await _applyFrameRateMatching();
@@ -68,6 +73,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (_positionSubscription != null) _positionSubscription!.cancel(), if (_positionSubscription != null) _positionSubscription!.cancel(),
]); ]);
if (!mounted || player != currentPlayer) return; if (!mounted || player != currentPlayer) return;
int? lastObservedPositionMs;
_playingSubscription = currentPlayer.streams.playing.listen(_onPlayingStateChanged); _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 // completed=false while parked at EOF must NOT re-arm, or the position
// listener would immediately re-fire the Play Next prompt. // listener would immediately re-fire the Play Next prompt.
if (!done) { if (!done) {
lastObservedPositionMs = null;
final durMs = currentPlayer.state.duration.inMilliseconds; final durMs = currentPlayer.state.duration.inMilliseconds;
final posMs = currentPlayer.state.position.inMilliseconds; final posMs = currentPlayer.state.position.inMilliseconds;
if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) { if (durMs <= 0 || posMs < durMs - _completionLatch.rearmWindowMs) {
@@ -145,18 +152,16 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
await markFirstFrameReady; await markFirstFrameReady;
}); });
int? lastObservedPositionMs;
_positionSubscription = currentPlayer.streams.position.listen((position) { _positionSubscription = currentPlayer.streams.position.listen((position) {
final activePlayer = player; final activePlayer = player;
if (activePlayer == null || activePlayer != currentPlayer) return; if (activePlayer == null || activePlayer != currentPlayer) return;
// Fallback for cases where playbackRestart doesn't fire (observed on // Fallback for MPV backends whose playbackRestart event is unavailable.
// some offline Android playback flows). Prevents a permanent loading // Android ExoPlayer position can advance on its standalone clock without
// spinner. Checking `position > 0` was broken for resume playback — // a renderer, so it may infer readiness only after switching to MPV.
// the native layer sets position to the resume offset before the first final canInferRenderedFrameFromPosition =
// frame renders, so the fallback tripped immediately. Requiring a !(Platform.isAndroid && useExoPlayer) || (currentPlayer is PlayerAndroid && currentPlayer.usingMpvFallback);
// position *change* ensures we only fire when playback is advancing. if (canInferRenderedFrameFromPosition && !_hasRenderedFirstFrame) {
if (!_hasFirstFrame.value) {
if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) { if (lastObservedPositionMs != null && position.inMilliseconds != lastObservedPositionMs) {
unawaited(_markFirstFrameReady(currentPlayer, settingsService)); unawaited(_markFirstFrameReady(currentPlayer, settingsService));
} }
@@ -305,6 +310,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
if (mounted) { if (mounted) {
_isBuffering.value = false; _isBuffering.value = false;
_hasFirstFrame.value = false; _hasFirstFrame.value = false;
_hasRenderedFirstFrame = false;
} }
} }
@@ -326,6 +332,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
MediaSourceInfo? mediaInfo, MediaSourceInfo? mediaInfo,
}) { }) {
final currentPlayer = player; final currentPlayer = player;
if (_hasFatalPlaybackError) return;
if (currentPlayer == null) return; if (currentPlayer == null) return;
_rebindProgressTracker( _rebindProgressTracker(
@@ -371,6 +379,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
MediaSourceInfo? mediaInfo, MediaSourceInfo? mediaInfo,
}) { }) {
final currentPlayer = player; final currentPlayer = player;
if (_hasFatalPlaybackError) return;
if (currentPlayer == null) return; if (currentPlayer == null) return;
// Local media still reports live when its server is online; only queue // Local media still reports live when its server is online; only queue
@@ -390,6 +400,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
playMethod: effectivePlayMethod, playMethod: effectivePlayMethod,
playSessionId: playSessionId, playSessionId: playSessionId,
mediaInfo: mediaInfo, mediaInfo: mediaInfo,
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _hasRenderedFirstFrame,
onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode' onPausedKeepalive: mediaClient is PlexClient && effectivePlayMethod == 'Transcode'
? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId) ? () => mediaClient.pingTranscodeSession(_playbackTranscodeSessionId)
: null, : null,
@@ -414,6 +426,8 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
player: currentPlayer, player: currentPlayer,
isOffline: true, isOffline: true,
offlineWatchService: offlineWatchService, offlineWatchService: offlineWatchService,
canReportPlayback: () => _hasRenderedFirstFrame && !_hasFatalPlaybackError,
hasRenderedPlayback: () => _hasRenderedFirstFrame,
); );
_progressTracker!.startTracking(); _progressTracker!.startTracking();
} }
@@ -422,7 +436,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
/// Initialize the service layer /// Initialize the service layer
Future<void> _initializeServices() async { Future<void> _initializeServices() async {
final currentPlayer = player; final currentPlayer = player;
if (!mounted || currentPlayer == null) return; if (!mounted || currentPlayer == null || _hasFatalPlaybackError) return;
// Live TV: send timeline heartbeats to keep transcode session alive // Live TV: send timeline heartbeats to keep transcode session alive
if (widget.isLive) { if (widget.isLive) {
@@ -5,6 +5,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
final currentPlayer = player; final currentPlayer = player;
if (!mounted || currentPlayer == null) return; if (!mounted || currentPlayer == null) return;
final attempt = _beginPlaybackAttempt(currentPlayer); final attempt = _beginPlaybackAttempt(currentPlayer);
_hasRenderedFirstFrame = false;
_hasFatalPlaybackError = false;
// Live TV mode: bypass standard playback initialization // Live TV mode: bypass standard playback initialization
if (widget.isLive) { if (widget.isLive) {
@@ -97,6 +99,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
// Capture providers before async gaps // Capture providers before async gaps
final offlineWatchService = context.read<OfflineWatchSyncService>(); final offlineWatchService = context.read<OfflineWatchSyncService>();
var primaryMediaOpened = false;
try { try {
PlaybackContext playbackContext; PlaybackContext playbackContext;
@@ -255,6 +258,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
play: shouldAutoPlay, play: shouldAutoPlay,
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
shouldContinue: () => attempt.isCurrent, shouldContinue: () => attempt.isCurrent,
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
); );
if (!openResult.didOpen || !attempt.isCurrent) return; if (!openResult.didOpen || !attempt.isCurrent) return;
if (openResult.sidecarFallbackUsed) { if (openResult.sidecarFallbackUsed) {
@@ -376,14 +380,20 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
} }
} on PlaybackException catch (e, st) { } on PlaybackException catch (e, st) {
appLogger.w('Playback initialization failed', error: e, stackTrace: st); appLogger.w('Playback initialization failed', error: e, stackTrace: st);
if (mounted) { if (attempt.isCurrent && mounted) {
_hasFirstFrame.value = true; // Hide spinner on error if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, e.message); showErrorSnackBar(context, e.message);
} }
} catch (e, st) { } catch (e, st) {
appLogger.e('Failed to start playback', error: e, stackTrace: st); appLogger.e('Failed to start playback', error: e, stackTrace: st);
if (mounted) { if (attempt.isCurrent && mounted) {
_hasFirstFrame.value = true; // Hide spinner on error if (!primaryMediaOpened) {
_hasFatalPlaybackError = true;
}
_hasFirstFrame.value = true; // Hide spinner on every current startup failure
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
} }
} }
+6
View File
@@ -722,6 +722,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false); final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false); final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(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<bool> _isExiting = ValueNotifier<bool>(false); final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
final PlayerChromeController _chromeController = PlayerChromeController(); final PlayerChromeController _chromeController = PlayerChromeController();
late final PlayerNavigationCoordinator _playerNavigationCoordinator; late final PlayerNavigationCoordinator _playerNavigationCoordinator;
+40 -7
View File
@@ -71,6 +71,17 @@ class PlaybackProgressTracker {
/// while paused. Best-effort; failures are the callee's to swallow. /// while paused. Best-effort; failures are the callee's to swallow.
final Future<void> Function()? onPausedKeepalive; final Future<void> 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 for periodic progress updates
Timer? _progressTimer; Timer? _progressTimer;
@@ -94,6 +105,7 @@ class PlaybackProgressTracker {
Future<void>? _stoppedProgressFuture; Future<void>? _stoppedProgressFuture;
Duration? _lastProgressNotifiedPosition; Duration? _lastProgressNotifiedPosition;
Duration? _lastReportablePosition;
static const Duration _progressNotifyDelta = Duration(seconds: 30); static const Duration _progressNotifyDelta = Duration(seconds: 30);
@@ -111,6 +123,8 @@ class PlaybackProgressTracker {
this.mediaInfo, this.mediaInfo,
this.onScrobbled, this.onScrobbled,
this.onPausedKeepalive, this.onPausedKeepalive,
this.canReportPlayback,
this.hasRenderedPlayback,
this.updateInterval = const Duration(seconds: 10), this.updateInterval = const Duration(seconds: 10),
}) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'), }) : assert(!isOffline || offlineWatchService != null, 'offlineWatchService is required when isOffline is true'),
assert(isOffline || client != null, 'client is required when isOffline is false'), assert(isOffline || client != null, 'client is required when isOffline is false'),
@@ -195,8 +209,17 @@ class PlaybackProgressTracker {
Duration? attemptedPosition; Duration? attemptedPosition;
Duration? attemptedDuration; Duration? attemptedDuration;
try { 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 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; attemptedPosition = position;
attemptedDuration = duration; attemptedDuration = duration;
@@ -206,14 +229,19 @@ class PlaybackProgressTracker {
} }
if (isOffline) { 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); await _sendOfflineProgress(position, duration);
_notifyProgressIfNeeded(position, duration, force: state == 'stopped'); _notifyProgressIfNeeded(position, duration, force: state == 'stopped');
} else if (state == 'stopped') { } else if (state == 'stopped') {
// Stopped must complete before disposal // Stopped must complete before disposal. When reporting was disabled
final accepted = await _sendOnlineProgress(state, position, duration); // 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(); _resetBackoff();
if (accepted) { if (accepted && canCommitStoppedProgress) {
_notifyProgressIfNeeded(position, duration, force: true); _notifyProgressIfNeeded(position, duration, force: true);
} }
} else { } else {
@@ -305,7 +333,12 @@ class PlaybackProgressTracker {
/// Send progress update to the active server through the unified /// Send progress update to the active server through the unified
/// [MediaServerClient.reportPlayback*] surface. /// [MediaServerClient.reportPlayback*] surface.
Future<bool> _sendOnlineProgress(String state, Duration position, Duration duration) async { Future<bool> _sendOnlineProgress(
String state,
Duration position,
Duration duration, {
bool allowScrobble = true,
}) async {
final c = client; final c = client;
final session = _reportSession; final session = _reportSession;
if (c == null || session == null) return false; if (c == null || session == null) return false;
@@ -321,7 +354,7 @@ class PlaybackProgressTracker {
), ),
); );
if (accepted) { if (accepted && allowScrobble) {
await _maybeScrobble(c, position, duration); await _maybeScrobble(c, position, duration);
} }
return accepted; return accepted;
+119
View File
@@ -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<PlatformException>()),
);
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 { test('ExoPlayer applies audio settings queued before initialization', () async {
final calls = <MethodCall>[]; final calls = <MethodCall>[];
await withMockPlayerChannels( await withMockPlayerChannels(
@@ -826,6 +939,12 @@ void main() {
}); });
} }
class _TestPlayerAndroid extends PlayerAndroid {
void seedExternalSubtitleMetadata(List<SubtitleTrack> subtitles) {
setExternalSubtitleMetadata(subtitles);
}
}
void _seedTracks(dynamic player) { void _seedTracks(dynamic player) {
player.handlePropertyChange('track-list', const [ player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true}, {'type': 'audio', 'id': '2_0', 'title': 'English', 'lang': 'eng', 'selected': true},
+24
View File
@@ -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<void>.delayed(Duration.zero);
expect(restartCount, 0);
player.handlePlayerEvent('playback-restart', null);
await Future<void>.delayed(Duration.zero);
expect(restartCount, 1);
});
test('mpv registers the core properties (plus its track/device extras)', () async { test('mpv registers the core properties (plus its track/device extras)', () async {
final player = PlayerNative(); final player = PlayerNative();
final observations = await capturedObservations( final observations = await capturedObservations(
@@ -355,14 +355,19 @@ class _StopMarksWatchedClient extends _FakePlexClient {
const Object _defaultServerId = Object(); const Object _defaultServerId = Object();
MediaItem _meta({String ratingKey = '42', Object? serverId = _defaultServerId, String? type = 'movie'}) => MediaItem _meta({
testMediaItem( String ratingKey = '42',
Object? serverId = _defaultServerId,
String? type = 'movie',
int? viewOffsetMs,
}) => testMediaItem(
id: ratingKey, id: ratingKey,
backend: MediaBackend.plex, backend: MediaBackend.plex,
kind: MediaKind.fromString(type), kind: MediaKind.fromString(type),
title: 'Test Item', title: 'Test Item',
serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?, serverId: identical(serverId, _defaultServerId) ? ServerId('srv') : serverId as ServerId?,
); viewOffsetMs: viewOffsetMs,
);
void main() { void main() {
setUp(resetSharedPreferencesForTest); 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<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, isEmpty);
await tracker.sendProgress('paused');
await Future<void>.delayed(Duration.zero);
expect(client.updateProgressCalls, isEmpty);
ready = true;
await tracker.sendProgress('playing');
await Future<void>.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<void>.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 // sendProgress: online routing
// ============================================================ // ============================================================
@@ -962,6 +1043,34 @@ void main() {
expect(action.duration, 60000); 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 { test('offline + null serverId is a no-op (does NOT throw, does NOT queue)', () async {
final (svc: svc, db: db, mgr: mgr) = await makeOfflineService(); final (svc: svc, db: db, mgr: mgr) = await makeOfflineService();
addTearDown(() async { addTearDown(() async {