fix: android thread safety

- ExoPlayerPlugin: move all MPV JNI calls off main thread via runOnExecutor
- WatchNextPlugin: run ContentProvider ops on IO executor with error handling
- MpvPlayerCore: guard executor methods against dispose race (RejectedExecutionException)
- MainActivity: fix PiP null engine NPE with safe flutterEngine?.let
This commit is contained in:
edde746
2026-03-12 03:34:32 +01:00
parent 912ebc2f90
commit c6fa6afa9b
4 changed files with 195 additions and 128 deletions
@@ -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)
}
}
}
@@ -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<String>()
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<String>()
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
@@ -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<String>) {
@@ -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<String>, 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)
}
}
@@ -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) {