fix: android video surface lifecycle management

close #771
This commit is contained in:
edde746
2026-03-29 18:07:50 +02:00
parent 6a7644935f
commit ad8faab9ea
7 changed files with 534 additions and 96 deletions
@@ -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
// ============================================
+1 -1
View File
@@ -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');
}
}
+94 -43
View File
@@ -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');