From d355e07e109f2f937f0a7bdb38c810f55b6232f6 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:53:08 +0100 Subject: [PATCH] refactor: switch to libmpv-android fork, fix ANR and track selection --- android/app/build.gradle.kts | 21 +- .../plezy/exoplayer/ExoPlayerPlugin.kt | 142 +++-- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 522 +++++------------- .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 24 +- lib/mpv/player/platform/player_android.dart | 5 +- lib/mpv/player/player_base.dart | 33 +- 6 files changed, 277 insertions(+), 470 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2f4cd845..065644ff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,6 +8,21 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val mpvVersion = "v1.0.1" +val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile +val mpvAar = "libmpv-release.aar" + +val downloadLibmpv by tasks.registering { + val stamp = File(mpvDir, ".version") + outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion } + doLast { + mpvDir.mkdirs() + val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar" + exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) } + stamp.writeText(mpvVersion) + } +} + val assVersion = "safety" val assDir = layout.buildDirectory.dir("libass").get().asFile val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar") @@ -145,14 +160,16 @@ tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") dependsOn(downloadLibdovi) } -// Download libass AARs before compilation +// Download libmpv and libass AARs before compilation tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach { + dependsOn(downloadLibmpv) dependsOn(downloadLibass) } dependencies { - implementation("dev.jdtech.mpv:libmpv:0.5.1") + implementation(files(File(mpvDir, mpvAar))) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") // Android TV Watch Next integration implementation("androidx.tvprovider:tvprovider:1.0.0") 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 b8e89bd1..09137dcf 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 @@ -247,9 +247,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, val optionsStr = options.joinToString(",") // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri - mpvCore?.runOnExecutor { - mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) - } + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) } else { playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive, externalSubtitles) } @@ -260,7 +258,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handlePlay(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "no") } + mpvCore?.setProperty("pause", "no") } else { playerCore?.play() } @@ -271,7 +269,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handlePause(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "yes") } + mpvCore?.setProperty("pause", "yes") } else { playerCore?.pause() } @@ -282,10 +280,8 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleStop(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { - mpvCore?.command(arrayOf("stop")) - mpvCore?.setVisible(false) - } + mpvCore?.command(arrayOf("stop")) + mpvCore?.setVisible(false) } else { playerCore?.stop() } @@ -304,9 +300,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { val positionSeconds = positionMs / 1000.0 - mpvCore?.runOnExecutor { - mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) - } + mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) } else { playerCore?.seekTo(positionMs) } @@ -324,7 +318,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { mpvCore?.setProperty("volume", volume.toString()) } + mpvCore?.setProperty("volume", volume.toString()) } else { playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 } @@ -342,7 +336,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { mpvCore?.setProperty("speed", rate.toString()) } + mpvCore?.setProperty("speed", rate.toString()) } else { playerCore?.setPlaybackSpeed(rate) } @@ -360,9 +354,8 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { - // MPV uses numeric track IDs - extract from string format - val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 - mpvCore?.runOnExecutor { mpvCore?.setProperty("aid", numericId.toString()) } + // After fallback, track IDs come from mpv's track-list (already 1-indexed) + mpvCore?.setProperty("aid", trackId) } else { playerCore?.selectAudioTrack(trackId) } @@ -376,14 +369,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, // trackId can be null or "no" to disable subtitles activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.runOnExecutor { - if (trackId == null || trackId == "no") { - mpvCore?.setProperty("sid", "no") - } else { - val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 - mpvCore?.setProperty("sid", numericId.toString()) - } - } + mpvCore?.setProperty("sid", trackId ?: "no") } else { playerCore?.selectSubtitleTrack(trackId) } @@ -406,9 +392,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { val selectFlag = if (select) "select" else "auto" - mpvCore?.runOnExecutor { - mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) - } + mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) } else { playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) } @@ -517,7 +501,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } if (usingMpvFallback) { - mpvCore?.runOnExecutor { mpvCore?.setProperty(name, value) } + mpvCore?.setProperty(name, value) } else { // Store for later application if ExoPlayer falls back to MPV pendingMpvProperties.add(Pair(name, value)) @@ -527,10 +511,10 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleGetStats(result: MethodChannel.Result) { if (usingMpvFallback) { - mpvCore?.runOnExecutor { + Thread { val stats = getMpvStats() activity?.runOnUiThread { result.success(stats) } - } ?: result.success(mapOf("playerType" to "mpv")) + }.start() } else { activity?.runOnUiThread { val coreStats = playerCore?.getStats() ?: emptyMap() @@ -733,52 +717,52 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, fallbackInProgress = false return@initialize } - core.runOnExecutor { - // Configure basic MPV properties for Plex playback - core.setProperty("hwdec", "mediacodec,mediacodec-copy") - core.setProperty("vo", "gpu") - core.setProperty("ao", "audiotrack") + // Configure basic MPV properties for Plex playback + core.setProperty("hwdec", "mediacodec,mediacodec-copy") + core.setProperty("vo", "gpu") + core.setProperty("ao", "audiotrack") - // Forward user's buffer config to MPV fallback - if (bufferSize != null && bufferSize > 0) { - core.setProperty("demuxer-max-bytes", bufferSize.toString()) - } + // Forward user's buffer config to MPV fallback + if (bufferSize != null && bufferSize > 0) { + core.setProperty("demuxer-max-bytes", bufferSize.toString()) + } - // Apply pending MPV properties from Dart - for ((propName, propValue) in pendingProps) { - core.setProperty(propName, propValue) - } + // Apply pending MPV properties from Dart + for ((propName, propValue) in pendingProps) { + core.setProperty(propName, propValue) + } - // Setup property observers - core.observeProperty("time-pos", "double") - core.observeProperty("duration", "double") - core.observeProperty("seekable", "flag") - core.observeProperty("pause", "flag") - core.observeProperty("paused-for-cache", "flag") - core.observeProperty("demuxer-cache-time", "double") - core.observeProperty("eof-reached", "flag") - core.observeProperty("track-list", "string") - core.observeProperty("aid", "string") - core.observeProperty("sid", "string") - core.observeProperty("volume", "double") - core.observeProperty("speed", "double") + // Setup property observers + core.observeProperty("time-pos", "double") + core.observeProperty("duration", "double") + core.observeProperty("seekable", "flag") + core.observeProperty("pause", "flag") + core.observeProperty("paused-for-cache", "flag") + core.observeProperty("demuxer-cache-time", "double") + core.observeProperty("eof-reached", "flag") + core.observeProperty("track-list", "string") + core.observeProperty("aid", "string") + core.observeProperty("sid", "string") + core.observeProperty("volume", "double") + core.observeProperty("speed", "double") - // Show the MPV surface (internally posts to UI) - core.setVisible(true) + // Show the MPV surface (internally posts to UI) + core.setVisible(true) - // Load media at the same position - val startSeconds = positionMs / 1000.0 - val options = mutableListOf() - options.add("start=$startSeconds") - headers?.forEach { (key, value) -> - options.add("http-header-fields-append=$key: $value") - } - val optionsStr = options.joinToString(",") - core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + // Load media at the same position + val startSeconds = positionMs / 1000.0 + val options = mutableListOf() + options.add("start=$startSeconds") + headers?.forEach { (key, value) -> + options.add("http-header-fields-append=$key: $value") + } + val optionsStr = options.joinToString(",") + core.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) - // On GPUs without compute shaders, MPV can't do dynamic peak detection - // and spline tone-mapping produces dim/washed-out results with extreme - // static HDR peak metadata. Use reinhard which handles this better. + // On GPUs without compute shaders, MPV can't do dynamic peak detection + // and spline tone-mapping produces dim/washed-out results with extreme + // static HDR peak metadata. Use reinhard which handles this better. + Thread { val peakDetection = core.getProperty("hdr-compute-peak") if (peakDetection == "no") { Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") @@ -786,17 +770,17 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, core.setProperty("tone-mapping-param", "0.7") core.setProperty("tone-mapping-mode", "luma") } + }.start() - // Request audio focus - core.requestAudioFocus() + // Request audio focus + core.requestAudioFocus() - // Emit backend-switched event on main thread - activity?.runOnUiThread { - onEvent("backend-switched", null) - } - - Log.i(TAG, "Successfully switched to MPV fallback") + // Emit backend-switched event on main thread + activity?.runOnUiThread { + onEvent("backend-switched", null) } + + Log.i(TAG, "Successfully switched to MPV fallback") } } catch (e: Exception) { fallbackInProgress = false 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 1a144f2e..7a9d3d07 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 @@ -1,9 +1,7 @@ package com.edde746.plezy.mpv import android.app.Activity -import android.content.Context import android.graphics.Color -import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log @@ -17,41 +15,18 @@ import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.PlayerDelegate -import dev.jdtech.mpv.MPVLib -import io.flutter.plugin.common.MethodChannel -import java.util.concurrent.Executors +import dev.jdtech.mpv.* +import kotlinx.coroutines.* -class MpvPlayerCore(private val activity: Activity) : - SurfaceHolder.Callback, - MPVLib.EventObserver, - MPVLib.LogObserver { +class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback { companion object { private const val TAG = "MpvPlayerCore" - - // Guards MPVLib.create/destroy which share global native state - private val mpvLock = Object() - - // JNI's NewStringUTF may produce Java Strings with lone surrogates when - // mpv passes invalid UTF-8 (log messages, system-encoded paths, etc.). - // Lone surrogates cause FormatException in Flutter's StandardMessageCodec - // when it encodes them as UTF-8. Re-encoding strips these invalid chars. - private fun safeString(s: String): String { - for (c in s) { - if (c.isSurrogate()) { - // Slow path: re-encode through UTF-8 to replace lone surrogates with U+FFFD - return s.encodeToByteArray().decodeToString() - } - } - return s // Fast path: no surrogates, string is safe - } } private var surfaceView: SurfaceView? = null private var surfaceContainer: android.widget.FrameLayout? = null - private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = - null - @Volatile private var nativeReady: Boolean = false + private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null @Volatile private var disposing: Boolean = false private var pendingSurface: Surface? = null private var lastSurfaceSize: String? = null @@ -59,8 +34,8 @@ class MpvPlayerCore(private val activity: Activity) : var isInitialized: Boolean = false private set - // Executor for running MPV commands off the UI thread to prevent ANR - private val commandExecutor = Executors.newSingleThreadExecutor() + private var player: MpvPlayer? = null + private var scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) // Frame rate matching private var frameRateManager: FrameRateManager? = null @@ -103,19 +78,19 @@ class MpvPlayerCore(private val activity: Activity) : context = activity, handler = handler, onPause = { - if (isInitialized) { - try { MPVLib.setPropertyBoolean("pause", true) } + scope.launch { + try { player?.setProperty("pause", true) } catch (e: Exception) { Log.w(TAG, "Failed to pause on focus loss", e) } } }, onResume = { - if (isInitialized) { - try { MPVLib.setPropertyBoolean("pause", false) } + scope.launch { + try { player?.setProperty("pause", false) } catch (e: Exception) { Log.w(TAG, "Failed to resume after focus gain", e) } } }, isPaused = { - try { MPVLib.getPropertyBoolean("pause") == true } + try { runBlocking { player?.getFlag("pause") == true } } catch (e: Exception) { true } } ) @@ -123,20 +98,20 @@ class MpvPlayerCore(private val activity: Activity) : activity = activity, handler = handler, onDisplayChanged = { - try { - if (MPVLib.getPropertyBoolean("pause") == true) { - Log.d(TAG, "Display changed, resuming playback") - MPVLib.setPropertyBoolean("pause", false) + 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) } - } catch (e: Exception) { - Log.w(TAG, "Failed to resume after display change", e) } } ) - // Create FrameLayout container for video (matches ExoPlayer pattern) - // Setting visibility on container instead of SurfaceView directly allows - // the surface to be created even when hidden (required with RenderMode.texture) + // Create FrameLayout container for video surfaceContainer = android.widget.FrameLayout(activity).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -152,8 +127,6 @@ class MpvPlayerCore(private val activity: Activity) : android.widget.FrameLayout.LayoutParams.MATCH_PARENT ) holder.addCallback(this@MpvPlayerCore) - - // Critical: Ensure SurfaceView renders BEHIND Flutter's view setZOrderOnTop(false) setZOrderMediaOverlay(false) } @@ -170,11 +143,9 @@ class MpvPlayerCore(private val activity: Activity) : FlutterOverlayHelper.configureFlutterZOrder(contentView, container, zOrderOnTop = true) flutterOverlayApplied = true } - // Repeat after layout settles to catch late-added Flutter surfaces (release builds) ensureFlutterOverlayOnTop() overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { ensureFlutterOverlayOnTop() - // Re-apply surface size on layout change (orientation transitions) val sv = surfaceView if (sv != null) applySurfaceSize(sv.width, sv.height) } @@ -182,77 +153,99 @@ class MpvPlayerCore(private val activity: Activity) : Log.d(TAG, "SurfaceView added to content view") - // Native MPVLib init on background thread — waits for any - // in-flight destroy to finish without blocking the UI thread. - val ctx = activity.applicationContext - Thread { + // Create MpvPlayer on background thread via coroutine + scope.launch { try { - synchronized(mpvLock) { - if (disposing) { - handler.post { onResult(false) } - return@Thread - } - MPVLib.create(ctx) - setupMpvDefaults() - MPVLib.init() - nativeReady = true + if (disposing) { + onResult(false) + return@launch } - handler.post { - if (disposing) { - if (nativeReady) { - Thread { - synchronized(mpvLock) { - try { - MPVLib.destroy() - } catch (_: Exception) { - } finally { - nativeReady = false - } - } - }.start() - } - onResult(false) - return@post - } - - MPVLib.addObserver(this) - MPVLib.addLogObserver(this) - isInitialized = true - - // surfaceCreated can fire before MPV init finishes. - // Defer attaching the surface until native init is ready. - pendingSurface?.takeIf { it.isValid }?.let { - attachSurfaceInternal(it) - } - pendingSurface = null - - Log.d(TAG, "Initialized successfully") - onResult(true) + val p = MpvPlayer.create(activity.applicationContext) { + setOption("vo", "gpu") + setOption("gpu-context", "android") + setOption("opengl-es", "yes") + setOption("vd-lavc-film-grain", "cpu") + setOption("ao", "audiotrack,opensles") } + + if (disposing) { + p.close() + onResult(false) + return@launch + } + + player = p + isInitialized = true + + // Attach pending surface + pendingSurface?.takeIf { it.isValid }?.let { attachSurfaceInternal(it) } + pendingSurface = null + + // Start collecting events/properties/logs + collectEvents(p) + collectPropertyChanges(p) + collectLogMessages(p) + + Log.d(TAG, "Initialized successfully") + onResult(true) } catch (e: Exception) { Log.e(TAG, "Failed to initialize native: ${e.message}", e) - nativeReady = false - handler.post { onResult(false) } + onResult(false) } - }.start() + } } catch (e: Exception) { Log.e(TAG, "Failed to initialize: ${e.message}", e) onResult(false) } } - private fun setupMpvDefaults() { - // Video output configuration - MPVLib.setOptionString("vo", "gpu") - MPVLib.setOptionString("gpu-context", "android") - MPVLib.setOptionString("opengl-es", "yes") - // hwdec is set from Flutter via setProperty based on user preference + // Flow collectors - // Prevent crashes/artifacts from hardware film grain synthesis (mpv #14651) - MPVLib.setOptionString("vd-lavc-film-grain", "cpu") + private fun collectEvents(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.eventFlow.collect { event -> + when (event) { + is MpvEvent.EndFile -> { + val data = event.reason?.let { mapOf("reason" to it.id) } + delegate?.onEvent("end-file", data) + } + is MpvEvent.FileLoaded -> delegate?.onEvent("file-loaded", null) + is MpvEvent.PlaybackRestart -> delegate?.onEvent("playback-restart", null) + else -> {} + } + } + } + } - // Audio configuration - MPVLib.setOptionString("ao", "audiotrack,opensles") + private fun collectPropertyChanges(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.propertyFlow.collect { change -> + // Skip None — matches old MPVLib behavior where eventProperty(name) + // with no value was a no-op. Forwarding null would incorrectly clear + // track selections (aid/sid) before the file loads. + if (change is PropertyChange.None) return@collect + val value: Any? = when (change) { + is PropertyChange.Flag -> change.value + is PropertyChange.Int64 -> change.value + is PropertyChange.Double -> change.value + is PropertyChange.Str -> change.value + is PropertyChange.None -> null + } + delegate?.onPropertyChange(change.name, value) + } + } + } + + private fun collectLogMessages(p: MpvPlayer) { + scope.launch(start = CoroutineStart.UNDISPATCHED) { + p.logFlow.collect { msg -> + delegate?.onEvent("log-message", mapOf( + "prefix" to msg.prefix, + "level" to msg.level.name.lowercase(), + "text" to msg.text + )) + } + } } // Audio Focus @@ -268,14 +261,13 @@ class MpvPlayerCore(private val activity: Activity) : if (disposing) return val surface = holder.surface - if (!nativeReady) { + if (player == null) { pendingSurface = surface - Log.d(TAG, "Deferring surface attach until MPV native init completes") + Log.d(TAG, "Deferring surface attach until MPV init completes") return } attachSurfaceInternal(surface) - // Reassert overlay order whenever the surface is recreated flutterOverlayApplied = false ensureFlutterOverlayOnTop() } @@ -288,279 +280,86 @@ class MpvPlayerCore(private val activity: Activity) : override fun surfaceDestroyed(holder: SurfaceHolder) { Log.d(TAG, "Surface destroyed") pendingSurface = null - if (!nativeReady || disposing) return + if (player == null || disposing) return detachSurfaceInternal() } private fun attachSurfaceInternal(surface: Surface) { - if (!nativeReady || disposing || !surface.isValid) return + val p = player ?: return + if (disposing || !surface.isValid) return try { - MPVLib.attachSurface(surface) - MPVLib.setOptionString("force-window", "yes") - // Restore video output after surface is available - MPVLib.setPropertyString("vo", "gpu") + p.attachSurface(surface) + scope.launch { + p.setProperty("force-window", "yes") + p.setProperty("vo", "gpu") + } } catch (e: Exception) { Log.w(TAG, "Failed to attach MPV surface", e) } } private fun applySurfaceSize(width: Int, height: Int) { - if (!nativeReady || disposing || width <= 0 || height <= 0) return + val p = player ?: return + if (disposing || width <= 0 || height <= 0) return val size = "${width}x${height}" if (size == lastSurfaceSize) return lastSurfaceSize = size - try { - MPVLib.setPropertyString("android-surface-size", size) - } catch (e: Exception) { - Log.w(TAG, "Failed to apply surface size to MPV", e) + scope.launch { + try { p.setProperty("android-surface-size", size) } + catch (e: Exception) { Log.w(TAG, "Failed to apply surface size to MPV", e) } } } private fun detachSurfaceInternal() { lastSurfaceSize = null - if (!nativeReady) return - // Disable video output before detaching (like mpv-android) + val p = player ?: return try { - MPVLib.setPropertyString("vo", "null") - MPVLib.setOptionString("force-window", "no") - MPVLib.detachSurface() + scope.launch { + p.setProperty("vo", "null") + p.setProperty("force-window", "no") + } + p.detachSurface() } catch (e: Exception) { Log.w(TAG, "Failed to detach MPV surface", e) } } - // MPVLib.EventObserver - - override fun eventProperty(property: String) { - // No value provided - } - - override fun eventProperty(property: String, value: Long) { - activity.runOnUiThread { - delegate?.onPropertyChange(property, value) - } - } - - override fun eventProperty(property: String, value: Double) { - activity.runOnUiThread { - delegate?.onPropertyChange(property, value) - } - } - - override fun eventProperty(property: String, value: Boolean) { - activity.runOnUiThread { - delegate?.onPropertyChange(property, value) - } - } - - override fun eventProperty(property: String, value: String) { - val safe = safeString(value) - activity.runOnUiThread { - delegate?.onPropertyChange(property, safe) - } - } - - override fun event(eventId: Int) { - if (disposing) return - when (eventId) { - MPVLib.MPV_EVENT_END_FILE -> { - val eofReached = try { MPVLib.getPropertyBoolean("eof-reached") == true } catch (_: Exception) { false } - val data: Map? = if (eofReached) { - mapOf("reason" to 0) // EOF - } else { - null // Could be stop, quit, or error — no way to distinguish from JNI - } - activity.runOnUiThread { delegate?.onEvent("end-file", data) } - } - MPVLib.MPV_EVENT_FILE_LOADED -> { - activity.runOnUiThread { delegate?.onEvent("file-loaded", null) } - } - MPVLib.MPV_EVENT_PLAYBACK_RESTART -> { - activity.runOnUiThread { delegate?.onEvent("playback-restart", null) } - } - } - } - - // MPVLib.LogObserver - - override fun logMessage(prefix: String, level: Int, text: String) { - val levelStr = when (level) { - MPVLib.MPV_LOG_LEVEL_FATAL -> "fatal" - MPVLib.MPV_LOG_LEVEL_ERROR -> "error" - MPVLib.MPV_LOG_LEVEL_WARN -> "warn" - MPVLib.MPV_LOG_LEVEL_INFO -> "info" - MPVLib.MPV_LOG_LEVEL_V -> "v" - MPVLib.MPV_LOG_LEVEL_DEBUG -> "debug" - MPVLib.MPV_LOG_LEVEL_TRACE -> "trace" - else -> "info" - } - val safePrefix = safeString(prefix) - val safeText = safeString(text) - activity.runOnUiThread { - delegate?.onEvent("log-message", mapOf( - "prefix" to safePrefix, - "level" to levelStr, - "text" to safeText - )) - } - } - // Public API fun setProperty(name: String, value: String) { if (!isInitialized) return - MPVLib.setPropertyString(name, value) + scope.launch { + try { player?.setProperty(name, value) } + catch (e: Exception) { Log.w(TAG, "setProperty($name) failed", e) } + } } fun getProperty(name: String): String? { if (!isInitialized) return null return try { - MPVLib.getPropertyString(name) + runBlocking { player?.getString(name) } } catch (e: Exception) { null } } fun observeProperty(name: String, format: String) { + val p = player ?: return if (!isInitialized) return - - val mpvFormat = when (format) { - "double" -> MPVLib.MPV_FORMAT_DOUBLE - "flag" -> MPVLib.MPV_FORMAT_FLAG - "string" -> MPVLib.MPV_FORMAT_STRING - "node" -> MPVLib.MPV_FORMAT_NODE - else -> MPVLib.MPV_FORMAT_NONE - } - MPVLib.observeProperty(name, mpvFormat) - } - - /** - * Get an MPV property asynchronously off the UI thread. - * This prevents ANR when mpv_get_property blocks waiting for internal locks. - */ - fun getPropertyAsync(name: String, result: MethodChannel.Result) { - if (!isInitialized || disposing) { - result.success(null) - return - } - try { - commandExecutor.execute { - try { - val value = MPVLib.getPropertyString(name) - activity.runOnUiThread { result.success(value) } - } catch (e: Exception) { - activity.runOnUiThread { result.success(null) } - } - } - } catch (e: java.util.concurrent.RejectedExecutionException) { - Log.w(TAG, "getPropertyAsync rejected (executor shut down)") - result.success(null) - } - } - - /** - * Set an MPV property asynchronously off the UI thread. - * This prevents ANR when mpv_set_property blocks waiting for internal locks. - */ - fun setPropertyAsync(name: String, value: String, result: MethodChannel.Result) { - if (!isInitialized || disposing) { - result.success(null) - return - } - try { - commandExecutor.execute { - try { - MPVLib.setPropertyString(name, value) - activity.runOnUiThread { result.success(null) } - } catch (e: Exception) { - Log.e(TAG, "Async setProperty failed: ${e.message}", e) - activity.runOnUiThread { result.success(null) } - } - } - } catch (e: java.util.concurrent.RejectedExecutionException) { - Log.w(TAG, "setPropertyAsync rejected (executor shut down)") - result.success(null) - } - } - - /** - * Observe an MPV property asynchronously off the UI thread. - * This prevents ANR when mpv_observe_property blocks waiting for internal locks. - */ - fun observePropertyAsync(name: String, format: String, result: MethodChannel.Result) { - if (!isInitialized || disposing) { - result.success(null) - return - } - val mpvFormat = when (format) { - "double" -> MPVLib.MPV_FORMAT_DOUBLE - "flag" -> MPVLib.MPV_FORMAT_FLAG - "string" -> MPVLib.MPV_FORMAT_STRING - "node" -> MPVLib.MPV_FORMAT_NODE - else -> MPVLib.MPV_FORMAT_NONE - } - try { - commandExecutor.execute { - try { - MPVLib.observeProperty(name, mpvFormat) - activity.runOnUiThread { result.success(null) } - } catch (e: Exception) { - Log.e(TAG, "Async observeProperty failed: ${e.message}", e) - activity.runOnUiThread { result.success(null) } - } - } - } catch (e: java.util.concurrent.RejectedExecutionException) { - Log.w(TAG, "observePropertyAsync rejected (executor shut down)") - result.success(null) - } - } - - /** - * Run a block on the command executor (background thread). - * Used by the plugin for I/O operations that shouldn't block the main thread. - */ - fun runOnExecutor(block: () -> Unit) { - if (disposing) return - try { - commandExecutor.execute(block) - } catch (e: java.util.concurrent.RejectedExecutionException) { - Log.w(TAG, "runOnExecutor rejected (executor shut down)") + val fmt = when (format) { + "double" -> PropertyFormat.Double + "flag" -> PropertyFormat.Flag + "string" -> PropertyFormat.String + else -> PropertyFormat.None } + p.observeProperty(name, fmt) } fun command(args: Array) { if (!isInitialized || args.isEmpty()) return - MPVLib.command(args) - } - - /** - * Execute an MPV command asynchronously off the UI thread. - * This prevents ANR when commands like loadfile block waiting for network I/O. - * The result is called back on the UI thread when the command completes. - */ - fun commandAsync(args: Array, result: MethodChannel.Result) { - if (!isInitialized || disposing || args.isEmpty()) { - result.success(null) - return - } - - try { - commandExecutor.execute { - try { - MPVLib.command(args) - activity.runOnUiThread { - result.success(null) - } - } catch (e: Exception) { - Log.e(TAG, "Async command failed: ${e.message}", e) - activity.runOnUiThread { - result.error("COMMAND_FAILED", e.message, null) - } - } - } - } catch (e: java.util.concurrent.RejectedExecutionException) { - Log.w(TAG, "commandAsync rejected (executor shut down)") - result.success(null) + scope.launch { + try { player?.command(*args) } + catch (e: Exception) { Log.w(TAG, "command failed", e) } } } @@ -598,8 +397,6 @@ class MpvPlayerCore(private val activity: Activity) : check(Looper.myLooper() == Looper.getMainLooper()) Log.d(TAG, "Disposing") - // Shutdown command executor - commandExecutor.shutdown() handler.removeCallbacksAndMessages(null) // Clean up frame rate and audio focus @@ -608,22 +405,17 @@ class MpvPlayerCore(private val activity: Activity) : audioFocusManager?.release() audioFocusManager = null - if (nativeReady) { - try { - MPVLib.removeObserver(this) - MPVLib.removeLogObserver(this) - } catch (e: Exception) { - Log.w(TAG, "Failed to remove MPV observers during dispose", e) - } - } + // Detach surface before cancelling scope (needs scope.launch for property sets) + detachSurfaceInternal() + + // Cancel all coroutines + scope.cancel() // Capture locals for deferred cleanup val sv = surfaceView val container = surfaceContainer val contentView = activity.findViewById(android.R.id.content) - // Synchronous ownership invalidation — stale code can no longer - // reach surface state through instance fields. surfaceContainer = null surfaceView = null @@ -636,9 +428,7 @@ class MpvPlayerCore(private val activity: Activity) : pendingSurface = null isInitialized = false - // Deferred view removal only — uses captured locals. - // postAtFrontOfQueue as defense-in-depth: orders removal before - // queued initialize messages. + // Deferred view removal Handler(Looper.getMainLooper()).postAtFrontOfQueue { sv?.holder?.removeCallback(this) if (container?.parent != null) { @@ -646,30 +436,24 @@ class MpvPlayerCore(private val activity: Activity) : } } - // Run native destroy on background thread to avoid ANR — - // MPVLib.destroy() blocks on pthread_cond_wait while mpv's - // internal threads (lua, demux, vo) shut down. - // The completion callback is posted to the main thread so the - // caller (MpvPlayerPlugin) can defer result.success() until - // native resources are fully released, preventing a new mpv - // instance from being created while the old one still holds memory. - if (nativeReady) { + // Close player on background thread + val p = player + player = null + if (p != null) { Thread { - synchronized(mpvLock) { - try { - detachSurfaceInternal() - MPVLib.destroy() - } catch (e: Exception) { - Log.w(TAG, "MPV destroy failed", e) - } finally { - nativeReady = false - } + try { + p.close() + } catch (e: Exception) { + Log.w(TAG, "MPV close failed", e) } Log.d(TAG, "Disposed (native)") - onComplete?.let { Handler(Looper.getMainLooper()).post(it) } + Handler(Looper.getMainLooper()).post { onComplete?.invoke() } }.start() } else { onComplete?.invoke() } + + // Reset scope for potential re-initialization + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) } } 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 65e25e44..1eff1484 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 @@ -162,8 +162,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - playerCore?.setPropertyAsync(name, value, result) - ?: result.success(null) + playerCore?.setProperty(name, value) + result.success(null) } private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) { @@ -174,8 +174,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - playerCore?.getPropertyAsync(name, result) - ?: result.success(null) + val value = playerCore?.getProperty(name) + result.success(value) } private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) { @@ -189,8 +189,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } nameToId[name] = id - playerCore?.observePropertyAsync(name, format, result) - ?: result.success(null) + playerCore?.observeProperty(name, format) + result.success(null) } private fun handleCommand(call: MethodCall, result: MethodChannel.Result) { @@ -201,10 +201,8 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, return } - // Use async command to prevent ANR - command executes off UI thread - // and result is called back when complete - playerCore?.commandAsync(args.toTypedArray(), result) - ?: result.success(null) + playerCore?.command(args.toTypedArray()) + result.success(null) } private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) { @@ -260,7 +258,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } // Open file descriptor off UI thread to prevent ANR on slow storage - playerCore?.runOnExecutor { + Thread { try { val uri = Uri.parse(uriString) val pfd = contentResolver.openFileDescriptor(uri, "r") @@ -268,7 +266,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null) } - return@runOnExecutor + return@Thread } val fd = pfd.detachFd() @@ -278,7 +276,7 @@ class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Log.e(TAG, "Failed to open content FD: ${e.message}", e) activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) } } - } ?: result.error("NO_PLAYER", "Player not initialized", null) + }.start() } // PlayerDelegate diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 8731f512..d35b5a7e 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -39,7 +39,10 @@ class PlayerAndroid extends PlayerBase { void handlePlayerEvent(String name, Map? data) { // Handle Android-specific events if (name == 'backend-switched') { - // Native player switched from ExoPlayer to MPV due to unsupported format + // Native player switched from ExoPlayer to MPV due to unsupported format. + // Clear stale ExoPlayer tracks so applyTrackSelectionWhenReady waits for + // mpv's track-list instead of immediately applying with ExoPlayer IDs. + clearTracks(); backendSwitchedController.add(null); return; } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 1b983ec9..69a3a3f2 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -225,9 +225,18 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } } if (trackList != null) { - final tracks = parseTrackList(trackList); - _state = _state.copyWith(tracks: tracks); - tracksController.add(tracks); + final result = parseTrackList(trackList); + _state = _state.copyWith(tracks: result.tracks); + tracksController.add(result.tracks); + // Derive selection from mpv's "selected" field in the track data. + // This is the source of truth and handles cases where aid/sid + // values don't match track IDs (e.g. "auto", "0", "no"). + if (result.selectedAudioId != null) { + updateSelectedAudioTrack(result.selectedAudioId); + } + if (result.selectedSubtitleId != null) { + updateSelectedSubtitleTrack(result.selectedSubtitleId); + } } break; @@ -384,18 +393,22 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { }; } - /// Parse a track list from the platform into [Tracks]. - Tracks parseTrackList(List trackList) { + /// Parse a track list from the platform into [Tracks] and selected track IDs. + ({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) { final audioTracks = []; final subtitleTracks = []; + String? selectedAudioId; + String? selectedSubtitleId; for (final track in trackList) { if (track is! Map) continue; final type = track['type'] as String?; final id = track['id']?.toString() ?? ''; + final selected = track['selected'] as bool? ?? false; if (type == 'audio') { + if (selected) selectedAudioId = id; audioTracks.add( AudioTrack( id: id, @@ -408,6 +421,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { ), ); } else if (type == 'sub') { + if (selected) selectedSubtitleId = id; subtitleTracks.add( SubtitleTrack( id: id, @@ -423,7 +437,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { } } - return Tracks(audio: audioTracks, subtitle: subtitleTracks); + return (tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks), selectedAudioId: selectedAudioId, selectedSubtitleId: selectedSubtitleId); } /// Update the selected audio track. @@ -472,6 +486,13 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { _state = update(_state); } + @protected + void clearTracks() { + const empty = Tracks(); + _state = _state.copyWith(tracks: empty, track: const TrackSelection()); + tracksController.add(empty); + } + @protected void setSeekable(bool seekable) { if (_state.seekable == seekable) return;