diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index e85f0c24..8b0981cd 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -265,11 +265,11 @@ class MainActivity : FlutterActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean,newConfig: Configuration) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - MethodChannel( flutterEngine!!.dartExecutor.binaryMessenger, PIP_CHANNEL ).invokeMethod( "onPipChanged" , isInPictureInPictureMode) - - // Notify ExoPlayer plugin to resize video surface for PiP - flutterEngine?.plugins?.get(ExoPlayerPlugin::class.java)?.let { plugin -> - (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) + flutterEngine?.let { engine -> + MethodChannel(engine.dartExecutor.binaryMessenger, PIP_CHANNEL).invokeMethod("onPipChanged", isInPictureInPictureMode) + engine.plugins.get(ExoPlayerPlugin::class.java)?.let { plugin -> + (plugin as? ExoPlayerPlugin)?.onPipModeChanged(isInPictureInPictureMode) + } } } 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 44cbff88..1389ea23 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 @@ -224,7 +224,9 @@ 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?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + mpvCore?.runOnExecutor { + mpvCore?.command(arrayOf("loadfile", mpvUri, "replace", "-1", optionsStr)) + } } else { playerCore?.open(uri, headers, startPositionMs, autoPlay, isLive) } @@ -235,7 +237,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handlePlay(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.setProperty("pause", "no") + mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "no") } } else { playerCore?.play() } @@ -246,7 +248,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handlePause(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.setProperty("pause", "yes") + mpvCore?.runOnExecutor { mpvCore?.setProperty("pause", "yes") } } else { playerCore?.pause() } @@ -257,8 +259,10 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, private fun handleStop(result: MethodChannel.Result) { activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.command(arrayOf("stop")) - mpvCore?.setVisible(false) + mpvCore?.runOnExecutor { + mpvCore?.command(arrayOf("stop")) + mpvCore?.setVisible(false) + } } else { playerCore?.stop() } @@ -277,7 +281,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { val positionSeconds = positionMs / 1000.0 - mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + mpvCore?.runOnExecutor { + mpvCore?.command(arrayOf("seek", positionSeconds.toString(), "absolute")) + } } else { playerCore?.seekTo(positionMs) } @@ -295,7 +301,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.setProperty("volume", volume.toString()) + mpvCore?.runOnExecutor { mpvCore?.setProperty("volume", volume.toString()) } } else { playerCore?.setVolume(volume / 100f) // Convert 0-100 to 0-1 } @@ -313,7 +319,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { - mpvCore?.setProperty("speed", rate.toString()) + mpvCore?.runOnExecutor { mpvCore?.setProperty("speed", rate.toString()) } } else { playerCore?.setPlaybackSpeed(rate) } @@ -333,7 +339,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, if (usingMpvFallback) { // MPV uses numeric track IDs - extract from string format val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 - mpvCore?.setProperty("aid", numericId.toString()) + mpvCore?.runOnExecutor { mpvCore?.setProperty("aid", numericId.toString()) } } else { playerCore?.selectAudioTrack(trackId) } @@ -347,11 +353,13 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, // trackId can be null or "no" to disable subtitles activity?.runOnUiThread { if (usingMpvFallback) { - if (trackId == null || trackId == "no") { - mpvCore?.setProperty("sid", "no") - } else { - val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 - mpvCore?.setProperty("sid", numericId.toString()) + mpvCore?.runOnExecutor { + if (trackId == null || trackId == "no") { + mpvCore?.setProperty("sid", "no") + } else { + val numericId = trackId.split("_").lastOrNull()?.toIntOrNull() ?: 1 + mpvCore?.setProperty("sid", numericId.toString()) + } } } else { playerCore?.selectSubtitleTrack(trackId) @@ -375,7 +383,9 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, activity?.runOnUiThread { if (usingMpvFallback) { val selectFlag = if (select) "select" else "auto" - mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) + mpvCore?.runOnExecutor { + mpvCore?.command(arrayOf("sub-add", uri, selectFlag, title ?: "External")) + } } else { playerCore?.addSubtitleTrack(uri, title, language, mimeType, select) } @@ -484,7 +494,7 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } if (usingMpvFallback) { - mpvCore?.setProperty(name, value) + mpvCore?.runOnExecutor { mpvCore?.setProperty(name, value) } } else { // Store for later application if ExoPlayer falls back to MPV pendingMpvProperties.add(Pair(name, value)) @@ -493,16 +503,17 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, } private fun handleGetStats(result: MethodChannel.Result) { - activity?.runOnUiThread { - val stats = if (usingMpvFallback) { - // For MPV fallback, query MPV properties directly - getMpvStats() - } else { + if (usingMpvFallback) { + mpvCore?.runOnExecutor { + val stats = getMpvStats() + activity?.runOnUiThread { result.success(stats) } + } ?: result.success(mapOf("playerType" to "mpv")) + } else { + activity?.runOnUiThread { val coreStats = playerCore?.getStats() ?: emptyMap() - coreStats + mapOf("playerType" to "exoplayer") - } - result.success(stats) - } ?: result.success(mapOf("playerType" to "unknown")) + result.success(coreStats + mapOf("playerType" to "exoplayer")) + } ?: result.success(mapOf("playerType" to "unknown")) + } } /** @@ -652,71 +663,80 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, usingMpvFallback = true fallbackInProgress = false - // Configure basic MPV properties for Plex playback - mpvCore?.setProperty("hwdec", "mediacodec,mediacodec-copy") - mpvCore?.setProperty("vo", "gpu") - mpvCore?.setProperty("ao", "audiotrack") - - // Forward user's buffer config to MPV fallback - configuredBufferSizeBytes?.let { bytes -> - if (bytes > 0) { - mpvCore?.setProperty("demuxer-max-bytes", bytes.toString()) - } - } - - // Apply any pending MPV properties from Dart - for ((propName, propValue) in pendingMpvProperties) { - mpvCore?.setProperty(propName, propValue) - } + // Snapshot pending properties on main thread before clearing + val pendingProps = pendingMpvProperties.toList() pendingMpvProperties.clear() - // Setup property observers - mpvCore?.observeProperty("time-pos", "double") - mpvCore?.observeProperty("duration", "double") - mpvCore?.observeProperty("seekable", "flag") - mpvCore?.observeProperty("pause", "flag") - mpvCore?.observeProperty("paused-for-cache", "flag") - mpvCore?.observeProperty("demuxer-cache-time", "double") - mpvCore?.observeProperty("eof-reached", "flag") - mpvCore?.observeProperty("track-list", "string") - mpvCore?.observeProperty("aid", "string") - mpvCore?.observeProperty("sid", "string") - mpvCore?.observeProperty("volume", "double") - mpvCore?.observeProperty("speed", "double") - - // Show the MPV surface - mpvCore?.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(",") - // Convert content:// URIs to fdclose:// for MPV (SAF SD card downloads) + // Compute content FD on main thread (needs contentResolver) val mpvUri = openContentFd(uri)?.let { "fdclose://$it" } ?: uri - mpvCore?.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. - val peakDetection = mpvCore?.getProperty("hdr-compute-peak") - if (peakDetection == "no") { - Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") - mpvCore?.setProperty("tone-mapping", "reinhard") - mpvCore?.setProperty("tone-mapping-param", "0.7") - mpvCore?.setProperty("tone-mapping-mode", "luma") + // Buffer size for closure + val bufferSize = configuredBufferSizeBytes + + mpvCore?.runOnExecutor { + // Configure basic MPV properties for Plex playback + mpvCore?.setProperty("hwdec", "mediacodec,mediacodec-copy") + mpvCore?.setProperty("vo", "gpu") + mpvCore?.setProperty("ao", "audiotrack") + + // Forward user's buffer config to MPV fallback + if (bufferSize != null && bufferSize > 0) { + mpvCore?.setProperty("demuxer-max-bytes", bufferSize.toString()) + } + + // Apply pending MPV properties from Dart + for ((propName, propValue) in pendingProps) { + mpvCore?.setProperty(propName, propValue) + } + + // Setup property observers + mpvCore?.observeProperty("time-pos", "double") + mpvCore?.observeProperty("duration", "double") + mpvCore?.observeProperty("seekable", "flag") + mpvCore?.observeProperty("pause", "flag") + mpvCore?.observeProperty("paused-for-cache", "flag") + mpvCore?.observeProperty("demuxer-cache-time", "double") + mpvCore?.observeProperty("eof-reached", "flag") + mpvCore?.observeProperty("track-list", "string") + mpvCore?.observeProperty("aid", "string") + mpvCore?.observeProperty("sid", "string") + mpvCore?.observeProperty("volume", "double") + mpvCore?.observeProperty("speed", "double") + + // Show the MPV surface (internally posts to UI) + mpvCore?.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(",") + mpvCore?.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. + val peakDetection = mpvCore?.getProperty("hdr-compute-peak") + if (peakDetection == "no") { + Log.i(TAG, "No compute shaders — overriding tone-mapping to reinhard") + mpvCore?.setProperty("tone-mapping", "reinhard") + mpvCore?.setProperty("tone-mapping-param", "0.7") + mpvCore?.setProperty("tone-mapping-mode", "luma") + } + + // Request audio focus + mpvCore?.requestAudioFocus() + + // Emit backend-switched event on main thread + activity?.runOnUiThread { + onEvent("backend-switched", null) + } + + Log.i(TAG, "Successfully switched to MPV fallback") } - - // Request audio focus - mpvCore?.requestAudioFocus() - - // Emit backend-switched event so Flutter can show notification - 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 db72365c..4c7ae809 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 @@ -439,17 +439,22 @@ class MpvPlayerCore(private val activity: Activity) : * This prevents ANR when mpv_get_property blocks waiting for internal locks. */ fun getPropertyAsync(name: String, result: MethodChannel.Result) { - if (!isInitialized) { + if (!isInitialized || disposing) { result.success(null) return } - commandExecutor.execute { - try { - val value = MPVLib.getPropertyString(name) - activity.runOnUiThread { result.success(value) } - } catch (e: Exception) { - activity.runOnUiThread { result.success(null) } + 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) } } @@ -458,18 +463,23 @@ class MpvPlayerCore(private val activity: Activity) : * This prevents ANR when mpv_set_property blocks waiting for internal locks. */ fun setPropertyAsync(name: String, value: String, result: MethodChannel.Result) { - if (!isInitialized) { + if (!isInitialized || disposing) { result.success(null) return } - 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) } + 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) } } @@ -478,7 +488,7 @@ class MpvPlayerCore(private val activity: Activity) : * This prevents ANR when mpv_observe_property blocks waiting for internal locks. */ fun observePropertyAsync(name: String, format: String, result: MethodChannel.Result) { - if (!isInitialized) { + if (!isInitialized || disposing) { result.success(null) return } @@ -489,14 +499,19 @@ class MpvPlayerCore(private val activity: Activity) : "node" -> MPVLib.MPV_FORMAT_NODE else -> MPVLib.MPV_FORMAT_NONE } - 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) } + 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) } } @@ -505,7 +520,12 @@ class MpvPlayerCore(private val activity: Activity) : * Used by the plugin for I/O operations that shouldn't block the main thread. */ fun runOnExecutor(block: () -> Unit) { - commandExecutor.execute(block) + if (disposing) return + try { + commandExecutor.execute(block) + } catch (e: java.util.concurrent.RejectedExecutionException) { + Log.w(TAG, "runOnExecutor rejected (executor shut down)") + } } fun command(args: Array) { @@ -519,23 +539,28 @@ class MpvPlayerCore(private val activity: Activity) : * The result is called back on the UI thread when the command completes. */ fun commandAsync(args: Array, result: MethodChannel.Result) { - if (!isInitialized || args.isEmpty()) { + if (!isInitialized || disposing || args.isEmpty()) { result.success(null) return } - 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) + 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) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt index 0d758e82..f7e4dd95 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/watchnext/WatchNextPlugin.kt @@ -3,11 +3,14 @@ package com.edde746.plezy.watchnext import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.tvprovider.media.tv.TvContractCompat import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors /** * Flutter plugin for Android TV Watch Next integration. @@ -37,6 +40,8 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { private lateinit var methodChannel: MethodChannel private var applicationContext: Context? = null private var watchNextProvider: WatchNextProvider? = null + private val ioExecutor by lazy { Executors.newSingleThreadExecutor() } + private val mainHandler = Handler(Looper.getMainLooper()) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { applicationContext = binding.applicationContext @@ -49,6 +54,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { methodChannel.setMethodCallHandler(null) applicationContext = null watchNextProvider = null + ioExecutor.shutdown() } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -85,7 +91,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { } val items = itemsData.mapNotNull { parseWatchNextItem(it) } - result.success(provider.syncWatchNextPrograms(items)) + executeOnIo(result) { provider.syncWatchNextPrograms(items) } } private fun handleClear(result: MethodChannel.Result) { @@ -94,7 +100,7 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null) return } - result.success(provider.clearAll()) + executeOnIo(result) { provider.clearAll() } } private fun handleRemove(call: MethodCall, result: MethodChannel.Result) { @@ -109,7 +115,23 @@ class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { result.error("INVALID_ARGS", "Missing 'contentId' argument", null) return } - result.success(provider.removeItem(contentId)) + executeOnIo(result) { provider.removeItem(contentId) } + } + + private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) { + try { + ioExecutor.execute { + try { + val value = block() + mainHandler.post { result.success(value) } + } catch (e: Exception) { + Log.e(TAG, "IO operation failed: ${e.message}", e) + mainHandler.post { result.error("IO_ERROR", e.message, null) } + } + } + } catch (e: java.util.concurrent.RejectedExecutionException) { + result.error("SHUTDOWN", "Plugin is shutting down", null) + } } private fun handleGetInitialDeepLink(result: MethodChannel.Result) {