feat(music): mpv audio playback engine with gapless queue service

Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
This commit is contained in:
edde746
2026-07-05 19:26:28 +02:00
parent 05a631415e
commit 422db75b5b
41 changed files with 3353 additions and 269 deletions
@@ -30,6 +30,7 @@ import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.ThemeHelper
@@ -490,6 +491,7 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
flutterEngine.plugins.add(MpvAudioPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
@@ -1,8 +1,10 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
@@ -22,12 +24,31 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
/**
* mpv playback core. Two modes:
* - Video (default): [context] is the host Activity, which is needed for the
* SurfaceView/window hierarchy, display refresh-rate reads and frame-rate
* matching.
* - Audio-only ([audioOnly]): the music core. Built on the application
* context (no Activity dependency, so it survives activity teardown);
* never creates a surface, view, or frame-rate manager, and mpv is
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
class MpvPlayerCore(
private val context: Context,
private val audioOnly: Boolean = false
) : SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerCore"
}
/** Video-only paths. The plugin always constructs video cores with the
* host Activity, and audio-only mode never touches these paths. */
private val activity: Activity
get() = context as Activity
private var surfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@@ -63,6 +84,16 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
// Result-callback marshaling. Separate from [handler], whose queued
// messages dispose() clears — pending method-channel results must still
// complete after dispose.
private val mainHandler = Handler(Looper.getMainLooper())
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
// Audio focus
private var audioFocusManager: AudioFocusManager? = null
@@ -88,7 +119,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private var flutterOverlayApplied = false
private fun ensureFlutterOverlayOnTop() {
if (disposing || flutterOverlayApplied) return
if (audioOnly || disposing || flutterOverlayApplied) return
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (disposing || !isInitialized) return@post
@@ -112,6 +143,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
private fun currentDisplayFpsOverride(): String? {
if (audioOnly) return null
val refreshRate = activity.display?.mode?.refreshRate ?: return null
if (refreshRate <= 0f) return null
return refreshRate.toString()
@@ -167,12 +199,15 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
ensurePlaceholderSurface()
if (!audioOnly) ensurePlaceholderSurface()
// Initialize audio focus handling
// Initialize audio focus handling. mpv has none built in, so both modes
// use the shared manager: pause on (transient) loss, auto-resume on
// regain when the loss interrupted active playback.
audioFocusManager = AudioFocusManager(
context = activity,
context = context,
handler = handler,
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
onPause = {
scope.launch {
try {
@@ -187,57 +222,59 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
},
isPaused = { cachedPaused }
)
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
if (!audioOnly) {
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
log = { emitLog("info", "framerate", it) }
)
setBackgroundColor(Color.BLACK)
}
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
}
// Add SurfaceView to container
surfaceContainer!!.addView(surfaceView)
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Add SurfaceView to container
surfaceContainer!!.addView(surfaceView)
// Find FlutterView and set it on top of our video surface.
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface.
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
Log.d(TAG, "SurfaceView added to content view")
Log.d(TAG, "SurfaceView added to content view")
}
// Create MpvPlayer on background thread via coroutine
scope.launch {
@@ -247,18 +284,31 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
return@launch
}
val displayFpsOverride = currentDisplayFpsOverride()
val p = MpvPlayer.create(activity.applicationContext) {
setOption("vo", "gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
val p = MpvPlayer.create(context.applicationContext) {
if (audioOnly) {
// Pure audio core (all set before mpv_initialize, mirroring the
// Windows/Linux audio instances): vid=no keeps embedded cover
// art from ever becoming a video track, force-window and
// audio-display make sure mpv never opens a video output for
// it, and gapless-audio splices the pre-armed next playlist
// entry into the running audio stream.
setOption("vid", "no")
setOption("force-window", "no")
setOption("audio-display", "no")
setOption("gapless-audio", "weak")
} else {
setOption("vo", "gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows).
setOption("keep-open", "yes")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
if (displayFpsOverride != null) {
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
@@ -273,7 +323,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
player = p
isInitialized = true
refreshVideoOutput("initialize")
if (!audioOnly) refreshVideoOutput("initialize")
// Start collecting events/properties/logs
collectEvents(p)
@@ -408,7 +458,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private fun hasAttachedRealSurface(): Boolean = hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)
private fun hasReadyVideoOutput(): Boolean = hasAttachedRealSurface() && !videoOutputRestoring
// Audio-only mode has no video output to wait for — playback and resume
// paths gated on output readiness must always proceed there.
private fun hasReadyVideoOutput(): Boolean = audioOnly || (hasAttachedRealSurface() && !videoOutputRestoring)
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && epoch == videoOutputEpoch
@@ -419,7 +471,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
private fun refreshVideoOutput(reason: String) {
if (disposing) return
if (audioOnly || disposing) return
rememberCurrentSurfaceSize()
val p = player
@@ -730,7 +782,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Thread {
val value = getPropertyBlocking(name)
activity.runOnUiThread {
runOnMain {
onResult(if (!disposing && isInitialized) value else null)
}
}.start()
@@ -824,9 +876,10 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
fun setVisible(visible: Boolean) {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
// Audio-only: no render layer to show or hide — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
if (disposing) return@runOnMain
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
if (visible) {
flutterOverlayApplied = false
@@ -852,16 +905,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
fun updateFrame() {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
// Audio-only: no surface to refresh — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
if (disposing) return@runOnMain
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
rememberCurrentSurfaceSize()
val p = player
if (p == null) {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
return@runOnUiThread
return@runOnMain
}
if (!hasReadyVideoOutput()) {
val surface = currentCandidateSurface()
@@ -871,7 +925,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} else {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
}
return@runOnUiThread
return@runOnMain
}
scope.launch {
try {
@@ -957,17 +1011,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
videoOutputEpoch += 1L
}
// Capture locals for deferred cleanup
// Capture locals for deferred cleanup (audio-only has no views)
val sv = surfaceView
val container = surfaceContainer
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
surfaceContainer = null
surfaceView = null
// Remove layout listener synchronously
overlayLayoutListener?.let { listener ->
contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener)
contentView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
}
overlayLayoutListener = null
@@ -989,15 +1043,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
if (p != null) {
Thread {
try {
// Detach surface BEFORE close to prevent GPU mutex contention with view removal
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
// Detach surface BEFORE close to prevent GPU mutex contention with
// view removal (audio-only never attached one)
if (!audioOnly) {
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
p.close()
} catch (e: Exception) {
@@ -1008,7 +1065,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
contentView?.removeView(container)
}
onComplete?.invoke()
}
@@ -1018,7 +1075,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
contentView?.removeView(container)
}
}
onComplete?.invoke()
@@ -1,7 +1,10 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -10,18 +13,27 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin :
FlutterPlugin,
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
* dedicated music core on its own channel pair with two lifecycle
* differences:
* - the core is built on the application context, not the Activity, so
* background music playback survives activity teardown — it is only
* disposed on explicit Dart `dispose` or engine detach, never in
* [onDetachedFromActivity];
* - all video-only surface work is skipped inside the core.
*/
open class MpvPlayerPlugin(
private val channelBase: String = "com.plezy/mpv_player",
private val audioOnly: Boolean = false
) : FlutterPlugin,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler,
ActivityAware,
com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
}
private val tag = if (audioOnly) "MpvAudioPlayerPlugin" else "MpvPlayerPlugin"
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
@@ -29,9 +41,17 @@ class MpvPlayerPlugin :
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private var applicationContext: Context? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
private val mainHandler = Handler(Looper.getMainLooper())
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
// Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead
// of each tearing down the in-flight core and starting their own — which
@@ -43,19 +63,36 @@ class MpvPlayerPlugin :
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
applicationContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
Log.d(tag, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
if (audioOnly) {
// The audio core is not activity-bound; engine detach is its terminal
// native lifecycle event (mirrors the video core's activity detach).
disposeCoreForTeardown()
}
applicationContext = null
Log.d(tag, "Detached from engine")
}
private fun disposeCoreForTeardown() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
}
// ActivityAware
@@ -63,43 +100,42 @@ class MpvPlayerPlugin :
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
Log.d(tag, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
// The audio-only core deliberately outlives the activity (background
// music); it is torn down on engine detach / Dart dispose instead.
if (!audioOnly) {
disposeCoreForTeardown()
}
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
Log.d(tag, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
Log.d(tag, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
Log.d(tag, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
Log.d(tag, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
Log.d(tag, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
@@ -127,14 +163,20 @@ class MpvPlayerPlugin :
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
// Video cores need the Activity (surface/view hierarchy); the audio-only
// core is built on the application context so it can outlive it.
val coreContext: Context? = if (audioOnly) applicationContext else activity
if (coreContext == null) {
if (audioOnly) {
result.error("NO_CONTEXT", "Application context not available", null)
} else {
result.error("NO_ACTIVITY", "Activity not available", null)
}
return
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
Log.d(tag, "Already initialized")
result.success(true)
return
}
@@ -146,13 +188,13 @@ class MpvPlayerPlugin :
synchronized(pendingInitResults) {
pendingInitResults += result
if (isInitializing) {
Log.d(TAG, "Init already in flight, queuing caller")
Log.d(tag, "Init already in flight, queuing caller")
return
}
isInitializing = true
}
currentActivity.runOnUiThread {
runOnMain {
val gen: Int
val core: MpvPlayerCore
try {
@@ -160,31 +202,32 @@ class MpvPlayerPlugin :
// OR `playerCore?.isInitialized == true` and we early-exited
// above. We never tear down a core that's mid-initialization.
if (playerCore != null && playerCore?.isInitialized != true) {
Log.w(TAG, "Discarding stale uninitialized core before re-init")
Log.w(tag, "Discarding stale uninitialized core before re-init")
playerCore?.dispose()
playerCore = null
}
gen = ++sessionGeneration
core = MpvPlayerCore(currentActivity).apply {
core = MpvPlayerCore(coreContext, audioOnly).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(success = false, errorMessage = e.message)
return@runOnUiThread
return@runOnMain
}
core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core
if (stale) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
// not the SurfaceView directly (matching ExoPlayer's approach).
// No-op on the audio-only core, which has no render layer.
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
Log.d(tag, "Initialized: $success")
}
completePendingInits(success = !stale && success)
}
@@ -208,7 +251,7 @@ class MpvPlayerPlugin :
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
runOnMain {
val core = playerCore
++sessionGeneration
playerCore = null
@@ -218,10 +261,10 @@ class MpvPlayerPlugin :
completePendingInits(success = false)
core?.dispose {
Log.d(TAG, "Disposed")
Log.d(tag, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
}
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
@@ -269,9 +312,8 @@ class MpvPlayerPlugin :
}
private fun handleGetStats(result: MethodChannel.Result) {
val currentActivity = activity
val core = playerCore
if (currentActivity == null || core == null) {
if (core == null) {
result.success(mapOf("playerType" to "mpv"))
return
}
@@ -279,7 +321,7 @@ class MpvPlayerPlugin :
val gen = sessionGeneration
Thread {
val stats = core.getStats()
currentActivity.runOnUiThread {
runOnMain {
if (gen != sessionGeneration || playerCore !== core) {
result.success(mapOf("playerType" to "mpv"))
} else {
@@ -346,7 +388,7 @@ class MpvPlayerPlugin :
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
Log.d(tag, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
val core = playerCore
if (core == null) {
result.success(false)
@@ -358,19 +400,19 @@ class MpvPlayerPlugin :
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
Log.d(tag, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
Log.d(tag, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
Log.d(tag, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
@@ -382,9 +424,11 @@ class MpvPlayerPlugin :
return
}
val contentResolver = activity?.contentResolver
// The audio instance may run without an Activity (background music), so
// resolve SAF content URIs through the application context there.
val contentResolver = (if (audioOnly) applicationContext else activity)?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
result.error(if (audioOnly) "NO_CONTEXT" else "NO_ACTIVITY", "Context not available", null)
return
}
@@ -394,18 +438,18 @@ class MpvPlayerPlugin :
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
runOnMain {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
Log.d(tag, "Opened content FD $fd for $uriString")
runOnMain { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
Log.e(tag, "Failed to open content FD: ${e.message}", e)
runOnMain { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
@@ -426,3 +470,11 @@ class MpvPlayerPlugin :
eventSink?.success(event)
}
}
/**
* The audio-only music instance on `com.plezy/mpv_audio_player[/events]`.
* A distinct class (not just a configured [MpvPlayerPlugin]) because
* FlutterEngine's plugin registry keys plugins by class and would silently
* drop a second [MpvPlayerPlugin] registration.
*/
class MpvAudioPlayerPlugin : MpvPlayerPlugin(channelBase = "com.plezy/mpv_audio_player", audioOnly = true)
@@ -14,7 +14,8 @@ class AudioFocusManager(
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
private val log: (String) -> Unit = { Log.d(TAG, it) },
private val contentType: Int = AudioAttributes.CONTENT_TYPE_MOVIE
) {
companion object {
private const val TAG = "AudioFocusManager"
@@ -62,7 +63,7 @@ class AudioFocusManager(
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.setContentType(contentType)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)