fix(android): prevent ghost playback after autoplay failures
close #1673
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+281
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user