@@ -1460,6 +1460,19 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFrame() {
|
||||
if (disposing) return
|
||||
activity.runOnUiThread {
|
||||
if (disposing) return@runOnUiThread
|
||||
ensureFlutterOverlayOnTop()
|
||||
lastVideoSize?.let { videoSize ->
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
updateSurfaceViewSize(videoSize.width, videoSize.height, videoSize.pixelWidthHeightRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio Focus
|
||||
|
||||
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
||||
|
||||
@@ -124,6 +124,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
"selectSubtitleTrack" -> handleSelectSubtitleTrack(call, result)
|
||||
"addSubtitleTrack" -> handleAddSubtitleTrack(call, result)
|
||||
"setVisible" -> handleSetVisible(call, result)
|
||||
"updateFrame" -> handleUpdateFrame(result)
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
@@ -420,6 +421,15 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.updateFrame()
|
||||
} else {
|
||||
playerCore?.updateFrame()
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.edde746.plezy.mpv
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Color
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.ImageReader
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -17,6 +19,8 @@ import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
|
||||
@@ -28,13 +32,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
private var surfaceContainer: android.widget.FrameLayout? = null
|
||||
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||
@Volatile private var disposing: Boolean = false
|
||||
private var pendingSurface: Surface? = null
|
||||
private var lastSurfaceSize: String? = null
|
||||
@Volatile private var pendingSurface: Surface? = null
|
||||
@Volatile private var attachedSurface: Surface? = null
|
||||
private var placeholderImageReader: ImageReader? = null
|
||||
@Volatile private var placeholderSurface: Surface? = null
|
||||
@Volatile private var lastAppliedSurfaceSize: String? = null
|
||||
@Volatile private var lastKnownSurfaceWidth: Int = 0
|
||||
@Volatile private var lastKnownSurfaceHeight: Int = 0
|
||||
var delegate: PlayerDelegate? = null
|
||||
var isInitialized: Boolean = false
|
||||
private set
|
||||
|
||||
private var player: MpvPlayer? = null
|
||||
@Volatile private var player: MpvPlayer? = null
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// Frame rate matching
|
||||
@@ -44,6 +53,16 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
// Audio focus
|
||||
private var audioFocusManager: AudioFocusManager? = null
|
||||
@Volatile private var cachedPaused: Boolean = true
|
||||
@Volatile private var pausedForSurfaceLoss: Boolean = false
|
||||
@Volatile private var hasAttachedSurface: Boolean = false
|
||||
@Volatile private var attachedToPlaceholder: Boolean = false
|
||||
@Volatile private var videoOutputRestoring: Boolean = false
|
||||
@Volatile private var deferredResumeRequested: Boolean = false
|
||||
@Volatile private var resumeBlockedByPublicPause: Boolean = false
|
||||
@Volatile private var videoOutputEpoch: Long = 0L
|
||||
private val videoOutputMutex = Mutex()
|
||||
private var pendingVideoOutputDisableJob: Job? = null
|
||||
private var pendingVideoOutputRefreshJob: Job? = null
|
||||
|
||||
private var flutterOverlayApplied = false
|
||||
|
||||
@@ -63,6 +82,14 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePlaceholderSurface() {
|
||||
if (placeholderSurface?.isValid == true) return
|
||||
placeholderImageReader?.close()
|
||||
placeholderImageReader = ImageReader.newInstance(1, 1, PixelFormat.RGBA_8888, 2)
|
||||
placeholderSurface = placeholderImageReader?.surface
|
||||
Log.d(TAG, "Created MPV placeholder surface")
|
||||
}
|
||||
|
||||
fun initialize(onResult: (Boolean) -> Unit) {
|
||||
if (isInitialized) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
@@ -73,7 +100,21 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
try {
|
||||
disposing = false
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
pendingSurface = null
|
||||
attachedSurface = null
|
||||
attachedToPlaceholder = false
|
||||
hasAttachedSurface = false
|
||||
videoOutputRestoring = false
|
||||
deferredResumeRequested = false
|
||||
resumeBlockedByPublicPause = false
|
||||
videoOutputEpoch = 0L
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
pendingVideoOutputDisableJob = null
|
||||
lastAppliedSurfaceSize = null
|
||||
lastKnownSurfaceWidth = 0
|
||||
lastKnownSurfaceHeight = 0
|
||||
ensurePlaceholderSurface()
|
||||
|
||||
// Initialize audio focus handling
|
||||
audioFocusManager = AudioFocusManager(
|
||||
@@ -86,10 +127,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
}
|
||||
},
|
||||
onResume = {
|
||||
scope.launch {
|
||||
try { player?.setProperty("pause", false) }
|
||||
catch (e: Exception) { Log.w(TAG, "Failed to resume after focus gain", e) }
|
||||
}
|
||||
requestAutoResume("audio focus gain")
|
||||
},
|
||||
isPaused = { cachedPaused }
|
||||
)
|
||||
@@ -97,16 +135,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
activity = activity,
|
||||
handler = handler,
|
||||
onDisplayChanged = {
|
||||
scope.launch {
|
||||
try {
|
||||
if (player?.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Display changed, resuming playback")
|
||||
player?.setProperty("pause", false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resume after display change", e)
|
||||
}
|
||||
}
|
||||
requestAutoResume("display change")
|
||||
}
|
||||
)
|
||||
|
||||
@@ -176,9 +205,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
player = p
|
||||
isInitialized = true
|
||||
|
||||
// Attach pending surface
|
||||
pendingSurface?.takeIf { it.isValid }?.let { attachSurfaceInternal(it) }
|
||||
pendingSurface = null
|
||||
refreshVideoOutput("initialize")
|
||||
|
||||
// Start collecting events/properties/logs
|
||||
collectEvents(p)
|
||||
@@ -263,66 +290,299 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
if (disposing) return
|
||||
|
||||
val surface = holder.surface
|
||||
pendingSurface = surface.takeIf { it.isValid }
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
videoOutputEpoch += 1L
|
||||
rememberCurrentSurfaceSize()
|
||||
if (player == null) {
|
||||
pendingSurface = surface
|
||||
Log.d(TAG, "Deferring surface attach until MPV init completes")
|
||||
Log.d(TAG, "Deferring video output refresh until MPV init completes")
|
||||
return
|
||||
}
|
||||
|
||||
attachSurfaceInternal(surface)
|
||||
flutterOverlayApplied = false
|
||||
ensureFlutterOverlayOnTop()
|
||||
refreshVideoOutput("surfaceCreated")
|
||||
}
|
||||
|
||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||
Log.d(TAG, "Surface changed: ${width}x${height}")
|
||||
applySurfaceSize(width, height)
|
||||
rememberSurfaceSize(width, height)
|
||||
refreshVideoOutput("surfaceChanged")
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
Log.d(TAG, "Surface destroyed")
|
||||
pendingSurface = null
|
||||
if (player == null || disposing) return
|
||||
detachSurfaceInternal()
|
||||
detachSurfaceInternal(reason = "surfaceDestroyed")
|
||||
}
|
||||
|
||||
private fun attachSurfaceInternal(surface: Surface) {
|
||||
val p = player ?: return
|
||||
if (disposing || !surface.isValid) return
|
||||
try {
|
||||
p.attachSurface(surface)
|
||||
scope.launch {
|
||||
p.setProperty("force-window", "yes")
|
||||
p.setProperty("vo", "gpu")
|
||||
private fun rememberSurfaceSize(width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
lastKnownSurfaceWidth = width
|
||||
lastKnownSurfaceHeight = height
|
||||
}
|
||||
|
||||
private fun rememberCurrentSurfaceSize() {
|
||||
val sv = surfaceView ?: return
|
||||
rememberSurfaceSize(sv.width, sv.height)
|
||||
}
|
||||
|
||||
private fun currentCandidateSurface(): Surface? =
|
||||
surfaceView?.holder?.surface?.takeIf { it.isValid }
|
||||
?: pendingSurface?.takeIf { it.isValid }
|
||||
|
||||
private fun hasAttachedRealSurface(): Boolean =
|
||||
hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)
|
||||
|
||||
private fun hasReadyVideoOutput(): Boolean =
|
||||
hasAttachedRealSurface() && !videoOutputRestoring
|
||||
|
||||
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean =
|
||||
!disposing && epoch == videoOutputEpoch
|
||||
|
||||
private fun isVideoOutputRefreshCurrent(epoch: Long): Boolean {
|
||||
if (disposing) return false
|
||||
if (epoch != videoOutputEpoch) return false
|
||||
return hasAttachedRealSurface()
|
||||
}
|
||||
|
||||
private fun refreshVideoOutput(reason: String) {
|
||||
if (disposing) return
|
||||
|
||||
rememberCurrentSurfaceSize()
|
||||
val p = player
|
||||
val surface = currentCandidateSurface()
|
||||
if (p == null) {
|
||||
pendingSurface = surface?.takeIf { it.isValid }
|
||||
Log.d(TAG, "refreshVideoOutput($reason): player not ready yet")
|
||||
return
|
||||
}
|
||||
|
||||
if (surface == null || !surface.isValid) {
|
||||
hasAttachedSurface = false
|
||||
attachedSurface = null
|
||||
attachedToPlaceholder = false
|
||||
pendingSurface = null
|
||||
lastAppliedSurfaceSize = null
|
||||
videoOutputRestoring = true
|
||||
Log.d(TAG, "refreshVideoOutput($reason): no valid surface available")
|
||||
return
|
||||
}
|
||||
|
||||
val refreshEpoch = videoOutputEpoch
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
videoOutputRestoring = true
|
||||
flutterOverlayApplied = false
|
||||
ensureFlutterOverlayOnTop()
|
||||
Log.d(TAG, "refreshVideoOutput($reason): scheduling async refresh (epoch=$refreshEpoch)")
|
||||
pendingVideoOutputRefreshJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
videoOutputMutex.withLock {
|
||||
if (!isCurrentVideoOutputEpoch(refreshEpoch)) {
|
||||
Log.d(TAG, "Skipping stale MPV video output refresh ($reason, epoch=$refreshEpoch)")
|
||||
return@withLock
|
||||
}
|
||||
if (!surface.isValid) {
|
||||
hasAttachedSurface = false
|
||||
attachedSurface = null
|
||||
attachedToPlaceholder = false
|
||||
pendingSurface = null
|
||||
lastAppliedSurfaceSize = null
|
||||
videoOutputRestoring = true
|
||||
Log.d(TAG, "Skipping MPV video output refresh with invalid surface ($reason, epoch=$refreshEpoch)")
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val needsAttach = !hasAttachedSurface || attachedSurface !== surface
|
||||
val wasAttachedToPlaceholder = attachedToPlaceholder
|
||||
val wasPausedForSurfaceLoss = pausedForSurfaceLoss
|
||||
if (needsAttach) {
|
||||
p.attachSurface(surface)
|
||||
attachedSurface = surface
|
||||
hasAttachedSurface = true
|
||||
attachedToPlaceholder = false
|
||||
pendingSurface = null
|
||||
Log.d(TAG, "refreshVideoOutput($reason): attached surface")
|
||||
} else {
|
||||
Log.d(TAG, "refreshVideoOutput($reason): surface already attached, refreshing surface state")
|
||||
}
|
||||
|
||||
if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
|
||||
Log.d(TAG, "Skipping stale MPV video output refresh after attach ($reason, epoch=$refreshEpoch)")
|
||||
return@withLock
|
||||
}
|
||||
applySurfaceSizeInternal(p, force = true)
|
||||
if (!isVideoOutputRefreshCurrent(refreshEpoch)) {
|
||||
Log.d(TAG, "Skipping stale MPV video output refresh after surface size ($reason, epoch=$refreshEpoch)")
|
||||
return@withLock
|
||||
}
|
||||
videoOutputRestoring = false
|
||||
applyDeferredResumeIfNeeded(p, reason)
|
||||
if (wasPausedForSurfaceLoss) {
|
||||
pausedForSurfaceLoss = false
|
||||
Log.d(TAG, "Cleared surface-loss pause after $reason")
|
||||
}
|
||||
if (wasAttachedToPlaceholder) {
|
||||
Log.d(TAG, "Restored MPV real surface after placeholder ($reason)")
|
||||
}
|
||||
Log.d(TAG, "Video output ready after $reason")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled pending MPV video output refresh ($reason, epoch=$refreshEpoch)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to finalize MPV video output refresh ($reason)", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to attach MPV surface", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySurfaceSize(width: Int, height: Int) {
|
||||
val p = player ?: return
|
||||
if (disposing || width <= 0 || height <= 0) return
|
||||
val size = "${width}x${height}"
|
||||
if (size == lastSurfaceSize) return
|
||||
lastSurfaceSize = size
|
||||
rememberSurfaceSize(width, height)
|
||||
if (!hasReadyVideoOutput()) return
|
||||
scope.launch {
|
||||
try { p.setProperty("android-surface-size", size) }
|
||||
try { applySurfaceSizeInternal(p) }
|
||||
catch (e: Exception) { Log.w(TAG, "Failed to apply surface size to MPV", e) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun detachSurfaceInternal() {
|
||||
lastSurfaceSize = null
|
||||
val p = player ?: return
|
||||
try {
|
||||
scope.launch {
|
||||
p.setProperty("vo", "null")
|
||||
p.setProperty("force-window", "no")
|
||||
private suspend fun applySurfaceSizeInternal(p: MpvPlayer, force: Boolean = false) {
|
||||
if (disposing) return
|
||||
val width = lastKnownSurfaceWidth
|
||||
val height = lastKnownSurfaceHeight
|
||||
if (width <= 0 || height <= 0) return
|
||||
|
||||
val size = "${width}x${height}"
|
||||
if (!force && size == lastAppliedSurfaceSize) return
|
||||
p.setProperty("android-surface-size", size)
|
||||
lastAppliedSurfaceSize = size
|
||||
Log.d(TAG, "Applied MPV surface size $size${if (force) " (forced)" else ""}")
|
||||
}
|
||||
|
||||
private fun schedulePlaceholderSurfaceAttach(
|
||||
p: MpvPlayer,
|
||||
reason: String,
|
||||
epoch: Long
|
||||
) {
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
pendingVideoOutputDisableJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
videoOutputMutex.withLock {
|
||||
if (!isCurrentVideoOutputEpoch(epoch)) {
|
||||
Log.d(TAG, "Skipping stale MPV placeholder attach ($reason, epoch=$epoch)")
|
||||
return@withLock
|
||||
}
|
||||
val wasPaused = try {
|
||||
p.getFlag("pause") == true
|
||||
} catch (e: Exception) {
|
||||
cachedPaused
|
||||
}
|
||||
if (!wasPaused) {
|
||||
try {
|
||||
p.setProperty("pause", true)
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = true
|
||||
Log.d(TAG, "Paused MPV for surface loss ($reason, epoch=$epoch)")
|
||||
} catch (e: Exception) {
|
||||
pausedForSurfaceLoss = false
|
||||
Log.w(TAG, "Failed to pause MPV before placeholder attach ($reason)", e)
|
||||
}
|
||||
} else {
|
||||
pausedForSurfaceLoss = false
|
||||
}
|
||||
val surface = placeholderSurface?.takeIf { it.isValid } ?: run {
|
||||
Log.w(TAG, "No valid MPV placeholder surface available for $reason")
|
||||
return@withLock
|
||||
}
|
||||
p.attachSurface(surface)
|
||||
attachedSurface = surface
|
||||
hasAttachedSurface = true
|
||||
attachedToPlaceholder = true
|
||||
lastAppliedSurfaceSize = null
|
||||
Log.d(TAG, "Attached MPV placeholder surface ($reason, epoch=$epoch)")
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
Log.d(TAG, "Canceled pending MPV placeholder attach ($reason, epoch=$epoch)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to attach MPV placeholder surface ($reason)", e)
|
||||
}
|
||||
p.detachSurface()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to detach MPV surface", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun detachSurfaceInternal(reason: String) {
|
||||
val hadAttachedSurface = hasAttachedSurface || attachedSurface != null
|
||||
hasAttachedSurface = false
|
||||
attachedSurface = null
|
||||
attachedToPlaceholder = false
|
||||
videoOutputRestoring = true
|
||||
lastAppliedSurfaceSize = null
|
||||
val detachEpoch = videoOutputEpoch + 1L
|
||||
videoOutputEpoch = detachEpoch
|
||||
|
||||
val p = player ?: return
|
||||
if (!hadAttachedSurface) {
|
||||
Log.d(TAG, "detachSurfaceInternal($reason): no attached surface to clear")
|
||||
return
|
||||
}
|
||||
|
||||
schedulePlaceholderSurfaceAttach(
|
||||
p = p,
|
||||
reason = reason,
|
||||
epoch = detachEpoch
|
||||
)
|
||||
Log.d(TAG, "Cleared MPV surface attachment ($reason, epoch=$detachEpoch)")
|
||||
}
|
||||
|
||||
private fun normalizePauseValue(value: String): Boolean? = when (value.lowercase()) {
|
||||
"yes", "true", "1" -> true
|
||||
"no", "false", "0" -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun requestAutoResume(reason: String) {
|
||||
val p = player ?: return
|
||||
if (disposing) return
|
||||
|
||||
if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is explicitly paused")
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring auto-resume after $reason until video output is ready")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
if (p.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Auto-resuming playback after $reason")
|
||||
p.setProperty("pause", false)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping auto-resume after $reason because playback is already running")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to resume after $reason", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyDeferredResumeIfNeeded(p: MpvPlayer, reason: String) {
|
||||
if (!deferredResumeRequested) return
|
||||
|
||||
if (resumeBlockedByPublicPause) {
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Dropping deferred auto-resume after $reason because playback is explicitly paused")
|
||||
return
|
||||
}
|
||||
|
||||
deferredResumeRequested = false
|
||||
if (p.getFlag("pause") == true) {
|
||||
Log.d(TAG, "Applying deferred auto-resume after $reason")
|
||||
p.setProperty("pause", false)
|
||||
} else {
|
||||
Log.d(TAG, "Skipping deferred auto-resume after $reason because playback is already running")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +590,26 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
|
||||
fun setProperty(name: String, value: String) {
|
||||
if (!isInitialized || disposing) return
|
||||
if (name == "pause") {
|
||||
val paused = normalizePauseValue(value)
|
||||
if (paused == true) {
|
||||
cachedPaused = true
|
||||
pausedForSurfaceLoss = false
|
||||
resumeBlockedByPublicPause = true
|
||||
deferredResumeRequested = false
|
||||
Log.d(TAG, "Public pause state updated: paused=true")
|
||||
} else if (paused == false) {
|
||||
resumeBlockedByPublicPause = false
|
||||
if (!hasReadyVideoOutput()) {
|
||||
deferredResumeRequested = true
|
||||
Log.d(TAG, "Deferring public resume until video output is ready")
|
||||
return
|
||||
}
|
||||
cachedPaused = false
|
||||
pausedForSurfaceLoss = false
|
||||
Log.d(TAG, "Public pause state updated: paused=false")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try { player?.setProperty(name, value) }
|
||||
catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) }
|
||||
@@ -370,6 +650,21 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
activity.runOnUiThread {
|
||||
if (disposing) return@runOnUiThread
|
||||
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
|
||||
if (visible) {
|
||||
flutterOverlayApplied = false
|
||||
ensureFlutterOverlayOnTop()
|
||||
rememberCurrentSurfaceSize()
|
||||
val surface = currentCandidateSurface()
|
||||
if (surface != null) {
|
||||
pendingSurface = surface
|
||||
refreshVideoOutput("setVisible")
|
||||
} else {
|
||||
val sv = surfaceView
|
||||
if (sv != null) {
|
||||
applySurfaceSize(sv.width, sv.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "setVisible($visible)")
|
||||
}
|
||||
}
|
||||
@@ -378,6 +673,38 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
// MPV handles aspect ratio internally via its own surface management
|
||||
}
|
||||
|
||||
fun updateFrame() {
|
||||
if (disposing) return
|
||||
activity.runOnUiThread {
|
||||
if (disposing) return@runOnUiThread
|
||||
flutterOverlayApplied = false
|
||||
ensureFlutterOverlayOnTop()
|
||||
rememberCurrentSurfaceSize()
|
||||
val p = player
|
||||
if (p == null) {
|
||||
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
|
||||
return@runOnUiThread
|
||||
}
|
||||
if (!hasReadyVideoOutput()) {
|
||||
val surface = currentCandidateSurface()
|
||||
if (surface != null) {
|
||||
pendingSurface = surface
|
||||
refreshVideoOutput("updateFrame")
|
||||
} else {
|
||||
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
|
||||
}
|
||||
return@runOnUiThread
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
applySurfaceSizeInternal(p, force = true)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to update Android MPV surface frame", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(fps: Float, videoDurationMs: Long) {
|
||||
@@ -409,13 +736,27 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
|
||||
// Cancel all coroutines
|
||||
scope.cancel()
|
||||
pendingVideoOutputDisableJob?.cancel()
|
||||
pendingVideoOutputDisableJob = null
|
||||
pendingVideoOutputRefreshJob?.cancel()
|
||||
pendingVideoOutputRefreshJob = null
|
||||
|
||||
// Detach surface from MPV BEFORE removing views to prevent GPU mutex contention
|
||||
val p = player
|
||||
if (p != null) {
|
||||
try {
|
||||
runBlocking(Dispatchers.IO) { p.setProperty("vo", "null") }
|
||||
runBlocking(Dispatchers.IO) {
|
||||
p.setProperty("force-window", "no")
|
||||
p.setProperty("vo", "null")
|
||||
}
|
||||
p.detachSurface()
|
||||
hasAttachedSurface = false
|
||||
attachedSurface = null
|
||||
pausedForSurfaceLoss = false
|
||||
attachedToPlaceholder = false
|
||||
videoOutputRestoring = false
|
||||
lastAppliedSurfaceSize = null
|
||||
videoOutputEpoch += 1L
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to detach surface during dispose", e)
|
||||
}
|
||||
@@ -436,6 +777,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
|
||||
overlayLayoutListener = null
|
||||
|
||||
pendingSurface = null
|
||||
placeholderSurface?.release()
|
||||
placeholderSurface = null
|
||||
placeholderImageReader?.close()
|
||||
placeholderImageReader = null
|
||||
pausedForSurfaceLoss = false
|
||||
attachedToPlaceholder = false
|
||||
videoOutputRestoring = false
|
||||
deferredResumeRequested = false
|
||||
resumeBlockedByPublicPause = false
|
||||
videoOutputEpoch = 0L
|
||||
pendingVideoOutputDisableJob = null
|
||||
isInitialized = false
|
||||
|
||||
// Close player on background thread, then remove views
|
||||
|
||||
@@ -98,6 +98,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
"observeProperty" -> handleObserveProperty(call, result)
|
||||
"command" -> handleCommand(call, result)
|
||||
"setVisible" -> handleSetVisible(call, result)
|
||||
"updateFrame" -> handleUpdateFrame(result)
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
@@ -230,6 +231,11 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
||||
playerCore?.updateFrame()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
|
||||
@@ -387,6 +387,12 @@ class PlayerAndroid extends PlayerBase {
|
||||
await invoke('clearVideoFrameRate');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
if (disposed || !initialized) return;
|
||||
await invoke('updateFrame');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Audio Focus
|
||||
// ============================================
|
||||
|
||||
@@ -266,7 +266,7 @@ class PlayerNative extends PlayerBase {
|
||||
@override
|
||||
Future<void> updateFrame() async {
|
||||
if (disposed || !initialized) return;
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
|
||||
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS || Platform.isLinux) {
|
||||
await invoke('updateFrame');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _hiddenForBackground = false;
|
||||
bool _autoPipEnabled = false;
|
||||
int _rewindOnResume = 0;
|
||||
Future<void> _lifecycleTransition = Future<void>.value();
|
||||
|
||||
/// Whether to skip lifecycle actions because PiP is active or about to start.
|
||||
/// iOS auto-PiP is system-initiated during the background transition, so
|
||||
@@ -342,24 +343,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Don't pause - user may still be watching
|
||||
break;
|
||||
case AppLifecycleState.hidden:
|
||||
if (_shouldSkipForPip) break;
|
||||
// Pause video on mobile (we don't support background playback)
|
||||
if (PlatformDetector.isMobile(context)) {
|
||||
if (player != null && _isPlayerInitialized) {
|
||||
_wasPlayingBeforeInactive = player!.state.playing;
|
||||
if (_wasPlayingBeforeInactive) {
|
||||
player!.pause();
|
||||
appLogger.d('Video paused due to app being hidden (mobile)');
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hide render layer to stop Vulkan present loop and gate native events
|
||||
if (player != null && _isPlayerInitialized) {
|
||||
player!.setVisible(false);
|
||||
_hiddenForBackground = true;
|
||||
_liveTimelineTimer?.cancel();
|
||||
appLogger.d('Render layer hidden due to app being hidden');
|
||||
}
|
||||
_enqueueLifecycleTransition('hidden', _handleAppHidden);
|
||||
break;
|
||||
case AppLifecycleState.paused:
|
||||
if (_shouldSkipForPip) break;
|
||||
@@ -371,19 +355,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
appLogger.d('Media controls cleared and wakelock disabled due to app being paused/backgrounded');
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
// Restore render layer if it was hidden for background
|
||||
if (_hiddenForBackground && player != null && _isPlayerInitialized) {
|
||||
player!.setVisible(true);
|
||||
_hiddenForBackground = false;
|
||||
if (_liveSessionIdentifier != null) {
|
||||
_startLiveTimelineUpdates();
|
||||
}
|
||||
appLogger.d('Render layer restored after app resumed');
|
||||
}
|
||||
// Restore media controls and wakelock when app is resumed
|
||||
if (_isPlayerInitialized && mounted) {
|
||||
unawaited(_restoreMediaControlsAfterResume());
|
||||
}
|
||||
_enqueueLifecycleTransition('resumed', _handleAppResumed);
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
// No action needed for this state
|
||||
@@ -391,6 +363,73 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
void _enqueueLifecycleTransition(String label, Future<void> Function() transition) {
|
||||
_lifecycleTransition = _lifecycleTransition
|
||||
.catchError((Object error, StackTrace stackTrace) {
|
||||
appLogger.w('Previous lifecycle transition failed', error: error, stackTrace: stackTrace);
|
||||
})
|
||||
.then((_) async {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
await transition();
|
||||
} catch (e, stackTrace) {
|
||||
appLogger.w('Lifecycle transition failed during $label', error: e, stackTrace: stackTrace);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleAppHidden() async {
|
||||
if (_shouldSkipForPip) return;
|
||||
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null || !_isPlayerInitialized) return;
|
||||
|
||||
// Pause first so Android MPV does not keep decoding against a transient
|
||||
// background surface while the app is locking or hiding.
|
||||
if (PlatformDetector.isMobile(context)) {
|
||||
_wasPlayingBeforeInactive = currentPlayer.state.playing;
|
||||
if (_wasPlayingBeforeInactive) {
|
||||
try {
|
||||
await currentPlayer.pause();
|
||||
appLogger.d('Video paused due to app being hidden (mobile)');
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to pause video before hiding render layer', error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
|
||||
_hiddenForBackground = true;
|
||||
_liveTimelineTimer?.cancel();
|
||||
await currentPlayer.setVisible(false);
|
||||
appLogger.d('Render layer hidden due to app being hidden');
|
||||
}
|
||||
|
||||
Future<void> _handleAppResumed() async {
|
||||
final currentPlayer = player;
|
||||
|
||||
// Restore render layer if it was hidden for background, then force a
|
||||
// video-output refresh before any auto-resume logic runs.
|
||||
if (_hiddenForBackground && currentPlayer != null && _isPlayerInitialized) {
|
||||
await currentPlayer.setVisible(true);
|
||||
await currentPlayer.updateFrame();
|
||||
|
||||
if (!mounted || currentPlayer != player) return;
|
||||
|
||||
_hiddenForBackground = false;
|
||||
if (_liveSessionIdentifier != null) {
|
||||
_startLiveTimelineUpdates();
|
||||
}
|
||||
appLogger.d('Render layer restored after app resumed');
|
||||
}
|
||||
|
||||
// Restore media controls and wakelock when app is resumed.
|
||||
if (_isPlayerInitialized && mounted) {
|
||||
await _restoreMediaControlsAfterResume();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializePlayer() async {
|
||||
try {
|
||||
// Load buffer size from settings
|
||||
@@ -597,6 +636,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (!widget.isOffline && !widget.isLive) {
|
||||
final serverId = widget.metadata.serverId;
|
||||
if (serverId != null) {
|
||||
if (!mounted) return;
|
||||
final serverManager = context.read<MultiServerProvider>().serverManager;
|
||||
bool wasOffline = false;
|
||||
_serverStatusSubscription = serverManager.statusStream.listen((statusMap) {
|
||||
@@ -1012,7 +1052,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
});
|
||||
_trackManager?.mediaInfo = null;
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to start live TV playback', error: e);
|
||||
_sendLiveTimeline('stopped');
|
||||
@@ -1075,8 +1114,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
|
||||
// Enable FFmpeg auto-reconnect for VOD streams (covers network drops up to 10 min)
|
||||
if (!widget.isOffline && !widget.isLive) {
|
||||
await player!.setProperty('stream-lavf-o',
|
||||
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600');
|
||||
await player!.setProperty(
|
||||
'stream-lavf-o',
|
||||
'reconnect=1,reconnect_on_network_error=1,reconnect_streamed=1,reconnect_delay_max=600',
|
||||
);
|
||||
}
|
||||
|
||||
final hasExternalSubs = result.externalSubtitles.isNotEmpty;
|
||||
@@ -1290,11 +1331,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
? mediaInfo?.subtitleTracks.where((t) => t.id == trackId).firstOrNull
|
||||
: null;
|
||||
|
||||
offlineSubtitles.add(SubtitleTrack.uri(
|
||||
'file://${entity.path}',
|
||||
title: plexTrack?.displayTitle ?? plexTrack?.language ?? 'Subtitle $fileName',
|
||||
language: plexTrack?.languageCode,
|
||||
));
|
||||
offlineSubtitles.add(
|
||||
SubtitleTrack.uri(
|
||||
'file://${entity.path}',
|
||||
title: plexTrack?.displayTitle ?? plexTrack?.language ?? 'Subtitle $fileName',
|
||||
language: plexTrack?.languageCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1562,13 +1605,19 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
receiver.onSeekForward = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
final target = clampSeekPosition(player!, player!.state.position + Duration(seconds: settings.getSeekTimeSmall()));
|
||||
final target = clampSeekPosition(
|
||||
player!,
|
||||
player!.state.position + Duration(seconds: settings.getSeekTimeSmall()),
|
||||
);
|
||||
await player!.seek(target);
|
||||
};
|
||||
receiver.onSeekBackward = () async {
|
||||
if (player == null) return;
|
||||
final settings = await SettingsService.getInstance();
|
||||
final target = clampSeekPosition(player!, player!.state.position - Duration(seconds: settings.getSeekTimeSmall()));
|
||||
final target = clampSeekPosition(
|
||||
player!,
|
||||
player!.state.position - Duration(seconds: settings.getSeekTimeSmall()),
|
||||
);
|
||||
await player!.seek(target);
|
||||
};
|
||||
receiver.onVolumeUp = () async {
|
||||
@@ -2124,8 +2173,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Future<void> _setLiveStreamOptions() async {
|
||||
final p = player!;
|
||||
// FFmpeg HTTP protocol reconnection
|
||||
await p.setProperty('stream-lavf-o',
|
||||
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=30');
|
||||
await p.setProperty(
|
||||
'stream-lavf-o',
|
||||
'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=30',
|
||||
);
|
||||
// Demuxer: retry up to 1000 times on stream reload failures
|
||||
await p.setProperty('demuxer-lavf-o', 'max_reload=1000');
|
||||
await p.setProperty('force-seekable', 'no');
|
||||
|
||||
Reference in New Issue
Block a user