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