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