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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user