From ad8faab9ea464188a05fc436597e41da030a669c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:07:28 +0200 Subject: [PATCH] fix: android video surface lifecycle management close #771 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 13 + .../plezy/exoplayer/ExoPlayerPlugin.kt | 10 + .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 456 ++++++++++++++++-- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 6 + lib/mpv/player/platform/player_android.dart | 6 + lib/mpv/player/player_native.dart | 2 +- lib/screens/video_player_screen.dart | 137 ++++-- 7 files changed, 534 insertions(+), 96 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 6accf04c..21c0a635 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 6330fd21..114be702 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 3a15388a..3e8ec21f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index 715dc5e4..cd556693 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -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("fps")?.toFloat() ?: 0f val duration = call.argument("duration")?.toLong() ?: 0L diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index df241b8a..6dd7bfb4 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -387,6 +387,12 @@ class PlayerAndroid extends PlayerBase { await invoke('clearVideoFrameRate'); } + @override + Future updateFrame() async { + if (disposed || !initialized) return; + await invoke('updateFrame'); + } + // ============================================ // Audio Focus // ============================================ diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index badfa3bc..96fb3506 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -266,7 +266,7 @@ class PlayerNative extends PlayerBase { @override Future 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'); } } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 69f46d67..17ad79ab 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -203,6 +203,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin bool _hiddenForBackground = false; bool _autoPipEnabled = false; int _rewindOnResume = 0; + Future _lifecycleTransition = Future.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 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 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 with WidgetsBindin } } + void _enqueueLifecycleTransition(String label, Future 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 _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 _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 _initializePlayer() async { try { // Load buffer size from settings @@ -597,6 +636,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!widget.isOffline && !widget.isLive) { final serverId = widget.metadata.serverId; if (serverId != null) { + if (!mounted) return; final serverManager = context.read().serverManager; bool wasOffline = false; _serverStatusSubscription = serverManager.statusStream.listen((statusMap) { @@ -1012,7 +1052,6 @@ class VideoPlayerScreenState extends State 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 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 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 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 with WidgetsBindin Future _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');