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