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 23eaf21e..92da4487 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 @@ -446,11 +446,18 @@ class ExoPlayerPlugin : activity?.runOnUiThread { if (usingMpvFallback) { val selectFlag = if (select) "select" else "auto" - mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) + val core = mpvCore + if (core == null) { + result.success(null) + } else { + core.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) { + result.success(null) + } + } } else { playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) + result.success(null) } - result.success(null) } ?: result.success(null) } @@ -690,57 +697,7 @@ class ExoPlayerPlugin : * compatible with the performance overlay. */ private fun getMpvStats(): Map { - val mpv = mpvCore ?: return mapOf("playerType" to "mpv") - - val hasVideo = mpv.getProperty("video-params/w") != null - - val stats = mutableMapOf( - "playerType" to "mpv", - // Video metrics - "video-codec" to mpv.getProperty("video-codec"), - "video-params/w" to mpv.getProperty("video-params/w"), - "video-params/h" to mpv.getProperty("video-params/h"), - "videoWidth" to mpv.getProperty("dwidth"), - "videoHeight" to mpv.getProperty("dheight"), - "container-fps" to mpv.getProperty("container-fps"), - "estimated-vf-fps" to mpv.getProperty("estimated-vf-fps"), - "video-bitrate" to mpv.getProperty("video-bitrate"), - "hwdec-current" to mpv.getProperty("hwdec-current"), - // Audio metrics - "audio-codec-name" to mpv.getProperty("audio-codec-name"), - "audio-params/samplerate" to mpv.getProperty("audio-params/samplerate"), - "audio-params/hr-channels" to mpv.getProperty("audio-params/hr-channels"), - "audio-bitrate" to mpv.getProperty("audio-bitrate"), - // Performance metrics - "total-avsync-change" to mpv.getProperty("total-avsync-change"), - "cache-used" to mpv.getProperty("cache-used"), - "demuxer-max-bytes" to mpv.getProperty("demuxer-max-bytes"), - "cache-speed" to mpv.getProperty("cache-speed"), - "frame-drop-count" to mpv.getProperty("frame-drop-count"), - "decoder-frame-drop-count" to mpv.getProperty("decoder-frame-drop-count"), - "demuxer-cache-duration" to mpv.getProperty("demuxer-cache-duration") - ) - - // Only query properties that require an active video track - if (hasVideo) { - stats["display-fps"] = mpv.getProperty("display-fps") - // Color/Format properties - stats["video-params/pixelformat"] = mpv.getProperty("video-params/pixelformat") - stats["video-params/hw-pixelformat"] = mpv.getProperty("video-params/hw-pixelformat") - stats["video-params/colormatrix"] = mpv.getProperty("video-params/colormatrix") - stats["video-params/primaries"] = mpv.getProperty("video-params/primaries") - stats["video-params/gamma"] = mpv.getProperty("video-params/gamma") - // HDR metadata - stats["video-params/max-luma"] = mpv.getProperty("video-params/max-luma") - stats["video-params/min-luma"] = mpv.getProperty("video-params/min-luma") - stats["video-params/max-cll"] = mpv.getProperty("video-params/max-cll") - stats["video-params/max-fall"] = mpv.getProperty("video-params/max-fall") - // Other - stats["video-params/aspect-name"] = mpv.getProperty("video-params/aspect-name") - stats["video-params/rotate"] = mpv.getProperty("video-params/rotate") - } - - return stats + return mpvCore?.getStats() ?: mapOf("playerType" to "mpv") } // PiP Mode handling 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 fad44576..c81a7f90 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 @@ -76,6 +76,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { @Volatile private var videoOutputEpoch: Long = 0L private val videoOutputMutex = Mutex() + private val commandMutex = Mutex() private var pendingVideoOutputDisableJob: Job? = null private var pendingVideoOutputRefreshJob: Job? = null @@ -111,20 +112,29 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { return refreshRate.toString() } - private fun updateDisplayFpsOverride(p: MpvPlayer, reason: String) { + private fun updateDisplayFpsOverride(p: MpvPlayer, reason: String, onComplete: () -> Unit = {}) { val fps = currentDisplayFpsOverride() if (fps == null) { Log.d(TAG, "Skipping display-fps-override update ($reason): no display rate") + onComplete() + return + } + if (!scope.isActive) { + onComplete() return } - try { - runBlocking(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { + try { p.setProperty("display-fps-override", fps) + Log.d(TAG, "Updated display-fps-override=$fps ($reason)") + } catch (e: Exception) { + Log.w(TAG, "Failed to update display-fps-override ($reason)", e) + } finally { + withContext(NonCancellable + Dispatchers.Main) { + onComplete() + } } - Log.d(TAG, "Updated display-fps-override=$fps ($reason)") - } catch (e: Exception) { - Log.w(TAG, "Failed to update display-fps-override ($reason)", e) } } @@ -649,8 +659,11 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { // Public API - fun setProperty(name: String, value: String) { - if (!isInitialized || disposing) return + fun setProperty(name: String, value: String, onComplete: ((Boolean) -> Unit)? = null) { + if (!isInitialized || disposing || !scope.isActive) { + onComplete?.invoke(false) + return + } if (name == "pause") { val paused = normalizePauseValue(value) if (paused == true) { @@ -664,6 +677,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { if (!hasReadyVideoOutput()) { deferredResumeRequested = true Log.d(TAG, "Deferring public resume until video output is ready") + onComplete?.invoke(true) return } cachedPaused = false @@ -671,16 +685,30 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { Log.d(TAG, "Public pause state updated: paused=false") } } - scope.launch { + scope.launch(Dispatchers.IO) { + var success = false try { player?.setProperty(name, value) + success = true } catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) + } finally { + withContext(NonCancellable + Dispatchers.Main) { + onComplete?.invoke(success) + } } } } fun getProperty(name: String): String? { + if (Looper.myLooper() == Looper.getMainLooper()) { + Log.w(TAG, "Refusing synchronous getProperty($name) on the main thread") + return null + } + return getPropertyBlocking(name) + } + + private fun getPropertyBlocking(name: String): String? { if (!isInitialized || disposing) return null return try { runBlocking(Dispatchers.IO) { player?.getString(name) } @@ -689,6 +717,75 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { } } + fun getPropertyAsync(name: String, onResult: (String?) -> Unit) { + if (!isInitialized || disposing) { + onResult(null) + return + } + + Thread { + val value = getPropertyBlocking(name) + activity.runOnUiThread { + onResult(if (!disposing && isInitialized) value else null) + } + }.start() + } + + /** + * Returns MPV stats in the same key format used by the performance overlay. + * This method performs synchronous native property reads and must not be + * called on Android's main thread. + */ + fun getStats(): Map { + if (Looper.myLooper() == Looper.getMainLooper()) { + Log.w(TAG, "Refusing synchronous getStats() on the main thread") + return mapOf("playerType" to "mpv") + } + + val hasVideo = getProperty("video-params/w") != null + + val stats = mutableMapOf( + "playerType" to "mpv", + "video-codec" to getProperty("video-codec"), + "video-params/w" to getProperty("video-params/w"), + "video-params/h" to getProperty("video-params/h"), + "videoWidth" to getProperty("dwidth"), + "videoHeight" to getProperty("dheight"), + "container-fps" to getProperty("container-fps"), + "estimated-vf-fps" to getProperty("estimated-vf-fps"), + "video-bitrate" to getProperty("video-bitrate"), + "hwdec-current" to getProperty("hwdec-current"), + "audio-codec-name" to getProperty("audio-codec-name"), + "audio-params/samplerate" to getProperty("audio-params/samplerate"), + "audio-params/hr-channels" to getProperty("audio-params/hr-channels"), + "audio-bitrate" to getProperty("audio-bitrate"), + "total-avsync-change" to getProperty("total-avsync-change"), + "cache-used" to getProperty("cache-used"), + "demuxer-max-bytes" to getProperty("demuxer-max-bytes"), + "cache-speed" to getProperty("cache-speed"), + "frame-drop-count" to getProperty("frame-drop-count"), + "decoder-frame-drop-count" to getProperty("decoder-frame-drop-count"), + "demuxer-cache-duration" to getProperty("demuxer-cache-duration") + ) + + if (hasVideo) { + stats["display-fps"] = getProperty("display-fps") + stats["video-params/pixelformat"] = getProperty("video-params/pixelformat") + stats["video-params/hw-pixelformat"] = getProperty("video-params/hw-pixelformat") + stats["video-params/colormatrix"] = getProperty("video-params/colormatrix") + stats["video-params/primaries"] = getProperty("video-params/primaries") + stats["video-params/gamma"] = getProperty("video-params/gamma") + stats["video-params/max-luma"] = getProperty("video-params/max-luma") + stats["video-params/min-luma"] = getProperty("video-params/min-luma") + stats["video-params/max-cll"] = getProperty("video-params/max-cll") + stats["video-params/max-fall"] = getProperty("video-params/max-fall") + stats["video-params/aspect-name"] = getProperty("video-params/aspect-name") + stats["video-params/rotate"] = getProperty("video-params/rotate") + } + + return stats + } + fun observeProperty(name: String, format: String) { val p = player ?: return if (!isInitialized) return @@ -706,15 +803,19 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { onComplete?.invoke(false) return } - scope.launch { + scope.launch(Dispatchers.IO) { var success = false try { - player?.command(*args) - success = true + commandMutex.withLock { + player?.command(*args) + success = true + } } catch (e: Exception) { Log.w(TAG, "command failed", e) } finally { - onComplete?.invoke(success) + withContext(NonCancellable + Dispatchers.Main) { + onComplete?.invoke(success) + } } } } @@ -793,8 +894,11 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { return } mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs) { switched -> - player?.let { updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") } - onComplete(switched) + player?.let { + updateDisplayFpsOverride(it, "frame rate switch, switched=$switched") { + onComplete(switched) + } + } ?: onComplete(switched) } } 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 b1cb808d..45af9574 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 @@ -110,6 +110,7 @@ class MpvPlayerPlugin : "dispose" -> handleDispose(result) "setProperty" -> handleSetProperty(call, result) "getProperty" -> handleGetProperty(call, result) + "getStats" -> handleGetStats(result) "observeProperty" -> handleObserveProperty(call, result) "command" -> handleCommand(call, result) "setVisible" -> handleSetVisible(call, result) @@ -232,8 +233,15 @@ class MpvPlayerPlugin : return } - playerCore?.setProperty(name, value) - result.success(null) + val core = playerCore + if (core == null) { + result.success(null) + return + } + + core.setProperty(name, value) { + result.success(null) + } } private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) { @@ -244,8 +252,41 @@ class MpvPlayerPlugin : return } - val value = playerCore?.getProperty(name) - result.success(value) + val core = playerCore + if (core == null) { + result.success(null) + return + } + + val gen = sessionGeneration + core.getPropertyAsync(name) { value -> + if (gen != sessionGeneration || playerCore !== core) { + result.success(null) + } else { + result.success(value) + } + } + } + + private fun handleGetStats(result: MethodChannel.Result) { + val currentActivity = activity + val core = playerCore + if (currentActivity == null || core == null) { + result.success(mapOf("playerType" to "mpv")) + return + } + + val gen = sessionGeneration + Thread { + val stats = core.getStats() + currentActivity.runOnUiThread { + if (gen != sessionGeneration || playerCore !== core) { + result.success(mapOf("playerType" to "mpv")) + } else { + result.success(stats) + } + } + }.start() } private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 50c4b4af..b479d54d 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -32,6 +32,9 @@ class PlayerNative extends PlayerBase { @override String get playerType => 'mpv'; + @override + bool get providesNativeStats => Platform.isAndroid; + /// Node properties are returned as structured maps on macOS/iOS/Linux, /// but as JSON strings on Android/Windows. static final String _nodeFormat = (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'; @@ -294,6 +297,14 @@ class PlayerNative extends PlayerBase { return await invoke('getProperty', {'name': name}); } + @override + Future> getStats() async { + if (disposed || !Platform.isAndroid) return super.getStats(); + await _ensureInitialized(); + final result = await invoke('getStats'); + return Map.from(result ?? const {}); + } + @override Future command(List args) async { if (disposed) return;