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 android.widget.FrameLayout
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import com.edde746.plezy.exoplayer.ExoPlayerPlugin import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.ThemeHelper import com.edde746.plezy.shared.ThemeHelper
@@ -490,6 +491,7 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin()) flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin()) flutterEngine.plugins.add(ExoPlayerPlugin())
flutterEngine.plugins.add(MpvAudioPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
@@ -1,8 +1,10 @@
package com.edde746.plezy.mpv package com.edde746.plezy.mpv
import android.app.Activity import android.app.Activity
import android.content.Context
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader import android.media.ImageReader
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -22,12 +24,31 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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 { companion object {
private const val TAG = "MpvPlayerCore" 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 surfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null private var surfaceContainer: android.widget.FrameLayout? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = 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 var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper()) 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 // Audio focus
private var audioFocusManager: AudioFocusManager? = null private var audioFocusManager: AudioFocusManager? = null
@@ -88,7 +119,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private var flutterOverlayApplied = false private var flutterOverlayApplied = false
private fun ensureFlutterOverlayOnTop() { private fun ensureFlutterOverlayOnTop() {
if (disposing || flutterOverlayApplied) return if (audioOnly || disposing || flutterOverlayApplied) return
val contentView = activity.findViewById<ViewGroup>(android.R.id.content) val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post { contentView.post {
if (disposing || !isInitialized) return@post if (disposing || !isInitialized) return@post
@@ -112,6 +143,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} }
private fun currentDisplayFpsOverride(): String? { private fun currentDisplayFpsOverride(): String? {
if (audioOnly) return null
val refreshRate = activity.display?.mode?.refreshRate ?: return null val refreshRate = activity.display?.mode?.refreshRate ?: return null
if (refreshRate <= 0f) return null if (refreshRate <= 0f) return null
return refreshRate.toString() return refreshRate.toString()
@@ -167,12 +199,15 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
lastAppliedSurfaceSize = null lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0 lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 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( audioFocusManager = AudioFocusManager(
context = activity, context = context,
handler = handler, handler = handler,
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
onPause = { onPause = {
scope.launch { scope.launch {
try { try {
@@ -187,57 +222,59 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}, },
isPaused = { cachedPaused } isPaused = { cachedPaused }
) )
frameRateManager = FrameRateManager( if (!audioOnly) {
activity = activity, frameRateManager = FrameRateManager(
handler = handler, activity = activity,
log = { emitLog("info", "framerate", it) } 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
) )
setBackgroundColor(Color.BLACK)
}
// Create SurfaceView for video rendering // Create FrameLayout container for video
surfaceView = SurfaceView(activity).apply { surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams( layoutParams = ViewGroup.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT ViewGroup.LayoutParams.MATCH_PARENT
) )
holder.addCallback(this@MpvPlayerCore) setBackgroundColor(Color.BLACK)
setZOrderOnTop(false) }
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Add SurfaceView to container // Create SurfaceView for video rendering
surfaceContainer!!.addView(surfaceView) 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) // Add SurfaceView to container
val contentView = activity.findViewById<ViewGroup>(android.R.id.content) surfaceContainer!!.addView(surfaceView)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface. // Insert container at bottom of view hierarchy (behind Flutter)
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+: val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
// negative is hole-punched behind the parent canvas, non-negative is above. contentView.addView(surfaceContainer, 0)
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container -> // Find FlutterView and set it on top of our video surface.
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1) // compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
flutterOverlayApplied = true // negative is hole-punched behind the parent canvas, non-negative is above.
} // Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
ensureFlutterOverlayOnTop() FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop() ensureFlutterOverlayOnTop()
val sv = surfaceView overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
if (sv != null) applySurfaceSize(sv.width, sv.height) ensureFlutterOverlayOnTop()
} val sv = surfaceView
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener) 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 // Create MpvPlayer on background thread via coroutine
scope.launch { scope.launch {
@@ -247,18 +284,31 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
return@launch return@launch
} }
val displayFpsOverride = currentDisplayFpsOverride() val displayFpsOverride = currentDisplayFpsOverride()
val p = MpvPlayer.create(activity.applicationContext) { val p = MpvPlayer.create(context.applicationContext) {
setOption("vo", "gpu") if (audioOnly) {
setOption("gpu-context", "android") // Pure audio core (all set before mpv_initialize, mirroring the
setOption("opengl-es", "yes") // Windows/Linux audio instances): vid=no keeps embedded cover
setOption("vd-lavc-film-grain", "cpu") // 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") setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a // Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows). // seek after the video ends still works (matches Linux/Windows).
setOption("keep-open", "yes") setOption("keep-open", "yes")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
} }
if (displayFpsOverride != null) { if (displayFpsOverride != null) {
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride") Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
@@ -273,7 +323,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
player = p player = p
isInitialized = true isInitialized = true
refreshVideoOutput("initialize") if (!audioOnly) refreshVideoOutput("initialize")
// Start collecting events/properties/logs // Start collecting events/properties/logs
collectEvents(p) 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 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 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) { private fun refreshVideoOutput(reason: String) {
if (disposing) return if (audioOnly || disposing) return
rememberCurrentSurfaceSize() rememberCurrentSurfaceSize()
val p = player val p = player
@@ -730,7 +782,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Thread { Thread {
val value = getPropertyBlocking(name) val value = getPropertyBlocking(name)
activity.runOnUiThread { runOnMain {
onResult(if (!disposing && isInitialized) value else null) onResult(if (!disposing && isInitialized) value else null)
} }
}.start() }.start()
@@ -824,9 +876,10 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} }
fun setVisible(visible: Boolean) { fun setVisible(visible: Boolean) {
if (disposing) return // Audio-only: no render layer to show or hide — tolerated no-op.
activity.runOnUiThread { if (audioOnly || disposing) return
if (disposing) return@runOnUiThread runOnMain {
if (disposing) return@runOnMain
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
if (visible) { if (visible) {
flutterOverlayApplied = false flutterOverlayApplied = false
@@ -852,16 +905,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} }
fun updateFrame() { fun updateFrame() {
if (disposing) return // Audio-only: no surface to refresh — tolerated no-op.
activity.runOnUiThread { if (audioOnly || disposing) return
if (disposing) return@runOnUiThread runOnMain {
if (disposing) return@runOnMain
flutterOverlayApplied = false flutterOverlayApplied = false
ensureFlutterOverlayOnTop() ensureFlutterOverlayOnTop()
rememberCurrentSurfaceSize() rememberCurrentSurfaceSize()
val p = player val p = player
if (p == null) { if (p == null) {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready") Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
return@runOnUiThread return@runOnMain
} }
if (!hasReadyVideoOutput()) { if (!hasReadyVideoOutput()) {
val surface = currentCandidateSurface() val surface = currentCandidateSurface()
@@ -871,7 +925,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} else { } else {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached") Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
} }
return@runOnUiThread return@runOnMain
} }
scope.launch { scope.launch {
try { try {
@@ -957,17 +1011,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
videoOutputEpoch += 1L videoOutputEpoch += 1L
} }
// Capture locals for deferred cleanup // Capture locals for deferred cleanup (audio-only has no views)
val sv = surfaceView val sv = surfaceView
val container = surfaceContainer 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 surfaceContainer = null
surfaceView = null surfaceView = null
// Remove layout listener synchronously // Remove layout listener synchronously
overlayLayoutListener?.let { listener -> overlayLayoutListener?.let { listener ->
contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener) contentView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
} }
overlayLayoutListener = null overlayLayoutListener = null
@@ -989,15 +1043,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
if (p != null) { if (p != null) {
Thread { Thread {
try { try {
// Detach surface BEFORE close to prevent GPU mutex contention with view removal // Detach surface BEFORE close to prevent GPU mutex contention with
try { // view removal (audio-only never attached one)
runBlocking { if (!audioOnly) {
p.setProperty("force-window", "no") try {
p.setProperty("vo", "null") 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() p.close()
} catch (e: Exception) { } catch (e: Exception) {
@@ -1008,7 +1065,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this) sv?.holder?.removeCallback(this)
if (container?.parent != null) { if (container?.parent != null) {
contentView.removeView(container) contentView?.removeView(container)
} }
onComplete?.invoke() onComplete?.invoke()
} }
@@ -1018,7 +1075,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).postAtFrontOfQueue { Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this) sv?.holder?.removeCallback(this)
if (container?.parent != null) { if (container?.parent != null) {
contentView.removeView(container) contentView?.removeView(container)
} }
} }
onComplete?.invoke() onComplete?.invoke()
@@ -1,7 +1,10 @@
package com.edde746.plezy.mpv package com.edde746.plezy.mpv
import android.app.Activity import android.app.Activity
import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware 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.MethodCall
import io.flutter.plugin.common.MethodChannel 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, MethodChannel.MethodCallHandler,
EventChannel.StreamHandler, EventChannel.StreamHandler,
ActivityAware, ActivityAware,
com.edde746.plezy.shared.PlayerDelegate { com.edde746.plezy.shared.PlayerDelegate {
companion object { private val tag = if (audioOnly) "MpvAudioPlayerPlugin" else "MpvPlayerPlugin"
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 lateinit var methodChannel: MethodChannel private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel private lateinit var eventChannel: EventChannel
@@ -29,9 +41,17 @@ class MpvPlayerPlugin :
private var playerCore: MpvPlayerCore? = null private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null private var activityBinding: ActivityPluginBinding? = null
private var applicationContext: Context? = null
private val nameToId = mutableMapOf<String, Int>() private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0 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. // Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead // Concurrent `invoke('initialize')` calls share the same outcome instead
// of each tearing down the in-flight core and starting their own — which // of each tearing down the in-flight core and starting their own — which
@@ -43,19 +63,36 @@ class MpvPlayerPlugin :
// FlutterPlugin // FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) applicationContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(this) methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(this) eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine") Log.d(tag, "Attached to engine")
} }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null) methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(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 // ActivityAware
@@ -63,43 +100,42 @@ class MpvPlayerPlugin :
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity activity = binding.activity
activityBinding = binding activityBinding = binding
Log.d(TAG, "Attached to activity") Log.d(tag, "Attached to activity")
} }
override fun onDetachedFromActivity() { override fun onDetachedFromActivity() {
++sessionGeneration // The audio-only core deliberately outlives the activity (background
playerCore?.dispose() // music); it is torn down on engine detach / Dart dispose instead.
playerCore = null if (!audioOnly) {
// Any in-flight init callback would never fire (its scope is cancelled disposeCoreForTeardown()
// by dispose), so close out queued callers explicitly. }
completePendingInits(success = false)
activity = null activity = null
activityBinding = null activityBinding = null
Log.d(TAG, "Detached from activity") Log.d(tag, "Detached from activity")
} }
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity activity = binding.activity
activityBinding = binding activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes") Log.d(tag, "Reattached to activity for config changes")
} }
override fun onDetachedFromActivityForConfigChanges() { override fun onDetachedFromActivityForConfigChanges() {
activity = null activity = null
activityBinding = null activityBinding = null
Log.d(TAG, "Detached from activity for config changes") Log.d(tag, "Detached from activity for config changes")
} }
// EventChannel.StreamHandler // EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events eventSink = events
Log.d(TAG, "Event stream connected") Log.d(tag, "Event stream connected")
} }
override fun onCancel(arguments: Any?) { override fun onCancel(arguments: Any?) {
eventSink = null eventSink = null
Log.d(TAG, "Event stream disconnected") Log.d(tag, "Event stream disconnected")
} }
// MethodChannel.MethodCallHandler // MethodChannel.MethodCallHandler
@@ -127,14 +163,20 @@ class MpvPlayerPlugin :
} }
private fun handleInitialize(result: MethodChannel.Result) { private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity // Video cores need the Activity (surface/view hierarchy); the audio-only
if (currentActivity == null) { // core is built on the application context so it can outlive it.
result.error("NO_ACTIVITY", "Activity not available", null) 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 return
} }
if (playerCore?.isInitialized == true) { if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized") Log.d(tag, "Already initialized")
result.success(true) result.success(true)
return return
} }
@@ -146,13 +188,13 @@ class MpvPlayerPlugin :
synchronized(pendingInitResults) { synchronized(pendingInitResults) {
pendingInitResults += result pendingInitResults += result
if (isInitializing) { if (isInitializing) {
Log.d(TAG, "Init already in flight, queuing caller") Log.d(tag, "Init already in flight, queuing caller")
return return
} }
isInitializing = true isInitializing = true
} }
currentActivity.runOnUiThread { runOnMain {
val gen: Int val gen: Int
val core: MpvPlayerCore val core: MpvPlayerCore
try { try {
@@ -160,31 +202,32 @@ class MpvPlayerPlugin :
// OR `playerCore?.isInitialized == true` and we early-exited // OR `playerCore?.isInitialized == true` and we early-exited
// above. We never tear down a core that's mid-initialization. // above. We never tear down a core that's mid-initialization.
if (playerCore != null && playerCore?.isInitialized != true) { 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?.dispose()
playerCore = null playerCore = null
} }
gen = ++sessionGeneration gen = ++sessionGeneration
core = MpvPlayerCore(currentActivity).apply { core = MpvPlayerCore(coreContext, audioOnly).apply {
delegate = this@MpvPlayerPlugin delegate = this@MpvPlayerPlugin
} }
playerCore = core playerCore = core
} catch (e: Exception) { } 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) completePendingInits(success = false, errorMessage = e.message)
return@runOnUiThread return@runOnMain
} }
core.initialize { success -> core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core val stale = gen != sessionGeneration || playerCore !== core
if (stale) { if (stale) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)") Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else { } else {
// Start hidden - now safe because setVisible operates on the container, // 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) core.setVisible(false)
Log.d(TAG, "Initialized: $success") Log.d(tag, "Initialized: $success")
} }
completePendingInits(success = !stale && success) completePendingInits(success = !stale && success)
} }
@@ -208,7 +251,7 @@ class MpvPlayerPlugin :
} }
private fun handleDispose(result: MethodChannel.Result) { private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread { runOnMain {
val core = playerCore val core = playerCore
++sessionGeneration ++sessionGeneration
playerCore = null playerCore = null
@@ -218,10 +261,10 @@ class MpvPlayerPlugin :
completePendingInits(success = false) completePendingInits(success = false)
core?.dispose { core?.dispose {
Log.d(TAG, "Disposed") Log.d(tag, "Disposed")
result.success(null) result.success(null)
} ?: result.success(null) } ?: result.success(null)
} ?: result.success(null) }
} }
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) { private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
@@ -269,9 +312,8 @@ class MpvPlayerPlugin :
} }
private fun handleGetStats(result: MethodChannel.Result) { private fun handleGetStats(result: MethodChannel.Result) {
val currentActivity = activity
val core = playerCore val core = playerCore
if (currentActivity == null || core == null) { if (core == null) {
result.success(mapOf("playerType" to "mpv")) result.success(mapOf("playerType" to "mpv"))
return return
} }
@@ -279,7 +321,7 @@ class MpvPlayerPlugin :
val gen = sessionGeneration val gen = sessionGeneration
Thread { Thread {
val stats = core.getStats() val stats = core.getStats()
currentActivity.runOnUiThread { runOnMain {
if (gen != sessionGeneration || playerCore !== core) { if (gen != sessionGeneration || playerCore !== core) {
result.success(mapOf("playerType" to "mpv")) result.success(mapOf("playerType" to "mpv"))
} else { } else {
@@ -346,7 +388,7 @@ class MpvPlayerPlugin :
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0 val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("videoHeight")?.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 val core = playerCore
if (core == null) { if (core == null) {
result.success(false) result.success(false)
@@ -358,19 +400,19 @@ class MpvPlayerPlugin :
} }
private fun handleClearVideoFrameRate(result: MethodChannel.Result) { private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate") Log.d(tag, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate() playerCore?.clearVideoFrameRate()
result.success(null) result.success(null)
} }
private fun handleRequestAudioFocus(result: MethodChannel.Result) { private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus") Log.d(tag, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted) result.success(granted)
} }
private fun handleAbandonAudioFocus(result: MethodChannel.Result) { private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus") Log.d(tag, "abandonAudioFocus")
playerCore?.abandonAudioFocus() playerCore?.abandonAudioFocus()
result.success(null) result.success(null)
} }
@@ -382,9 +424,11 @@ class MpvPlayerPlugin :
return 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) { 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 return
} }
@@ -394,18 +438,18 @@ class MpvPlayerPlugin :
val uri = Uri.parse(uriString) val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r") val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) { if (pfd == null) {
activity?.runOnUiThread { runOnMain {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null) result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
} }
return@Thread return@Thread
} }
val fd = pfd.detachFd() val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString") Log.d(tag, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) } runOnMain { result.success(fd) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e) Log.e(tag, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) } runOnMain { result.error("OPEN_FAILED", e.message, null) }
} }
}.start() }.start()
} }
@@ -426,3 +470,11 @@ class MpvPlayerPlugin :
eventSink?.success(event) 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 onPause: () -> Unit,
private val onResume: () -> Unit, private val onResume: () -> Unit,
private val isPaused: () -> Boolean, 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 { companion object {
private const val TAG = "AudioFocusManager" private const val TAG = "AudioFocusManager"
@@ -62,7 +63,7 @@ class AudioFocusManager(
.setAudioAttributes( .setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) .setContentType(contentType)
.build() .build()
) )
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler) .setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
+8
View File
@@ -22,6 +22,8 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; }; B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; }; B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */; };
B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */; }; B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; }; B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
@@ -76,6 +78,8 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AtmosProbePlugin.swift; path = ../shared/apple/AtmosProbe/AtmosProbePlugin.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AtmosProbePlugin.swift; path = ../shared/apple/AtmosProbe/AtmosProbePlugin.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; }; B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -128,6 +132,8 @@
children = ( children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */, B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */, B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */,
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */,
B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */, B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */,
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */, B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */, 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
@@ -420,6 +426,8 @@
files = ( files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */, B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */, B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */,
B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */, B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */,
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */, B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */, 6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
+5
View File
@@ -36,6 +36,11 @@ import MediaPlayer
MpvPlayerPlugin.register(with: registrar) MpvPlayerPlugin.register(with: registrar)
} }
// Register the audio-only MPV player plugin (music playback)
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AtmosProbePlugin") { if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "AtmosProbePlugin") {
AtmosProbePlugin.register(with: registrar) AtmosProbePlugin.register(with: registrar)
} }
+28
View File
@@ -96,6 +96,16 @@ abstract class Player {
/// Seek to a specific position. /// Seek to a specific position.
Future<void> seek(Duration position); Future<void> seek(Duration position);
/// Arm (or replace/clear) the item the backend should auto-advance into
/// when the current one plays out — the gapless-audio primitive.
///
/// Audio players keep a native playlist of `[current, next?]`: ExoPlayer
/// via `addMediaItem`, mpv via `loadfile append` with `gapless-audio`.
/// When the advance happens the backend emits
/// [PlayerStreams.trackTransition] with the armed [Media.uri] instead of
/// `completed`. Pass `null` to clear. No-op on video backends.
Future<void> setNext(Media? media);
/// Select an audio track. /// Select an audio track.
Future<void> selectAudioTrack(AudioTrack track); Future<void> selectAudioTrack(AudioTrack track);
@@ -379,4 +389,22 @@ abstract class Player {
} }
throw UnsupportedError('Player is not supported on this platform'); throw UnsupportedError('Player is not supported on this platform');
} }
/// Creates the dedicated audio-only player used for music playback.
///
/// An mpv audio-only core on every platform — regardless of the Android
/// video backend setting — running on its own native core and channels
/// (`com.plezy/mpv_audio_player`), so it never contends with the video
/// pipeline. Desktop and Android need none of the video plumbing (display
/// modes, GL textures, surfaces) — the plain mpv wrapper suffices. Only
/// one native player is kept alive at a time: the music service disposes
/// this instance when video playback claims the session (see
/// `PlaybackCoordinator`), and the video core only exists while the video
/// player screen is open.
factory Player.audio() {
if (Platform.isAndroid || Platform.isMacOS || Platform.isIOS || Platform.isWindows || Platform.isLinux) {
return PlayerNative.audio();
}
throw UnsupportedError('Player is not supported on this platform');
}
} }
+5
View File
@@ -38,6 +38,11 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
@override @override
bool get audioPassthroughActive => false; bool get audioPassthroughActive => false;
/// Gapless-audio arming — meaningful only on the audio players, which
/// override this. Video backends ignore it.
@override
Future<void> setNext(Media? media) async {}
late final PlayerStreams _streams; late final PlayerStreams _streams;
@override @override
+153 -8
View File
@@ -1,32 +1,58 @@
import 'dart:async' show unawaited;
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart'; import '../../media/media_display_criteria.dart';
import '../../utils/app_logger.dart';
import '../models.dart'; import '../models.dart';
import 'player_base.dart'; import 'player_base.dart';
/// MPV-backed player for platforms where AetherEngine is not the native route. /// MPV-backed player for platforms where AetherEngine is not the native route.
class PlayerNative extends PlayerBase { class PlayerNative extends PlayerBase {
/// Video player on the default mpv channels/core.
PlayerNative()
: methodChannel = const MethodChannel('com.plezy/mpv_player'),
eventChannel = const EventChannel('com.plezy/mpv_player/events'),
audioOnly = false;
/// Audio-only player on the dedicated music channels/core (see
/// [Player.audio]). Skips every video concern: no render layer
/// ([setVisible] no-ops via [audioOnly]), no subtitle plumbing, no
/// display-mode handling.
PlayerNative.audio()
: methodChannel = const MethodChannel('com.plezy/mpv_audio_player'),
eventChannel = const EventChannel('com.plezy/mpv_audio_player/events'),
audioOnly = true;
int? _textureIdValue; int? _textureIdValue;
String _dvConversionMode = 'auto'; String _dvConversionMode = 'auto';
String _dvConversionLog = 'no'; String _dvConversionLog = 'no';
// Gapless-audio arming state (audioOnly). The native playlist is always
// [current, next?]; these track whether entry 1 exists and what it plays.
bool _hasArmedNext = false;
String? _armedNextUri;
// Set by open() and consumed by that load's file-loaded event, so it is
// not mistaken for a gapless advance (see _handleAudioFileLoaded).
bool _expectOpenFileLoad = false;
@override @override
int? get textureId => _textureIdValue; int? get textureId => _textureIdValue;
static const _methodChannel = MethodChannel('com.plezy/mpv_player'); /// Whether this instance drives the audio-only core.
static const _eventChannel = EventChannel('com.plezy/mpv_player/events'); final bool audioOnly;
@override @override
MethodChannel get methodChannel => _methodChannel; final MethodChannel methodChannel;
@override @override
EventChannel get eventChannel => _eventChannel; final EventChannel eventChannel;
@override @override
String get logPrefix => 'MPV'; String get logPrefix => audioOnly ? 'MPV-audio' : 'MPV';
@override @override
String get playerType => 'mpv'; String get playerType => 'mpv';
@@ -61,6 +87,12 @@ class PlayerNative extends PlayerBase {
return '%${utf8.encode(value).length}%$value'; return '%${utf8.encode(value).length}%$value';
} }
/// Query-free tail of [uri] for logs (keeps the part id, drops tokens).
static String _uriTail(String uri) {
final path = uri.split('?').first;
return path.length <= 40 ? path : '${path.substring(path.length - 40)}';
}
static String _escapePathListEntry(String value, String separator) { static String _escapePathListEntry(String value, String separator) {
return value.replaceAll(r'\', r'\\').replaceAll(separator, '\\$separator'); return value.replaceAll(r'\', r'\\').replaceAll(separator, '\\$separator');
} }
@@ -78,6 +110,16 @@ class PlayerNative extends PlayerBase {
return 'sub-files=${_fixedLengthQuote(escapedUris.join(separator))}'; return 'sub-files=${_fixedLengthQuote(escapedUris.join(separator))}';
} }
/// Per-entry `http-header-fields` for a `loadfile ... append` options arg.
/// The fixed-length quote shields the whole value from the key=value list
/// parser; mpv then splits the headers on commas, the same separator the
/// `setProperty('http-header-fields', ...)` path in [open] relies on.
static String? _httpHeaderFieldsLoadfileOption(Map<String, String>? headers) {
if (headers == null || headers.isEmpty) return null;
final headerList = headers.entries.map((e) => '${e.key}: ${e.value}').join(',');
return 'http-header-fields=${_fixedLengthQuote(headerList)}';
}
MediaDisplayCriteria? _effectiveDisplayCriteria(MediaDisplayCriteria? criteria) { MediaDisplayCriteria? _effectiveDisplayCriteria(MediaDisplayCriteria? criteria) {
if (criteria == null || (criteria.doviProfile ?? 0) != 7) return criteria; if (criteria == null || (criteria.doviProfile ?? 0) != 7) return criteria;
@@ -144,6 +186,18 @@ class PlayerNative extends PlayerBase {
await observeProperty('audio-device-list', _nodeFormat); await observeProperty('audio-device-list', _nodeFormat);
await observeProperty('audio-device', 'string'); await observeProperty('audio-device', 'string');
if (audioOnly) {
// Debug aid only: raw playlist positions in the log trail. Gapless
// advance DETECTION rides the file-loaded event instead — see
// _handleAudioFileLoaded for why property edges are unreliable.
await observeProperty('playlist-pos', _nodeFormat);
// The Apple audio core sets this at context init; set it defensively
// here so every mpv audio backend behaves identically. Direct invoke —
// setProperty() would await _ensureInitialized and deadlock on the
// memoized future of this very _doInitialize call.
await invoke('setProperty', {'name': 'gapless-audio', 'value': 'weak'});
}
initialized = true; initialized = true;
} catch (e) { } catch (e) {
_initFuture = null; _initFuture = null;
@@ -171,6 +225,10 @@ class PlayerNative extends PlayerBase {
}) async { }) async {
if (disposed) return; if (disposed) return;
await _ensureInitialized(); await _ensureInitialized();
// `loadfile replace` (below) clears the native playlist, dropping any
// gapless entry armed via setNext.
_hasArmedNext = false;
_armedNextUri = null;
final startPosition = media.start ?? Duration.zero; final startPosition = media.start ?? Duration.zero;
configureTimeline(offset: timelineOffset, duration: timelineDuration); configureTimeline(offset: timelineOffset, duration: timelineDuration);
clearTracks(); clearTracks();
@@ -178,7 +236,7 @@ class PlayerNative extends PlayerBase {
resetPlaybackProgress(startPosition); resetPlaybackProgress(startPosition);
setSeekable(false); setSeekable(false);
await setVisible(true); if (!audioOnly) await setVisible(true);
if (media.headers != null && media.headers!.isNotEmpty) { if (media.headers != null && media.headers!.isNotEmpty) {
final headerList = media.headers!.entries.map((e) => '${e.key}: ${e.value}').toList(); final headerList = media.headers!.entries.map((e) => '${e.key}: ${e.value}').toList();
@@ -216,6 +274,7 @@ class PlayerNative extends PlayerBase {
if (loadfileOption != null) { if (loadfileOption != null) {
loadfileArgs.addAll(['-1', loadfileOption]); loadfileArgs.addAll(['-1', loadfileOption]);
} }
if (audioOnly) _expectOpenFileLoad = true;
await command(loadfileArgs); await command(loadfileArgs);
// mpv's pause property survives loadfile; in-place reloads pause the old // mpv's pause property survives loadfile; in-place reloads pause the old
@@ -239,9 +298,11 @@ class PlayerNative extends PlayerBase {
@override @override
Future<void> stop() async { Future<void> stop() async {
_hasArmedNext = false;
_armedNextUri = null;
await command(['stop']); await command(['stop']);
setSeekable(false); setSeekable(false);
await invoke('setVisible', {'visible': false}); if (!audioOnly) await invoke('setVisible', {'visible': false});
} }
@override @override
@@ -250,6 +311,90 @@ class PlayerNative extends PlayerBase {
await runSeek(position, () => command(['seek', (sourcePosition.inMilliseconds / 1000.0).toString(), 'absolute'])); await runSeek(position, () => command(['seek', (sourcePosition.inMilliseconds / 1000.0).toString(), 'absolute']));
} }
@override
Future<void> setNext(Media? media) async {
if (!audioOnly || disposed || !initialized) return;
if (_hasArmedNext) {
_hasArmedNext = false;
_armedNextUri = null;
appLogger.d('MPV-audio: clearing armed entry (playlist-remove 1)');
try {
await command(['playlist-remove', '1']);
} on PlatformException {
// Entry 1 can vanish in the arm/advance race (mpv already rolled into
// it); the append below still lands after the current entry.
}
}
if (media == null) return;
// Per-entry options are the 4th loadfile argument on mpv >= 0.38
// (`loadfile <url> append -1 opt=val`), exactly like open() passes
// sub-files. `gapless-audio=weak` splices the armed entry into the
// running audio stream when formats match.
final args = ['loadfile', media.uri, 'append'];
final headerOption = _httpHeaderFieldsLoadfileOption(media.headers);
if (headerOption != null) {
args.addAll(['-1', headerOption]);
}
await command(args);
_hasArmedNext = true;
_armedNextUri = media.uri;
appLogger.d('MPV-audio: armed next ${_uriTail(media.uri)}');
}
@override
void handlePropertyChange(String name, dynamic value) {
if (audioOnly && name == 'playlist-pos') {
// Debug aid only — see _handleAudioFileLoaded for the real detection.
appLogger.d('MPV-audio: playlist-pos=$value (armed=$_hasArmedNext)');
return;
}
super.handlePropertyChange(name, value);
}
@override
void handlePlayerEvent(String name, Map? data) {
if (audioOnly && name == 'file-loaded') _handleAudioFileLoaded();
super.handlePlayerEvent(name, data);
}
/// Gapless auto-advance detection: a `file-loaded` that open() didn't
/// produce while an entry is armed means mpv rolled into the armed entry.
/// Surface the transition, then rebase the playlist so the now playing
/// entry sits at index 0 again ([setNext] always appends at 1). The rebase
/// only removes the spent entry behind the playing one, so it cannot
/// disturb position/duration — those refresh with the same file-loaded.
///
/// Detection deliberately rides this EVENT, not `playlist-pos` property
/// edges: mpv coalesces observed-property notifications per observer
/// (1→0→1 under delivery lag nets out to nothing) and the Android bridge
/// additionally drops property changes when its shared 64-slot buffer
/// overflows (`MutableSharedFlow.tryEmit` from the native event thread),
/// so an edge can vanish entirely — which stalled playback at the end of
/// the armed track. `file-loaded` fires exactly once per started file on
/// the low-volume event flow. Clearing [_hasArmedNext] before emitting
/// makes a hypothetical duplicate signal a no-op (it cannot double
/// advance).
void _handleAudioFileLoaded() {
if (_expectOpenFileLoad) {
_expectOpenFileLoad = false;
appLogger.d('MPV-audio: file-loaded (open)');
return;
}
if (!_hasArmedNext) {
appLogger.d('MPV-audio: file-loaded (nothing armed, ignored)');
return;
}
final uri = _armedNextUri;
_hasArmedNext = false;
_armedNextUri = null;
appLogger.d('MPV-audio: transition (file-loaded) → playlist-remove 0, ${_uriTail(uri ?? '')}');
unawaited(command(['playlist-remove', '0']));
if (uri != null) trackTransitionController.add(uri);
}
@override @override
Future<void> selectAudioTrack(AudioTrack track) async { Future<void> selectAudioTrack(AudioTrack track) async {
await setProperty('aid', track.id); await setProperty('aid', track.id);
@@ -346,7 +491,7 @@ class PlayerNative extends PlayerBase {
@override @override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async { Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (disposed || !Platform.isIOS) return; if (disposed || audioOnly || !Platform.isIOS) return;
await _ensureInitialized(); await _ensureInitialized();
await invoke('setDisplayCriteria', { await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(), 'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
@@ -23,6 +23,7 @@ mixin PlayerStreamControllersMixin {
final playbackRestartController = StreamController<void>.broadcast(); final playbackRestartController = StreamController<void>.broadcast();
final fileLoadedController = StreamController<void>.broadcast(); final fileLoadedController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast(); final backendSwitchedController = StreamController<void>.broadcast();
final trackTransitionController = StreamController<String>.broadcast();
PlayerStreams createStreams() { PlayerStreams createStreams() {
return PlayerStreams( return PlayerStreams(
@@ -45,6 +46,7 @@ mixin PlayerStreamControllersMixin {
playbackRestart: playbackRestartController.stream, playbackRestart: playbackRestartController.stream,
fileLoaded: fileLoadedController.stream, fileLoaded: fileLoadedController.stream,
backendSwitched: backendSwitchedController.stream, backendSwitched: backendSwitchedController.stream,
trackTransition: trackTransitionController.stream,
); );
} }
@@ -68,5 +70,6 @@ mixin PlayerStreamControllersMixin {
await playbackRestartController.close(); await playbackRestartController.close();
await fileLoadedController.close(); await fileLoadedController.close();
await backendSwitchedController.close(); await backendSwitchedController.close();
await trackTransitionController.close();
} }
} }
+7
View File
@@ -63,6 +63,12 @@ class PlayerStreams {
/// Only emitted on Android when ExoPlayer encounters an unsupported format. /// Only emitted on Android when ExoPlayer encounters an unsupported format.
final Stream<void> backendSwitched; final Stream<void> backendSwitched;
/// Emits the URI the backend auto-advanced into after playing out the
/// current item, when a next item was pre-armed via [Player.setNext]
/// (gapless music). Only audio players emit this; the value is the armed
/// [Media.uri].
final Stream<String> trackTransition;
const PlayerStreams({ const PlayerStreams({
required this.playing, required this.playing,
required this.completed, required this.completed,
@@ -83,5 +89,6 @@ class PlayerStreams {
required this.playbackRestart, required this.playbackRestart,
this.fileLoaded = const Stream<void>.empty(), this.fileLoaded = const Stream<void>.empty(),
required this.backendSwitched, required this.backendSwitched,
this.trackTransition = const Stream<String>.empty(),
}); });
} }
+13 -3
View File
@@ -19,9 +19,12 @@ import '../providers/playback_state_provider.dart';
import '../providers/trakt_account_provider.dart'; import '../providers/trakt_account_provider.dart';
import '../providers/trackers_provider.dart'; import '../providers/trackers_provider.dart';
import '../providers/watch_state_store.dart'; import '../providers/watch_state_store.dart';
import '../database/app_database.dart';
import '../screens/main_screen.dart'; import '../screens/main_screen.dart';
import '../services/api_cache.dart'; import '../services/api_cache.dart';
import '../services/music/music_playback_service.dart'; import '../services/music/music_playback_service.dart';
import '../services/music/music_playback_service_impl.dart';
import '../services/offline_watch_sync_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../watch_together/providers/watch_together_provider.dart'; import '../watch_together/providers/watch_together_provider.dart';
@@ -166,9 +169,16 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
}, },
), ),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
// Stub until the music playback engine binds a real service; // Profile-session scope so a profile switch tears the music
// profile-session scope so a profile switch ends the session. // session down (dispose stops playback + releases the audio
ChangeNotifierProvider<MusicPlaybackService>(create: (context) => StubMusicPlaybackService()), // core).
ChangeNotifierProvider<MusicPlaybackService>(
create: (context) => MusicPlaybackServiceImpl(
serverManager: context.read<MultiServerProvider>().serverManager,
database: context.read<AppDatabase>(),
offlineWatchService: context.read<OfflineWatchSyncService>(),
),
),
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (context) { create: (context) {
+8
View File
@@ -47,6 +47,7 @@ import '../services/episode_navigation_service.dart';
import '../services/app_foreground_service.dart'; import '../services/app_foreground_service.dart';
import '../services/apple_tv_remote_touch_service.dart'; import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart'; import '../services/media_controls_manager.dart';
import '../services/playback_coordinator.dart';
import '../services/playback_initialization_service.dart'; import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart'; import '../services/playback_context.dart';
import '../services/local_playback_history.dart'; import '../services/local_playback_history.dart';
@@ -668,6 +669,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
FullscreenStateManager().addListener(_onFullscreenChanged); FullscreenStateManager().addListener(_onFullscreenChanged);
} }
// One-native-instance rule: a live music session owns the only audio
// core — stop it and wait for its dispose before constructing the
// video core (see PlaybackCoordinator).
initPhase = 'claiming playback session';
await PlaybackCoordinator.instance.claimVideo();
if (!mounted) return;
initPhase = 'creating player'; initPhase = 'creating player';
final currentPlayer = Player(useExoPlayer: useExoPlayer); final currentPlayer = Player(useExoPlayer: useExoPlayer);
player = currentPlayer; player = currentPlayer;
+19 -4
View File
@@ -876,8 +876,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
/// since those preserve the container shape (Series rows, PlaylistItemId). /// since those preserve the container shape (Series rows, PlaylistItemId).
/// ///
@override @override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) { Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes); final items = await _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes);
if (items.isNotEmpty) return items;
// Jellyfin links music to artists via *tags*, not the folder tree — a
// MusicArtist is usually not its tracks' ancestor, so the recursive
// `ParentId` query above comes back empty for tag-only artists (folder-
// backed artists resolve on the first query and never reach this).
// Retry once by album-artist credit, tracks only.
return _fetchAllPlayableDescendants(parentId, includeItemTypes: 'Audio', byAlbumArtist: true);
} }
/// Playable video descendants for a folder browse row. This includes /// Playable video descendants for a folder browse row. This includes
@@ -887,7 +894,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes); return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableFolderDescendantTypes);
} }
Future<List<MediaItem>> _fetchAllPlayableDescendants(String parentId, {required String includeItemTypes}) async { Future<List<MediaItem>> _fetchAllPlayableDescendants(
String parentId, {
required String includeItemTypes,
bool byAlbumArtist = false,
}) async {
final all = <MediaItem>[]; final all = <MediaItem>[];
var start = 0; var start = 0;
while (true) { while (true) {
@@ -896,6 +907,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
start: start, start: start,
size: _pagedListPageSize, size: _pagedListPageSize,
includeItemTypes: includeItemTypes, includeItemTypes: includeItemTypes,
byAlbumArtist: byAlbumArtist,
); );
if (page.items.isEmpty) break; if (page.items.isEmpty) break;
all.addAll(page.items); all.addAll(page.items);
@@ -927,6 +939,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
int? size, int? size,
AbortController? abort, AbortController? abort,
required String includeItemTypes, required String includeItemTypes,
bool byAlbumArtist = false,
}) async { }) async {
final offset = start ?? 0; final offset = start ?? 0;
final pageSize = size ?? _pagedListPageSize; final pageSize = size ?? _pagedListPageSize;
@@ -934,7 +947,9 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'/Items', '/Items',
queryParameters: { queryParameters: {
'userId': connection.userId, 'userId': connection.userId,
'ParentId': parentId, // Tag-linked music artists have no folder descendants; the retry in
// [fetchPlayableDescendants] expands them by album-artist credit.
if (byAlbumArtist) 'AlbumArtistIds': parentId else 'ParentId': parentId,
'Recursive': 'true', 'Recursive': 'true',
'IncludeItemTypes': includeItemTypes, 'IncludeItemTypes': includeItemTypes,
'StartIndex': offset.toString(), 'StartIndex': offset.toString(),
+9
View File
@@ -4,6 +4,7 @@ import 'package:rate_limiter/rate_limiter.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_item_types.dart'; import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback. /// Manages OS media controls integration for video playback.
@@ -59,6 +60,8 @@ class MediaControlsManager {
MediaMetadata( MediaMetadata(
title: metadata.title ?? '', title: metadata.title ?? '',
artist: _buildArtist(metadata), artist: _buildArtist(metadata),
// Music-only: null for video content, so video behavior is untouched.
album: metadata.kind == MediaKind.track ? metadata.albumTitle : null,
artworkUrl: artworkUrl, artworkUrl: artworkUrl,
duration: duration, duration: duration,
), ),
@@ -185,10 +188,16 @@ class MediaControlsManager {
/// Build artist string from metadata /// Build artist string from metadata
/// ///
/// For music tracks: the performing artist
/// For episodes: "Show Name - Season X Episode Y" /// For episodes: "Show Name - Season X Episode Y"
/// For movies: Director or studio /// For movies: Director or studio
/// For other content: Fallback to year or empty /// For other content: Fallback to year or empty
String _buildArtist(MediaItem metadata) { String _buildArtist(MediaItem metadata) {
if (metadata.kind == MediaKind.track) {
// Performing artist with album-artist fallback (compilations store the
// track's own artist separately).
return metadata.trackArtistTitle ?? '';
}
if (metadata.isEpisode) { if (metadata.isEpisode) {
final parts = <String>[]; final parts = <String>[];
@@ -110,6 +110,22 @@ abstract class MusicPlaybackService extends ChangeNotifier {
/// Stop playback and clear the session (mini-player disappears). /// Stop playback and clear the session (mini-player disappears).
Future<void> stop(); Future<void> stop();
/// Whether a sleep timer (timed or end-of-track) is armed.
bool get sleepTimerActive;
/// When the timed sleep timer fires; null in end-of-track mode or when
/// inactive.
DateTime? get sleepTimerEndsAt;
/// Whether the sleep timer pauses at the end of the current track instead
/// of after a fixed duration.
bool get sleepTimerEndOfTrack;
/// Arm the sleep timer: a fixed [duration], or [endOfTrack] to pause when
/// the current track finishes. Pass `null` with `endOfTrack: false` to
/// cancel. Fires as a pause (session stays); cancelled by [stop].
void setSleepTimer(Duration? duration, {bool endOfTrack = false});
/// Lyrics for [track] (defaults to the current track's backend). Delegates /// Lyrics for [track] (defaults to the current track's backend). Delegates
/// to `MediaServerClient.fetchLyrics`; null = none available. /// to `MediaServerClient.fetchLyrics`; null = none available.
Future<Lyrics?> fetchLyrics(MediaItem track); Future<Lyrics?> fetchLyrics(MediaItem track);
@@ -211,6 +227,18 @@ class StubMusicPlaybackService extends MusicPlaybackService {
@override @override
Future<void> stop() async {} Future<void> stop() async {}
@override
bool get sleepTimerActive => false;
@override
DateTime? get sleepTimerEndsAt => null;
@override
bool get sleepTimerEndOfTrack => false;
@override
void setSleepTimer(Duration? duration, {bool endOfTrack = false}) {}
@override @override
Future<Lyrics?> fetchLyrics(MediaItem track) async => null; Future<Lyrics?> fetchLyrics(MediaItem track) async => null;
} }
@@ -0,0 +1,973 @@
import 'dart:async';
import 'package:os_media_controls/os_media_controls.dart';
import '../../database/app_database.dart';
import '../../media/ids.dart';
import '../../media/lyrics.dart';
import '../../media/media_item.dart';
import '../../media/media_server_client.dart';
import '../../mpv/models.dart';
import '../../mpv/player/player.dart';
import '../../utils/app_logger.dart';
import '../media_controls_manager.dart';
import '../multi_server_manager.dart';
import '../offline_watch_sync_service.dart';
import '../playback_coordinator.dart';
import '../playback_progress_tracker.dart';
import 'music_playback_service.dart';
import 'music_queue_controller.dart';
import 'music_source_resolver.dart';
/// A gapless-armed next track: what [Player.setNext] was fed, so the
/// trackTransition event can be mapped back to a queue entry and its
/// already-resolved source reused without a second server round-trip.
class _ArmedTrack {
final MediaItem track;
final MusicSource source;
const _ArmedTrack({required this.track, required this.source});
}
/// Real music playback engine: owns the audio [Player], the queue
/// (via [MusicQueueController]), gapless arming, per-track server progress
/// reporting, and the OS media session.
///
/// ### Advancement paths
/// * **Gapless (normal):** after a track starts, the next queue entry is
/// resolved and armed via [Player.setNext]. When the backend auto-advances
/// it emits `trackTransition(uri)` — treated as the authoritative advance:
/// the finished track's tracker reports `stopped` at its full duration,
/// the cursor moves to the armed entry, services rebind, and the new next
/// is armed.
/// * **Completed fallback:** `completed` with nothing armed means either the
/// queue truly ended (repeat off, last track) — the session parks
/// `paused` at the end, keeping [currentTrack] so the mini-player stays —
/// or arming failed, in which case the next track is opened explicitly.
/// * **Manual:** next/previous/jumpTo/removeAt-current finalize the current
/// tracker at its *current* position and open the target directly.
///
/// ### Errors
/// Player/resolver failures surface on [errors] (for a snackbar) and
/// auto-skip to the next track; three consecutive failures without playback
/// progress stop the session with [MusicPlaybackStatus.error].
class MusicPlaybackServiceImpl extends MusicPlaybackService {
MusicPlaybackServiceImpl({
required MultiServerManager serverManager,
AppDatabase? database,
this._offlineWatchService,
MusicSourceResolver? resolver,
this._audioPlayerFactory = Player.audio,
this._mediaControlsFactory = MediaControlsManager.new,
this._completedConfirmDelay = const Duration(milliseconds: 400),
PlaybackCoordinator? coordinator,
}) : assert(resolver != null || database != null, 'database is required to build the default resolver'),
_serverManager = serverManager,
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
}
static const _previousRestartThreshold = Duration(seconds: 3);
static const _maxConsecutiveFailures = 3;
/// How long a completed (eof-reached) signal must stay uncontradicted
/// before it is treated as a genuine queue end — long enough for the
/// boundary pulse's own eof-reached=false / transition to arrive, short
/// enough to be imperceptible at a real queue end (see [_onCompleted]).
/// Injectable so tests can collapse the confirmation window.
final Duration _completedConfirmDelay;
final MultiServerManager _serverManager;
final OfflineWatchSyncService? _offlineWatchService;
final MusicSourceResolver _resolver;
final Player Function() _audioPlayerFactory;
final MediaControlsManager Function() _mediaControlsFactory;
final PlaybackCoordinator _coordinator;
final MusicQueueController _queue = MusicQueueController();
Player? _player;
final List<StreamSubscription<Object?>> _playerSubs = [];
MediaControlsManager? _mediaControls;
StreamSubscription<MediaControlEvent>? _controlEventsSub;
MusicPlaybackStatus _status = MusicPlaybackStatus.idle;
MediaItem? _currentTrack;
MusicSource? _currentSource;
MusicPlayContext? _playContext;
PlaybackProgressTracker? _tracker;
_ArmedTrack? _armed;
Timer? _completedConfirmTimer;
/// Bumped on every open/advance/stop so stale async continuations
/// (resolves, opens, arms) drop out instead of acting on the new state.
int _generation = 0;
int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false;
bool _disposed = false;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
bool _sleepTimerEndOfTrack = false;
final StreamController<Duration> _positionController = StreamController<Duration>.broadcast();
final StreamController<Object> _errorsController = StreamController<Object>.broadcast();
// ---------------------------------------------------------------------
// Getters
// ---------------------------------------------------------------------
@override
bool get isAvailable => true;
@override
MediaItem? get currentTrack => _currentTrack;
@override
MusicPlaybackStatus get status => _status;
@override
Duration? get duration {
if (_currentTrack == null) return null;
final playerDuration = _player?.state.duration ?? Duration.zero;
if (playerDuration > Duration.zero) return playerDuration;
final ms = _currentTrack?.durationMs;
return ms != null ? Duration(milliseconds: ms) : null;
}
@override
Duration get position => _player?.currentPosition ?? Duration.zero;
@override
Stream<Duration> get positionStream => _positionController.stream;
@override
List<MediaItem> get queue => _queue.queue;
@override
int get currentIndex => _queue.cursor;
@override
MusicPlayContext? get playContext => _playContext;
@override
bool get shuffled => _queue.shuffled;
@override
MusicRepeatMode get repeatMode => _queue.repeatMode;
@override
Stream<Object> get errors => _errorsController.stream;
@override
bool get sleepTimerActive => _sleepTimer != null || _sleepTimerEndOfTrack;
@override
DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt;
@override
bool get sleepTimerEndOfTrack => _sleepTimerEndOfTrack;
// ---------------------------------------------------------------------
// Session start
// ---------------------------------------------------------------------
@override
Future<void> playFromList({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
}) {
return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle);
}
@override
Future<void> playInstantMix(MediaItem seed) async {
final client = _clientFor(seed);
if (client == null) {
_errorsController.add(StateError('No server available for instant mix'));
return;
}
List<MediaItem> tracks;
try {
tracks = await client.fetchInstantMix(seed.id);
} catch (e, st) {
appLogger.w('Instant mix fetch failed for ${seed.id}', error: e, stackTrace: st);
_errorsController.add(e);
return;
}
if (_disposed || tracks.isEmpty) return;
await _startQueue(
tracks: tracks,
playContext: MusicPlayContext(title: seed.displayTitle, kind: MusicPlayContextKind.mix),
);
}
Future<void> _startQueue({
required List<MediaItem> tracks,
MediaItem? startTrack,
required MusicPlayContext playContext,
bool shuffle = false,
bool autoplay = true,
}) async {
if (tracks.isEmpty || _disposed) return;
final generation = ++_generation;
_finalizeCurrentTrack();
var startIndex = 0;
if (startTrack != null) {
startIndex = tracks.indexWhere((t) => t.globalKey == startTrack.globalKey);
if (startIndex < 0) startIndex = 0;
}
_queue.load(tracks, startIndex: startIndex, shuffle: shuffle);
_playContext = playContext;
_consecutiveFailures = 0;
await _openCurrent(generation, play: autoplay);
}
// ---------------------------------------------------------------------
// Opening / advancing
// ---------------------------------------------------------------------
/// Resolve and open the queue's current track. All failure handling funnels
/// through [_handlePlaybackFailure].
Future<void> _openCurrent(int generation, {bool play = true}) async {
final track = _queue.current;
if (track == null) return;
_currentTrack = track;
_currentSource = null;
_armed = null;
_setStatus(MusicPlaybackStatus.loading, forceNotify: true);
await _coordinator.claimMusic();
if (generation != _generation) return;
final player = _ensurePlayer();
_ensureMediaControls();
// Clear any native arm left over from the previous item before the open
// replaces it, so a stray transition can't fire mid-switch.
try {
await player.setNext(null);
} catch (e) {
appLogger.d('setNext(null) before open failed', error: e);
}
MusicSource source;
try {
source = await _resolver.resolve(track);
} catch (e, st) {
appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st);
if (generation == _generation) _handlePlaybackFailure(e);
return;
}
if (generation != _generation || _player != player) return;
_currentSource = source;
// Claim audio focus before audio starts so other media apps pause (mpv
// has no built-in focus handling; harmless no-op off Android). Result is
// ignored — mirrors the video screen, playback proceeds either way.
try {
await player.requestAudioFocus();
} catch (e) {
appLogger.d('Audio focus request failed', error: e);
}
if (generation != _generation || _player != player) return;
try {
await player.open(Media(source.url, headers: source.headers), play: play);
} catch (e, st) {
appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st);
if (generation == _generation) _handlePlaybackFailure(e);
return;
}
if (generation != _generation || _player != player) return;
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
_bindTrackServices(track, source);
unawaited(_armNext(generation));
}
/// Manual advance: finalize the current tracker at its current position and
/// open the queue entry at [cursor].
Future<void> _advanceTo(int cursor, {bool play = true}) async {
final generation = ++_generation;
_finalizeCurrentTrack();
_queue.jumpTo(cursor);
await _openCurrent(generation, play: play);
}
/// Arm (or clear) what the backend should auto-advance into. Skips the
/// resolve round-trip when the desired target is already armed; repeat-one
/// reuses the current track's resolved source.
Future<void> _armNext(int generation) async {
final player = _player;
if (player == null || generation != _generation) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
if (target == null) {
if (_armed == null) return;
appLogger.d('Music: clearing arm (queue end / end-of-track sleep)');
_armed = null;
await _trySetNext(player, null);
return;
}
if (_armed?.track.globalKey == target.globalKey) return;
_armed = null;
await _trySetNext(player, null);
if (generation != _generation || _player != player) return;
MusicSource source;
if (targetCursor == _queue.cursor && _currentSource != null) {
// Repeat-one: the same file plays again — reuse the resolved source.
source = _currentSource!;
} else {
try {
source = await _resolver.resolve(target);
} catch (e, st) {
// Fail soft: with nothing armed, the completed event falls back to
// an explicit open of the next track (which retries the resolve).
appLogger.w('Gapless arm resolve failed for ${target.id}', error: e, stackTrace: st);
return;
}
}
if (generation != _generation || _player != player) return;
_armed = _ArmedTrack(track: target, source: source);
appLogger.d('Music: arming cursor $targetCursor "${target.title}"');
final ok = await _trySetNext(player, Media(source.url, headers: source.headers));
if (!ok && generation == _generation && _player == player) {
// Nothing is armed natively; clear the record so the confirmed
// completed fallback can advance explicitly instead of waiting for a
// transition that can never come.
_armed = null;
}
}
Future<bool> _trySetNext(Player player, Media? media) async {
try {
await player.setNext(media);
return true;
} catch (e) {
appLogger.w('setNext failed', error: e);
return false;
}
}
/// Re-arm only when queue/mode changes altered what plays next — queue
/// edits that keep the same next track cost no server round-trip.
void _rearmIfNeeded() {
if (_player == null || _currentTrack == null) return;
final targetCursor = _sleepTimerEndOfTrack ? null : _queue.nextIndex();
final target = targetCursor == null ? null : _queue.trackAt(targetCursor);
if (target == null && _armed == null) return;
if (target != null && _armed?.track.globalKey == target.globalKey) return;
unawaited(_armNext(_generation));
}
// ---------------------------------------------------------------------
// Player events
// ---------------------------------------------------------------------
Player _ensurePlayer() {
final existing = _player;
if (existing != null && !existing.disposed) return existing;
final player = _audioPlayerFactory();
_player = player;
_wirePlayerStreams(player);
return player;
}
void _wirePlayerStreams(Player player) {
for (final sub in _playerSubs) {
sub.cancel();
}
_playerSubs
..clear()
..add(player.streams.position.listen(_onPosition))
..add(player.streams.playing.listen(_onPlayingChanged))
..add(player.streams.trackTransition.listen(_onTrackTransition))
..add(player.streams.completed.listen(_onCompleted))
..add(player.streams.error.listen(_onPlayerError));
}
void _onPosition(Duration position) {
_positionController.add(position);
// Real playback progress proves the pipeline recovered — reset the
// consecutive-failure strike counter.
if (_consecutiveFailures != 0 && position > Duration.zero && _status == MusicPlaybackStatus.playing) {
_consecutiveFailures = 0;
}
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(isPlaying: player.state.isActive, position: position, speed: 1.0);
}
}
void _onPlayingChanged(bool isPlaying) {
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
_setStatus(isPlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
unawaited(_tracker?.sendProgress(isPlaying ? 'playing' : 'paused'));
}
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(
isPlaying: player.state.isActive,
position: player.currentPosition,
speed: 1.0,
force: true,
);
}
}
/// The backend auto-advanced into the pre-armed item: authoritative
/// track change.
void _onTrackTransition(String uri) {
final armed = _armed;
if (armed == null || armed.source.url != uri) {
appLogger.w('Unexpected track transition to $uri (armed: ${armed?.source.url})');
return;
}
_armed = null;
final generation = ++_generation;
// The finished track played out fully — report stopped at its duration.
final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
// Move the cursor to the armed entry: the expected natural-next when it
// still matches, otherwise wherever the armed track now sits.
final expected = _queue.nextIndex();
if (expected != null && _queue.trackAt(expected)?.globalKey == armed.track.globalKey) {
_queue.jumpTo(expected);
} else {
final index = _queue.queue.indexWhere((t) => t.globalKey == armed.track.globalKey);
if (index >= 0) _queue.jumpTo(index);
}
_currentTrack = _queue.current ?? armed.track;
_currentSource = armed.source;
_consecutiveFailures = 0;
appLogger.d('Music: transition received "${armed.track.title}" → cursor ${_queue.cursor}');
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
_bindTrackServices(_currentTrack!, armed.source);
unawaited(_armNext(generation));
}
/// Completed (eof-reached) is NOT a last-entry-only signal: mpv pulses it
/// at every gapless boundary (the audio of the finished entry drains
/// before the armed entry starts), and its delivery order against the
/// trackTransition event is not guaranteed. A boundary pulse that lands
/// after the transition already cleared [_armed] (re-arm still resolving)
/// looks exactly like "queue advanced with nothing armed" — acting on it
/// immediately double-advanced the queue (skipped a track, cut off the
/// just-started file; live-captured on Android). So never act on the raw
/// pulse: confirm it is stable first. A boundary pulse is followed by
/// eof-reached=false / a transition within milliseconds (which resets
/// `state.completed` and bumps [_generation]); at a genuine queue end,
/// sleep-at-end-of-track, or failed arm it stays true, and the confirmed
/// handler advances explicitly or parks.
void _onCompleted(bool done) {
if (!done || _currentTrack == null || _status == MusicPlaybackStatus.idle) return;
appLogger.d('Music: completed received (armed=${_armed != null}, cursor ${_queue.cursor})');
if (_armed != null) return; // The backend advances; trackTransition handles it.
final generation = _generation;
_completedConfirmTimer?.cancel();
_completedConfirmTimer = Timer(_completedConfirmDelay, () {
_completedConfirmTimer = null;
if (_disposed || generation != _generation || _armed != null) return;
if (_player?.state.completed != true) return; // stale boundary pulse
appLogger.d('Music: completed confirmed (cursor ${_queue.cursor})');
_handleQueueCompleted();
});
}
/// Confirmed end of the current file with nothing armed: queue end,
/// sleep-at-end-of-track, or a failed arm (fall back to an explicit open).
void _handleQueueCompleted() {
if (_sleepTimerEndOfTrack) {
_sleepTimerEndOfTrack = false;
_parkAtEnd();
return;
}
final nextCursor = _queue.nextIndex();
if (nextCursor != null) {
unawaited(_advanceTo(nextCursor));
return;
}
_parkAtEnd();
}
/// Queue played out: report the final track stopped at its duration and
/// park paused at the end. [currentTrack] stays set so the mini-player
/// remains; pressing play restarts the current track from the top.
void _parkAtEnd() {
_generation++;
final finishedMs = _currentTrack?.durationMs;
_finalizeCurrentTrack(positionOverride: finishedMs != null ? Duration(milliseconds: finishedMs) : null);
_setStatus(MusicPlaybackStatus.paused, forceNotify: true);
final player = _player;
if (player != null) {
_mediaControls?.updatePlaybackState(isPlaying: false, position: player.currentPosition, speed: 1.0, force: true);
}
}
void _onPlayerError(PlayerError error) {
if (_status == MusicPlaybackStatus.idle || _status == MusicPlaybackStatus.error) return;
appLogger.w('Music player error: $error');
_handlePlaybackFailure(error);
}
/// Shared recovery for resolve/open/player errors: surface, then skip to
/// the next track; three consecutive strikes stop the session as failed.
void _handlePlaybackFailure(Object error) {
_errorsController.add(error);
_consecutiveFailures++;
if (_consecutiveFailures >= _maxConsecutiveFailures) {
unawaited(_stopSession(endStatus: MusicPlaybackStatus.error));
return;
}
final nextCursor = _queue.nextIndex(manual: true);
if (nextCursor == null) {
unawaited(_stopSession(endStatus: MusicPlaybackStatus.error));
return;
}
unawaited(_advanceTo(nextCursor));
}
// ---------------------------------------------------------------------
// Per-track services (progress reporting + OS media controls)
// ---------------------------------------------------------------------
/// (Re)bind the per-track progress tracker and media-session metadata —
/// the music mirror of the video screen's `_wirePerItemPlaybackServices`.
/// The previous track must already be finalized.
void _bindTrackServices(MediaItem track, MusicSource source) {
_tracker?.dispose();
_tracker = null;
final player = _player;
if (player == null) return;
final client = source.reportingClient;
if (client != null) {
_tracker = PlaybackProgressTracker(
client: client,
metadata: track,
player: player,
offlineWatchService: _offlineWatchService,
// Local files keep reporting online but queue locally when the
// server rejects the report — same policy as downloaded video.
queueOnOnlineFailure: source.isOffline && _offlineWatchService != null,
playMethod: source.playMethod ?? 'DirectPlay',
playSessionId: source.playSessionId,
mediaInfo: source.mediaInfo,
)..startTracking();
} else if (source.isOffline && _offlineWatchService != null) {
_tracker = PlaybackProgressTracker(
client: null,
metadata: track,
player: player,
isOffline: true,
offlineWatchService: _offlineWatchService,
)..startTracking();
}
final controls = _mediaControls;
if (controls != null) {
unawaited(
controls.updateMetadata(
metadata: track,
client: client ?? _clientFor(track),
duration: track.durationMs != null ? Duration(milliseconds: track.durationMs!) : null,
),
);
_syncControlsAvailability();
}
}
/// Stop tracking and fire the final `stopped` report for the current
/// track (fire-and-forget; report sessions are per track so the next
/// track's `started` can overlap safely).
void _finalizeCurrentTrack({Duration? positionOverride}) {
final tracker = _tracker;
_tracker = null;
if (tracker == null) return;
tracker.stopTracking();
unawaited(
tracker.sendStoppedProgressOnce(positionOverride: positionOverride).catchError((Object e) {
appLogger.d('Final music progress report failed', error: e);
}),
);
}
void _ensureMediaControls() {
if (_mediaControls != null) return;
final controls = _mediaControlsFactory();
_mediaControls = controls;
_controlEventsSub = controls.controlEvents.listen(_onControlEvent);
}
void _syncControlsAvailability() {
unawaited(
_mediaControls?.setControlsEnabled(
canGoNext: _queue.nextIndex(manual: true) != null,
// Previous always restarts the track even at queue head.
canGoPrevious: true,
canSeek: true,
),
);
}
void _onControlEvent(MediaControlEvent event) {
if (_disposed || _currentTrack == null) return;
if (event is PlayEvent) {
unawaited(play());
} else if (event is PauseEvent) {
unawaited(pause());
} else if (event is TogglePlayPauseEvent) {
unawaited(togglePlayPause());
} else if (event is NextTrackEvent) {
unawaited(next());
} else if (event is PreviousTrackEvent) {
unawaited(previous());
} else if (event is SeekEvent) {
unawaited(seek(event.position));
} else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) {
// Remember whether we were playing so interruption-end/route-return
// can resume. Unlike video, music resumes even while backgrounded —
// background audio is the product.
_resumeAfterInterruption = _player?.state.isActive ?? false;
unawaited(pause());
} else if (event is AudioInterruptionEndedEvent) {
if (event.shouldResume && _resumeAfterInterruption) {
_resumeAfterInterruption = false;
unawaited(play());
} else {
_resumeAfterInterruption = false;
}
} else if (event is AudioRouteNewDeviceAvailableEvent) {
if (_resumeAfterInterruption) {
_resumeAfterInterruption = false;
unawaited(play());
}
}
}
// ---------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------
@override
Future<void> play() async {
final player = _player;
if (player == null || _currentTrack == null) return;
if (player.state.completed) {
// Parked at queue end: restart the current track.
await player.seek(Duration.zero);
unawaited(_armNext(_generation));
}
await player.play();
_setStatus(MusicPlaybackStatus.playing);
}
@override
Future<void> pause() async {
final player = _player;
if (player == null) return;
await player.pause();
_setStatus(MusicPlaybackStatus.paused);
}
@override
Future<void> togglePlayPause() {
final player = _player;
if (player == null) return Future.value();
return player.state.isActive ? pause() : play();
}
@override
Future<void> next() async {
final nextCursor = _queue.nextIndex(manual: true);
if (nextCursor == null) return;
await _advanceTo(nextCursor);
}
@override
Future<void> previous() async {
final player = _player;
if (player == null) return;
if (player.currentPosition > _previousRestartThreshold) {
await player.seek(Duration.zero);
return;
}
final prevCursor = _queue.previousIndex();
if (prevCursor == null) {
await player.seek(Duration.zero);
return;
}
await _advanceTo(prevCursor);
}
@override
Future<void> seek(Duration position) async {
await _player?.seek(position);
}
@override
Future<void> jumpTo(int index) async {
if (index < 0 || index >= _queue.length || index == _queue.cursor) return;
await _advanceTo(index);
}
// ---------------------------------------------------------------------
// Queue / mode edits
// ---------------------------------------------------------------------
@override
void setRepeatMode(MusicRepeatMode mode) {
if (_queue.repeatMode == mode) return;
_queue.repeatMode = mode;
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void toggleShuffle() {
if (_queue.isEmpty) return;
_queue.toggleShuffle();
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void removeAt(int index) {
if (index < 0 || index >= _queue.length) return;
final wasCurrent = _queue.removeAt(index);
if (wasCurrent) {
if (_queue.isEmpty) {
unawaited(stop());
return;
}
// The cursor already points at what used to be next — open it.
unawaited(_advanceTo(_queue.cursor));
return;
}
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void reorder(int from, int to) {
if (from == to) return;
_queue.move(from, to);
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void addNext(List<MediaItem> tracks) => _enqueue(tracks, next: true);
@override
void addToEnd(List<MediaItem> tracks) => _enqueue(tracks, next: false);
/// Queue edits while idle start a session parked on the first added track
/// (mini-player appears paused) instead of silently dropping the action
/// or surprising the user with audio.
void _enqueue(List<MediaItem> tracks, {required bool next}) {
if (tracks.isEmpty) return;
if (_queue.isEmpty || _currentTrack == null) {
final first = tracks.first;
unawaited(
_startQueue(
tracks: tracks,
playContext: MusicPlayContext(
title: first.albumTitle ?? first.title ?? '',
kind: MusicPlayContextKind.tracks,
),
autoplay: false,
),
);
return;
}
if (next) {
_queue.addNext(tracks);
} else {
_queue.addToEnd(tracks);
}
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
@override
void clearUpcoming() {
if (_queue.isEmpty) return;
_queue.clearUpcoming();
_rearmIfNeeded();
_syncControlsAvailability();
notifyListeners();
}
// ---------------------------------------------------------------------
// Sleep timer
// ---------------------------------------------------------------------
@override
void setSleepTimer(Duration? duration, {bool endOfTrack = false}) {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
final hadEndOfTrack = _sleepTimerEndOfTrack;
_sleepTimerEndOfTrack = endOfTrack;
if (!endOfTrack && duration != null) {
_sleepTimerEndsAt = DateTime.now().add(duration);
_sleepTimer = Timer(duration, _onSleepTimerFired);
}
// End-of-track mode suppresses gapless arming (and leaving it restores
// the arm), so the track genuinely completes instead of transitioning.
if (hadEndOfTrack != _sleepTimerEndOfTrack) {
unawaited(_armNext(_generation));
}
notifyListeners();
}
void _onSleepTimerFired() {
_sleepTimer = null;
_sleepTimerEndsAt = null;
unawaited(pause());
notifyListeners();
}
void _cancelSleepTimer() {
_sleepTimer?.cancel();
_sleepTimer = null;
_sleepTimerEndsAt = null;
_sleepTimerEndOfTrack = false;
}
// ---------------------------------------------------------------------
// Stop / teardown
// ---------------------------------------------------------------------
@override
Future<void> stop() => _stopSession(endStatus: MusicPlaybackStatus.idle);
/// The coordinator's video claim uses the exact same full-stop path, so
/// the audio core is guaranteed disposed when it resolves.
Future<void> _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle);
Future<void> _stopSession({required MusicPlaybackStatus endStatus}) async {
_generation++;
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
_cancelSleepTimer();
_finalizeCurrentTrack();
_queue.clear();
_currentTrack = null;
_currentSource = null;
_armed = null;
_playContext = null;
_resumeAfterInterruption = false;
final player = _player;
_player = null;
for (final sub in _playerSubs) {
unawaited(sub.cancel());
}
_playerSubs.clear();
if (player != null && !player.disposed) {
try {
await player.stop();
} catch (e) {
appLogger.d('Audio player stop failed during session teardown', error: e);
}
try {
await player.abandonAudioFocus();
} catch (e) {
appLogger.d('Audio focus abandon failed during session teardown', error: e);
}
try {
await player.dispose();
} catch (e) {
appLogger.w('Audio player dispose failed during session teardown', error: e);
}
}
unawaited(_controlEventsSub?.cancel());
_controlEventsSub = null;
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.clear());
controls.dispose();
}
_setStatus(endStatus, forceNotify: true);
}
@override
Future<Lyrics?> fetchLyrics(MediaItem track) async {
final client = _clientFor(track);
if (client == null) return null;
return client.fetchLyrics(track);
}
MediaServerClient? _clientFor(MediaItem item) {
final serverId = serverIdOrNull(item.serverId);
if (serverId == null) return null;
return _serverManager.getClient(serverId);
}
void _setStatus(MusicPlaybackStatus status, {bool forceNotify = false}) {
if (_disposed) return;
if (_status == status && !forceNotify) return;
_status = status;
notifyListeners();
}
@override
void dispose() {
if (_disposed) return;
_disposed = true;
_coordinator.unregisterMusicSession(_stopForVideoClaim);
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
_cancelSleepTimer();
_finalizeCurrentTrack();
for (final sub in _playerSubs) {
unawaited(sub.cancel());
}
_playerSubs.clear();
unawaited(_controlEventsSub?.cancel());
_controlEventsSub = null;
final player = _player;
_player = null;
if (player != null && !player.disposed) {
unawaited(
player.abandonAudioFocus().catchError((Object e) {
appLogger.d('Audio focus abandon failed during dispose', error: e);
}),
);
unawaited(player.dispose());
}
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.clear());
controls.dispose();
}
unawaited(_positionController.close());
unawaited(_errorsController.close());
super.dispose();
}
}
@@ -0,0 +1,190 @@
import 'dart:math';
import '../../media/media_item.dart';
import 'music_playback_service.dart';
/// Pure, deterministic queue state for the music session — no I/O, no player.
///
/// Holds the canonical track list ([_items], insertion order) plus a playback
/// order ([_order], indexes into the canonical list; the identity permutation
/// while unshuffled) and the [cursor] into that playback order. Every index a
/// caller passes in ([jumpTo], [removeAt], [move]) is a *playback-order*
/// index — the same flat list the queue UI renders via [queue].
///
/// The controller only mutates state; deciding what to do about it (open a
/// new track, re-arm gapless, stop) is the service's job.
class MusicQueueController {
MusicQueueController({Random? random}) : _random = random ?? Random();
final Random _random;
/// Canonical tracks in the order they were loaded/enqueued. Restored as
/// the playback order when shuffle turns off.
final List<MediaItem> _items = [];
/// Playback order: indexes into [_items]. Identity when unshuffled.
List<int> _order = [];
int _cursor = -1;
bool _shuffled = false;
MusicRepeatMode repeatMode = MusicRepeatMode.off;
bool get isEmpty => _items.isEmpty;
int get length => _order.length;
bool get shuffled => _shuffled;
/// Position of the current track within the playback order; -1 when empty.
int get cursor => _cursor;
MediaItem? get current => trackAt(_cursor);
/// Full queue in playback order (what the UI renders).
List<MediaItem> get queue => [for (final i in _order) _items[i]];
MediaItem? trackAt(int queueIndex) =>
queueIndex >= 0 && queueIndex < _order.length ? _items[_order[queueIndex]] : null;
/// Replace the queue with [tracks], starting at [startIndex]. With
/// [shuffle] the start track is anchored first and the rest shuffle after
/// it (it keeps playing / plays first).
void load(List<MediaItem> tracks, {int startIndex = 0, bool shuffle = false}) {
_items
..clear()
..addAll(tracks);
_order = List.generate(tracks.length, (i) => i);
_shuffled = false;
_cursor = tracks.isEmpty ? -1 : startIndex.clamp(0, tracks.length - 1);
if (shuffle && tracks.isNotEmpty) _shuffleAnchoringCurrent();
}
void clear() {
_items.clear();
_order = [];
_cursor = -1;
_shuffled = false;
}
/// Playback-order position that plays after the current one, or null when
/// playback should end there. Natural advancement (`manual: false`)
/// honors repeat-one by returning the cursor itself; a user-initiated
/// next (`manual: true`) always steps to the following entry.
int? nextIndex({bool manual = false}) {
if (_order.isEmpty || _cursor < 0) return null;
if (repeatMode == MusicRepeatMode.one && !manual) return _cursor;
final next = _cursor + 1;
if (next < _order.length) return next;
return repeatMode == MusicRepeatMode.all ? 0 : null;
}
/// Playback-order position before the current one, or null when there is
/// none (the service restarts the current track in that case).
int? previousIndex() {
if (_order.isEmpty || _cursor < 0) return null;
final prev = _cursor - 1;
if (prev >= 0) return prev;
return repeatMode == MusicRepeatMode.all ? _order.length - 1 : null;
}
void jumpTo(int queueIndex) {
if (queueIndex < 0 || queueIndex >= _order.length) return;
_cursor = queueIndex;
}
/// Insert [tracks] directly after the current entry.
void addNext(List<MediaItem> tracks) {
if (tracks.isEmpty) return;
_order.insertAll(_cursor < 0 ? 0 : _cursor + 1, _append(tracks));
if (_cursor < 0) _cursor = 0;
}
void addToEnd(List<MediaItem> tracks) {
if (tracks.isEmpty) return;
_order.addAll(_append(tracks));
if (_cursor < 0) _cursor = 0;
}
List<int> _append(List<MediaItem> tracks) {
final first = _items.length;
_items.addAll(tracks);
return List.generate(tracks.length, (i) => first + i);
}
/// Remove the queue entry at playback-order [queueIndex]. Returns true
/// when the removed entry was the current track — the cursor then points
/// at what used to be the next entry (or the new last entry when the
/// current one was last; -1 when the queue emptied), and the caller
/// decides whether to open it.
bool removeAt(int queueIndex) {
if (queueIndex < 0 || queueIndex >= _order.length) return false;
final wasCurrent = queueIndex == _cursor;
final itemIndex = _order.removeAt(queueIndex);
_items.removeAt(itemIndex);
for (var i = 0; i < _order.length; i++) {
if (_order[i] > itemIndex) _order[i]--;
}
if (queueIndex < _cursor) {
_cursor--;
} else if (_cursor >= _order.length) {
_cursor = _order.length - 1;
}
return wasCurrent;
}
/// Reorder the playback queue: move the entry at [from] to [to] (both
/// playback-order indexes). The cursor keeps tracking the current track.
void move(int from, int to) {
if (from < 0 || from >= _order.length || to < 0 || to >= _order.length || from == to) {
return;
}
final entry = _order.removeAt(from);
_order.insert(to, entry);
if (from == _cursor) {
_cursor = to;
} else if (from < _cursor && to >= _cursor) {
_cursor--;
} else if (from > _cursor && to <= _cursor) {
_cursor++;
}
}
/// Toggle shuffle. Turning it on anchors the current track first and
/// shuffles the rest after it; turning it off restores canonical order
/// with the cursor following the current track.
void toggleShuffle() {
if (_items.isEmpty) return;
if (_shuffled) {
final currentItem = _order[_cursor];
_order = List.generate(_items.length, (i) => i);
_cursor = currentItem;
_shuffled = false;
} else {
_shuffleAnchoringCurrent();
}
}
void _shuffleAnchoringCurrent() {
final anchor = _order[_cursor < 0 ? 0 : _cursor];
final rest = [
for (final i in _order)
if (i != anchor) i,
]..shuffle(_random);
_order = [anchor, ...rest];
_cursor = 0;
_shuffled = true;
}
/// Drop everything after the current entry (playback order), including
/// the underlying canonical items.
void clearUpcoming() {
if (_cursor < 0 || _cursor >= _order.length - 1) return;
final removedItemIndexes = _order.sublist(_cursor + 1)..sort();
_order.removeRange(_cursor + 1, _order.length);
for (final itemIndex in removedItemIndexes.reversed) {
_items.removeAt(itemIndex);
for (var i = 0; i < _order.length; i++) {
if (_order[i] > itemIndex) _order[i]--;
}
}
}
}
@@ -0,0 +1,103 @@
import '../../database/app_database.dart';
import '../../media/media_item.dart';
import '../../media/media_server_client.dart';
import '../../media/media_source_info.dart';
import '../../models/transcode_quality_preset.dart';
import '../../utils/session_identifier.dart';
import '../multi_server_manager.dart';
import '../playback_initialization_service.dart';
import '../playback_source_resolver.dart';
import '../settings_service.dart';
/// Everything the music engine needs to open and report one track.
class MusicSource {
/// Playable stream URL (or `file://` path for downloaded tracks).
final String url;
/// HTTP headers to open [url] with (Plex identity headers; null for
/// local files and backends that self-authenticate via the query string).
final Map<String, String>? headers;
/// Server playback session id to echo in progress reports.
final String? playSessionId;
/// `DirectPlay` / `Transcode` for progress reports.
final String? playMethod;
final int selectedMediaIndex;
final String? selectedMediaSourceId;
/// True when [url] points at a downloaded/local copy.
final bool isOffline;
final MediaSourceInfo? mediaInfo;
/// Client that should receive progress reports for this track (null when
/// its server is unreachable — offline reports queue locally instead).
final MediaServerClient? reportingClient;
const MusicSource({
required this.url,
this.headers,
this.playSessionId,
this.playMethod,
this.selectedMediaIndex = 0,
this.selectedMediaSourceId,
this.isOffline = false,
this.mediaInfo,
this.reportingClient,
});
}
/// Seam between the music engine and playback initialization, so tests can
/// inject synthetic sources without any network or database.
abstract class MusicSourceResolver {
Future<MusicSource> resolve(MediaItem track);
}
/// Production resolver: delegates to the shared [PlaybackSourceResolver] /
/// [PlaybackInitializationService] pipeline, which routes
/// [MediaKind.track] items down the per-backend audio path (music transcode
/// preset) and substitutes downloaded copies before touching the network.
class ServerMusicSourceResolver implements MusicSourceResolver {
final MultiServerManager serverManager;
final AppDatabase database;
ServerMusicSourceResolver({required this.serverManager, required this.database});
@override
Future<MusicSource> resolve(MediaItem track) async {
final settings = await SettingsService.getInstance();
final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve(
metadata: track,
selectedMediaIndex: 0,
offlineLibraryMode: false,
// Video-shaped preset is ignored for tracks; `original` also keeps the
// resolver's downloaded-copy preference on.
qualityPreset: TranscodeQualityPreset.original,
audioQualityPreset: settings.read(SettingsService.musicQualityPreset),
// Plex music transcode requires both session ids; fresh per track so
// concurrent gapless arming never reuses a live transcode session.
sessionIdentifier: generateSessionIdentifier(),
transcodeSessionId: generateSessionIdentifier(),
);
final result = context.result;
final url = result.videoUrl;
if (url == null) {
throw PlaybackException('No audio URL available for ${track.title ?? track.id}');
}
return MusicSource(
url: url,
headers: context.streamHeaders,
playSessionId: result.playSessionId,
playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'),
selectedMediaIndex: result.selectedMediaIndex,
selectedMediaSourceId: result.selectedMediaSourceId,
isOffline: result.isOffline,
mediaInfo: result.mediaInfo,
reportingClient: context.reportingClient,
);
}
}
+55
View File
@@ -0,0 +1,55 @@
import '../utils/app_logger.dart';
/// Arbitrates the one-native-player-instance rule between the music engine
/// and the video player.
///
/// Only one native playback core is kept alive at a time: the music
/// service's audio `Player` lives across screens, while the video core only
/// exists while the video player screen is open. The video screen calls
/// [claimVideo] at the very start of its player initialization so a playing
/// music session is fully stopped *and its native core disposed* before the
/// video core is constructed.
class PlaybackCoordinator {
PlaybackCoordinator._();
static final PlaybackCoordinator instance = PlaybackCoordinator._();
Future<void> Function()? _stopMusicSession;
/// Register the active music session's teardown. [stopAndDispose] must
/// stop playback, send final progress, and dispose the audio `Player`
/// before completing. Replaces any previous registration (there is one
/// music service per profile session).
void registerMusicSession({required Future<void> Function() stopAndDispose}) {
_stopMusicSession = stopAndDispose;
}
/// Remove [stopAndDispose] if it is the current registration. Passing the
/// same callback used to register keeps a stale unregister (from an
/// already-replaced session) from tearing down the new one.
void unregisterMusicSession(Future<void> Function() stopAndDispose) {
if (_stopMusicSession == stopAndDispose) _stopMusicSession = null;
}
/// Video playback is about to construct its native core: stop and dispose
/// any live music session first. Completes once the audio core is gone.
Future<void> claimVideo() async {
final stop = _stopMusicSession;
if (stop == null) return;
try {
await stop();
} catch (e, st) {
// The video player must still be able to start; a wedged audio core
// is strictly worse than a leaked stop error.
appLogger.w('PlaybackCoordinator: music session teardown failed', error: e, stackTrace: st);
}
}
/// Music playback is about to construct its audio core. Currently a no-op
/// guard: the video core only exists while the video player screen is
/// open, and music playback cannot be started from inside that screen —
/// leaving it disposes the video core before any music UI is reachable.
/// Kept as an explicit seam so a future "start music over video" flow has
/// a single place to add the reverse teardown.
Future<void> claimMusic() async {}
}
@@ -8,6 +8,7 @@ import '../media/media_item.dart';
import '../media/media_item_types.dart'; import '../media/media_item_types.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../media/media_source_info.dart'; import '../media/media_source_info.dart';
import '../models/audio_quality_preset.dart';
import '../models/download_models.dart'; import '../models/download_models.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import '../mpv/models.dart'; import '../mpv/models.dart';
@@ -156,6 +157,7 @@ class PlaybackInitializationService {
String? preferredVersionSignature, String? preferredVersionSignature,
bool preferOffline = false, bool preferOffline = false,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId, int? selectedAudioStreamId,
String? sessionIdentifier, String? sessionIdentifier,
String? transcodeSessionId, String? transcodeSessionId,
@@ -200,6 +202,7 @@ class PlaybackInitializationService {
selectedMediaSourceId: selectedMediaSourceId, selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature, preferredVersionSignature: preferredVersionSignature,
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId, selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier, sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId, transcodeSessionId: transcodeSessionId,
@@ -3,6 +3,7 @@ import '../media/ids.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart'; import '../models/transcode_quality_preset.dart';
import 'multi_server_manager.dart'; import 'multi_server_manager.dart';
import 'playback_context.dart'; import 'playback_context.dart';
@@ -17,6 +18,10 @@ class PlaybackSourceResolver {
/// [preferOffline] overrides the default downloaded-copy preference /// [preferOffline] overrides the default downloaded-copy preference
/// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for /// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for
/// flows that must stay on the server stream, e.g. a transcode restart. /// flows that must stay on the server stream, e.g. a transcode restart.
///
/// [audioQualityPreset] is the music transcode preset, consulted by the
/// backends only for [MediaKind.track] items ([qualityPreset] is
/// video-shaped and ignored for tracks).
Future<PlaybackContext> resolve({ Future<PlaybackContext> resolve({
required MediaItem metadata, required MediaItem metadata,
required int selectedMediaIndex, required int selectedMediaIndex,
@@ -24,6 +29,7 @@ class PlaybackSourceResolver {
String? preferredVersionSignature, String? preferredVersionSignature,
required bool offlineLibraryMode, required bool offlineLibraryMode,
required TranscodeQualityPreset qualityPreset, required TranscodeQualityPreset qualityPreset,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId, int? selectedAudioStreamId,
String? sessionIdentifier, String? sessionIdentifier,
String? transcodeSessionId, String? transcodeSessionId,
@@ -38,6 +44,7 @@ class PlaybackSourceResolver {
preferredVersionSignature: preferredVersionSignature, preferredVersionSignature: preferredVersionSignature,
preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal), preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal),
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId, selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier, sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId, transcodeSessionId: transcodeSessionId,
+26 -9
View File
@@ -22,7 +22,7 @@ static void* get_opengl_proc_address(void* ctx, const char* name) {
namespace mpv { namespace mpv {
MpvPlayer::MpvPlayer() {} MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
MpvPlayer::~MpvPlayer() { Dispose(); } MpvPlayer::~MpvPlayer() { Dispose(); }
@@ -41,15 +41,27 @@ bool MpvPlayer::Initialize() {
return false; return false;
} }
// Configure mpv for embedded playback. if (audio_only_) {
mpv_set_option_string(mpv_, "vo", "libmpv"); // Music core: no VO, no video decode. vid=no keeps embedded cover art
mpv_set_option_string(mpv_, "hwdec", "auto"); // from ever becoming a video track, and force-window/audio-display make
// sure mpv never opens a video output for it either.
mpv_set_option_string(mpv_, "vid", "no");
mpv_set_option_string(mpv_, "force-window", "no");
mpv_set_option_string(mpv_, "audio-display", "no");
mpv_set_option_string(mpv_, "gapless-audio", "weak");
} else {
// Configure mpv for embedded playback.
mpv_set_option_string(mpv_, "vo", "libmpv");
mpv_set_option_string(mpv_, "hwdec", "auto");
}
mpv_set_option_string(mpv_, "keep-open", "yes"); mpv_set_option_string(mpv_, "keep-open", "yes");
// HDR tone mapping if (!audio_only_) {
mpv_set_option_string(mpv_, "tone-mapping", "auto"); // HDR tone mapping
mpv_set_option_string(mpv_, "target-colorspace-hint", "no"); mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto"); mpv_set_option_string(mpv_, "target-colorspace-hint", "no");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
}
mpv_set_option_string(mpv_, "idle", "yes"); mpv_set_option_string(mpv_, "idle", "yes");
mpv_set_option_string(mpv_, "input-default-bindings", "no"); mpv_set_option_string(mpv_, "input-default-bindings", "no");
mpv_set_option_string(mpv_, "input-vo-keyboard", "no"); mpv_set_option_string(mpv_, "input-vo-keyboard", "no");
@@ -71,11 +83,16 @@ bool MpvPlayer::Initialize() {
// Set up event wakeup callback. // Set up event wakeup callback.
mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this); mpv_set_wakeup_callback(mpv_, OnMpvWakeup, this);
g_message("MPV: Initialization successful (render context deferred)"); g_message("MPV: Initialization successful (%s)", audio_only_ ? "audio-only" : "render context deferred");
return true; return true;
} }
bool MpvPlayer::InitRenderContext() { bool MpvPlayer::InitRenderContext() {
if (audio_only_) {
g_warning("MPV: InitRenderContext called on an audio-only player");
return false;
}
if (mpv_gl_) { if (mpv_gl_) {
return true; // Already created. return true; // Already created.
} }
+9 -3
View File
@@ -34,7 +34,10 @@ using RedrawCallback = std::function<void()>;
/// commands, properties, and event dispatching. /// commands, properties, and event dispatching.
class MpvPlayer { class MpvPlayer {
public: public:
MpvPlayer(); /// |audio_only| runs mpv as a music core with video disabled entirely:
/// no render context is ever created (InitRenderContext must not be
/// called) and no GL/EGL state is touched.
explicit MpvPlayer(bool audio_only = false);
~MpvPlayer(); ~MpvPlayer();
/// Initializes the mpv instance and configures options. /// Initializes the mpv instance and configures options.
@@ -45,6 +48,7 @@ class MpvPlayer {
/// Creates the mpv OpenGL render context. /// Creates the mpv OpenGL render context.
/// Must be called with a valid GL context current (e.g., from FlTextureGL::populate). /// Must be called with a valid GL context current (e.g., from FlTextureGL::populate).
/// Fails on audio-only players.
/// @return true if render context creation succeeded. /// @return true if render context creation succeeded.
bool InitRenderContext(); bool InitRenderContext();
@@ -60,8 +64,9 @@ class MpvPlayer {
/// Disposes mpv and releases resources. /// Disposes mpv and releases resources.
void Dispose(); void Dispose();
/// Returns true if mpv is initialized (has both mpv handle and render context). /// Returns true if mpv is initialized (has both mpv handle and render
bool IsInitialized() const { return mpv_ != nullptr && mpv_gl_ != nullptr; } /// context; audio-only players never have a render context).
bool IsInitialized() const { return mpv_ != nullptr && (audio_only_ || mpv_gl_ != nullptr); }
/// Returns true if this player has been disposed. /// Returns true if this player has been disposed.
bool IsDisposed() const { return disposed_.load(); } bool IsDisposed() const { return disposed_.load(); }
@@ -140,6 +145,7 @@ class MpvPlayer {
/// Helper to convert mpv_node to FlValue. /// Helper to convert mpv_node to FlValue.
::_FlValue* NodeToFlValue(mpv_node* node); ::_FlValue* NodeToFlValue(mpv_node* node);
const bool audio_only_;
mpv_handle* mpv_ = nullptr; mpv_handle* mpv_ = nullptr;
mpv_render_context* mpv_gl_ = nullptr; mpv_render_context* mpv_gl_ = nullptr;
+42 -10
View File
@@ -16,6 +16,7 @@ struct _MpvPlugin {
MpvTexture* texture; // owned via GObject ref MpvTexture* texture; // owned via GObject ref
gboolean visible; gboolean visible;
gboolean initialized; gboolean initialized;
gboolean audio_only;
}; };
G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT) G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT)
@@ -67,31 +68,43 @@ static void mpv_plugin_init(MpvPlugin* self) {
self->initialized = FALSE; self->initialized = FALSE;
self->texture = nullptr; self->texture = nullptr;
self->texture_registrar = nullptr; self->texture_registrar = nullptr;
self->audio_only = FALSE;
} }
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar) { MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, const gchar* channel_name, gboolean audio_only) {
MpvPlugin* self = MPV_PLUGIN(g_object_new(MPV_PLUGIN_TYPE, nullptr)); MpvPlugin* self = MPV_PLUGIN(g_object_new(MPV_PLUGIN_TYPE, nullptr));
self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar)); self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar));
self->texture_registrar = fl_plugin_registrar_get_texture_registrar(registrar); self->audio_only = audio_only;
self->player = std::make_unique<mpv::MpvPlayer>(); // The audio-only core never renders; leaving the texture registrar unset
// makes the GL/texture path structurally unreachable for it.
self->texture_registrar = audio_only ? nullptr : fl_plugin_registrar_get_texture_registrar(registrar);
self->player = std::make_unique<mpv::MpvPlayer>(audio_only);
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
self->method_channel = fl_method_channel_new( self->method_channel =
fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player", FL_METHOD_CODEC(codec)); fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar), channel_name, FL_METHOD_CODEC(codec));
fl_method_channel_set_method_call_handler(self->method_channel, mpv_plugin_handle_method_call, self, nullptr); fl_method_channel_set_method_call_handler(self->method_channel, mpv_plugin_handle_method_call, self, nullptr);
self->event_channel = fl_event_channel_new( g_autofree gchar* event_channel_name = g_strconcat(channel_name, "/events", nullptr);
fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player/events", FL_METHOD_CODEC(codec)); self->event_channel =
fl_event_channel_new(fl_plugin_registrar_get_messenger(registrar), event_channel_name, FL_METHOD_CODEC(codec));
return self; return self;
} }
// Static reference to keep the plugin alive. // Static references to keep the plugin instances alive.
static MpvPlugin* g_mpv_plugin = nullptr; static MpvPlugin* g_mpv_plugin = nullptr;
static MpvPlugin* g_mpv_audio_plugin = nullptr;
void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar) { g_mpv_plugin = mpv_plugin_new(registrar); } void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
g_mpv_plugin = mpv_plugin_new(registrar, "com.plezy/mpv_player", FALSE);
}
void mpv_audio_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
g_mpv_audio_plugin = mpv_plugin_new(registrar, "com.plezy/mpv_audio_player", TRUE);
}
/// Method call handler. /// Method call handler.
static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) {
@@ -103,7 +116,26 @@ static void mpv_plugin_handle_method_call(FlMethodChannel* channel, FlMethodCall
g_autoptr(FlMethodResponse) response = nullptr; g_autoptr(FlMethodResponse) response = nullptr;
if (strcmp(method, "initialize") == 0) { if (strcmp(method, "initialize") == 0) {
if (self->initialized && self->texture) { if (self->audio_only) {
// Audio-only music core: no texture, no render context — mpv runs
// with video disabled entirely (see MpvPlayer). Returns `true`; the
// Dart side only treats int results as texture IDs.
if (!self->initialized) {
if (!self->player || self->player->IsDisposed()) {
self->player = std::make_unique<mpv::MpvPlayer>(/*audio_only=*/true);
}
if (self->player->Initialize()) {
self->player->SetEventCallback([self](FlValue* event) { send_event(self, event); });
self->initialized = TRUE;
}
}
if (self->initialized) {
response = FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_bool(TRUE)));
} else {
response =
FL_METHOD_RESPONSE(fl_method_error_response_new("INIT_FAILED", "Failed to initialize MPV player", nullptr));
}
} else if (self->initialized && self->texture) {
// Already initialized — return existing texture ID // Already initialized — return existing texture ID
response = response =
FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture)))); FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(mpv_texture_get_id(self->texture))));
+11 -6
View File
@@ -9,21 +9,26 @@
G_BEGIN_DECLS G_BEGIN_DECLS
/// Plugin for MPV video playback on Linux. /// Plugin for MPV playback on Linux.
/// ///
/// This plugin renders mpv video through Flutter's GPU-accelerated /// The video instance renders mpv video through Flutter's GPU-accelerated
/// texture pipeline via FlTextureGL. /// texture pipeline via FlTextureGL. The audio-only instance (music
/// playback) skips all texture/GL work and runs mpv with video disabled.
#define MPV_PLUGIN_TYPE (mpv_plugin_get_type()) #define MPV_PLUGIN_TYPE (mpv_plugin_get_type())
G_DECLARE_FINAL_TYPE(MpvPlugin, mpv_plugin, MPV, PLUGIN, GObject) G_DECLARE_FINAL_TYPE(MpvPlugin, mpv_plugin, MPV, PLUGIN, GObject)
/// Creates a new MpvPlugin instance. /// Creates a new MpvPlugin instance on the given method channel name (the
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar); /// event channel is |channel_name| + "/events").
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar, const gchar* channel_name, gboolean audio_only);
/// Registers the plugin with Flutter. /// Registers the video plugin with Flutter.
void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar); void mpv_plugin_register_with_registrar(FlPluginRegistrar* registrar);
/// Registers the audio-only (music) plugin with Flutter.
void mpv_audio_plugin_register_with_registrar(FlPluginRegistrar* registrar);
G_END_DECLS G_END_DECLS
#endif // MPV_PLUGIN_H_ #endif // MPV_PLUGIN_H_
+6
View File
@@ -52,6 +52,12 @@ static void my_application_activate(GApplication* application) {
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvPlugin"); fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvPlugin");
mpv_plugin_register_with_registrar(registrar); mpv_plugin_register_with_registrar(registrar);
// Register the dedicated audio-only MPV core for music playback (no
// texture/GL work at all).
FlPluginRegistrar* audio_registrar =
fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(self->flutter_view), "MpvAudioPlugin");
mpv_audio_plugin_register_with_registrar(audio_registrar);
gtk_widget_show(GTK_WIDGET(window)); gtk_widget_show(GTK_WIDGET(window));
gtk_widget_grab_focus(GTK_WIDGET(self->flutter_view)); gtk_widget_grab_focus(GTK_WIDGET(self->flutter_view));
} }
+8
View File
@@ -37,6 +37,8 @@
6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; }; 6AD8B16A2ED7B50000E9E1B6 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */; };
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; }; B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */; }; B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */; };
B1D51A6A2F00110000000010 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000011 /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F00110000000012 /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000013 /* MpvAudioPlayerPlugin.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -103,6 +105,8 @@
A1182D4EEFEC88235D8ECD2C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A1182D4EEFEC88235D8ECD2C /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; }; B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000011 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = SOURCE_ROOT; };
B1D51A6A2F00110000000013 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = SOURCE_ROOT; };
B94440F7FE93A00B39D27ADF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; B94440F7FE93A00B39D27ADF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
DFD234339E4EACF84227E544 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; DFD234339E4EACF84227E544 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
@@ -228,6 +232,8 @@
children = ( children = (
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */, B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */, B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000011 /* MpvAudioPlayerCore.swift */,
B1D51A6A2F00110000000013 /* MpvAudioPlayerPlugin.swift */,
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */, 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */, 6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */, 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
@@ -485,6 +491,8 @@
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */, B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */, B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000010 /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F00110000000012 /* MpvAudioPlayerPlugin.swift in Sources */,
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */, 6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */, 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */, 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
+4
View File
@@ -30,6 +30,10 @@ class MainFlutterWindow: NSWindow {
MpvPlayerPlugin.register( MpvPlayerPlugin.register(
with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin"))
// Register the audio-only MPV player plugin for music playback
MpvAudioPlayerPlugin.register(
with: flutterViewController.registrar(forPlugin: "MpvAudioPlayerPlugin"))
// Register window utils plugin for dynamic titlebar/fullscreen control from Dart // Register window utils plugin for dynamic titlebar/fullscreen control from Dart
WindowUtilsPlugin.register( WindowUtilsPlugin.register(
with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin"))
@@ -0,0 +1,58 @@
import Foundation
import Libmpv
/// Audio-only mpv core for music playback.
///
/// Reuses [MpvPlayerCoreBase]'s context/event/property machinery (all of it
/// instance-scoped) but never touches a render surface: video decoding is
/// disabled outright, embedded cover art must not surface as a video track
/// (`audio-display=no`), and none of the display-criteria/EDR/PiP paths apply.
/// Lives alongside and independently of the video core, so it can be
/// created and destroyed repeatedly regardless of the video plugin's state.
class MpvAudioPlayerCore: MpvPlayerCoreBase {
private var isDisposed = false
func initialize() -> Bool {
guard !isInitialized else {
print("[MpvAudioPlayerCore] Already initialized")
return true
}
let created = createMpvContext { [self] in
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "vid", "no"))
// Critical: without this, embedded cover art is exposed as a video
// track and mpv would try to present it.
checkError(mpv_set_option_string(mpv, "audio-display", "no"))
checkError(mpv_set_option_string(mpv, "force-window", "no"))
// Gapless track transitions when the next playlist entry matches the
// current audio format (the Dart side arms it via `loadfile append`).
checkError(mpv_set_option_string(mpv, "gapless-audio", "weak"))
// Match the video core: hold the final track at EOF (eof-reached flips
// true) instead of unloading, so Dart's completed handling still works.
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
}
guard created else { return false }
isInitialized = true
print("[MpvAudioPlayerCore] Initialized successfully")
return true
}
func dispose() {
// Guard double-dispose: the plugin calls dispose() then drops the strong
// ref, which fires deinit dispose() again (same pattern as the video
// cores).
guard !isDisposed else { return }
isDisposed = true
disposeSharedState(destroySynchronously: false)
isInitialized = false
print("[MpvAudioPlayerCore] Disposed")
}
deinit {
dispose()
}
}
@@ -0,0 +1,127 @@
#if os(iOS) || os(tvOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#endif
/// Flutter plugin for the dedicated audio-only mpv core (music playback).
///
/// Registers `com.plezy/mpv_audio_player` + `/events` and delegates all
/// generic property/command/observe traffic to the shared [MpvPluginShared]
/// handlers. There is no render layer, so the visual hooks are no-ops and
/// `setVisible`/`updateFrame` succeed without doing anything. Shared across
/// iOS, tvOS, and macOS unlike the video plugin there is nothing
/// platform-specific beyond the messenger accessor.
class MpvAudioPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
private var playerCore: MpvAudioPlayerCore?
var eventSink: FlutterEventSink?
var nameToId: [String: Int] = [:]
// MpvPluginShared conformance the audio core has no visual surface.
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) {}
func updatePlayerFrame() {}
func didSetPauseProperty(value _: String) {}
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
#if os(macOS)
let messenger = registrar.messenger
#else
let messenger = registrar.messenger()
#endif
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_audio_player",
binaryMessenger: messenger
)
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_audio_player/events",
binaryMessenger: messenger
)
let instance = MpvAudioPlayerPlugin()
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
// MARK: - FlutterPlugin Method Handler
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
case "setVisible", "updateFrame":
// No render layer succeed so shared Dart call sites stay unconditional.
result(nil)
case "setLogLevel":
handleSetLogLevel(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
if self.playerCore?.isInitialized == true {
result(true)
return
}
let core = MpvAudioPlayerCore()
core.delegate = self
guard core.initialize() else {
result(
FlutterError(
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV audio core", details: nil))
return
}
self.playerCore = core
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self else { result(nil); return }
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
}
+36 -22
View File
@@ -346,6 +346,41 @@ class MpvPlayerCoreBase: NSObject {
applyDvConversionModeEnvironment() applyDvConversionModeEnvironment()
let created = createMpvContext { [self] in
guard let mpv else { return }
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque()))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
}
guard created, let mpv else { return false }
mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE)
mpv_observe_property(
mpv, Self.internalDoviProfileObserverId,
"current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalDoviLevelObserverId,
"current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalContainerFpsObserverId,
"container-fps", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING)
mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING)
mpv_observe_property(
mpv, Self.internalVideoColorMatrixObserverId,
"video-params/colormatrix", MPV_FORMAT_STRING)
return true
}
/// Create the mpv context, apply pre-init options via `configure`, run
/// `mpv_initialize`, and install the wakeup callback. Everything here is
/// instance-scoped (per-instance dispatch queue, request table, and retained
/// wakeup context), so the video core and the audio-only core can each own
/// an independent context and be created/destroyed at any time.
func createMpvContext(configure: () -> Void) -> Bool {
mpv = mpv_create() mpv = mpv_create()
guard let mpv else { guard let mpv else {
print("[MpvPlayerCore] Failed to create MPV context") print("[MpvPlayerCore] Failed to create MPV context")
@@ -357,10 +392,7 @@ class MpvPlayerCoreBase: NSObject {
// subtitle-timing investigation. // subtitle-timing investigation.
checkError(mpv_request_log_messages(mpv, "v")) checkError(mpv_request_log_messages(mpv, "v"))
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque())) configure()
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
let initResult = mpv_initialize(mpv) let initResult = mpv_initialize(mpv)
if initResult < 0 { if initResult < 0 {
@@ -384,24 +416,6 @@ class MpvPlayerCoreBase: NSObject {
}, },
wakeupContext wakeupContext
) )
mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE)
mpv_observe_property(
mpv, Self.internalDoviProfileObserverId,
"current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalDoviLevelObserverId,
"current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalContainerFpsObserverId,
"container-fps", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING)
mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING)
mpv_observe_property(
mpv, Self.internalVideoColorMatrixObserverId,
"video-params/colormatrix", MPV_FORMAT_STRING)
return true return true
} }
@@ -0,0 +1,782 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:os_media_controls/os_media_controls.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_display_criteria.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/playback_report_metadata.dart';
import 'package:plezy/mpv/models.dart';
import 'package:plezy/mpv/player/player.dart';
import 'package:plezy/mpv/player/player_state.dart';
import 'package:plezy/mpv/player/player_streams.dart';
import 'package:plezy/services/media_controls_manager.dart';
import 'package:plezy/services/multi_server_manager.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_playback_service_impl.dart';
import 'package:plezy/services/music/music_source_resolver.dart';
import 'package:plezy/services/playback_coordinator.dart';
const _trackDuration = Duration(minutes: 3);
MediaItem _track(String id) => MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Track $id',
parentTitle: 'Album',
grandparentTitle: 'Artist',
durationMs: _trackDuration.inMilliseconds,
serverId: 'srv',
);
String _urlFor(MediaItem track) => 'fake://${track.id}';
/// In-memory audio player: records calls, exposes manual stream controllers
/// so tests drive transitions/completion/errors deterministically.
class FakePlayer implements Player {
final playingCtrl = StreamController<bool>.broadcast(sync: true);
final completedCtrl = StreamController<bool>.broadcast(sync: true);
final bufferingCtrl = StreamController<bool>.broadcast(sync: true);
final positionCtrl = StreamController<Duration>.broadcast(sync: true);
final durationCtrl = StreamController<Duration>.broadcast(sync: true);
final seekableCtrl = StreamController<bool>.broadcast(sync: true);
final bufferCtrl = StreamController<Duration>.broadcast(sync: true);
final volumeCtrl = StreamController<double>.broadcast(sync: true);
final rateCtrl = StreamController<double>.broadcast(sync: true);
final tracksCtrl = StreamController<Tracks>.broadcast(sync: true);
final trackCtrl = StreamController<TrackSelection>.broadcast(sync: true);
final logCtrl = StreamController<PlayerLog>.broadcast(sync: true);
final errorCtrl = StreamController<PlayerError>.broadcast(sync: true);
final audioDeviceCtrl = StreamController<AudioDevice>.broadcast(sync: true);
final audioDevicesCtrl = StreamController<List<AudioDevice>>.broadcast(sync: true);
final bufferRangesCtrl = StreamController<List<BufferRange>>.broadcast(sync: true);
final playbackRestartCtrl = StreamController<void>.broadcast(sync: true);
final fileLoadedCtrl = StreamController<void>.broadcast(sync: true);
final backendSwitchedCtrl = StreamController<void>.broadcast(sync: true);
final trackTransitionCtrl = StreamController<String>.broadcast(sync: true);
late final PlayerStreams _streams = PlayerStreams(
playing: playingCtrl.stream,
completed: completedCtrl.stream,
buffering: bufferingCtrl.stream,
position: positionCtrl.stream,
duration: durationCtrl.stream,
seekable: seekableCtrl.stream,
buffer: bufferCtrl.stream,
volume: volumeCtrl.stream,
rate: rateCtrl.stream,
tracks: tracksCtrl.stream,
track: trackCtrl.stream,
log: logCtrl.stream,
error: errorCtrl.stream,
audioDevice: audioDeviceCtrl.stream,
audioDevices: audioDevicesCtrl.stream,
bufferRanges: bufferRangesCtrl.stream,
playbackRestart: playbackRestartCtrl.stream,
fileLoaded: fileLoadedCtrl.stream,
backendSwitched: backendSwitchedCtrl.stream,
trackTransition: trackTransitionCtrl.stream,
);
PlayerState _state = const PlayerState();
final List<String> openedUris = [];
final List<Media?> setNextCalls = [];
final List<Duration> seeks = [];
int playCalls = 0;
int pauseCalls = 0;
int stopCalls = 0;
bool _disposed = false;
Media? _armedMedia;
/// Effective armed item, mirroring the native playlist: set by [setNext],
/// consumed by an auto-advance ([emitTransition]).
Media? get armed => _armedMedia;
void emitTransition(String uri) {
_armedMedia = null; // the backend advanced into the armed entry
_state = _state.copyWith(position: Duration.zero, duration: _trackDuration);
trackTransitionCtrl.add(uri);
}
void emitCompleted() {
_state = _state.copyWith(completed: true, position: _trackDuration);
completedCtrl.add(true);
}
void emitError(String message) => errorCtrl.add(PlayerError(message));
void setPosition(Duration position) {
_state = _state.copyWith(position: position);
positionCtrl.add(position);
}
void closeControllers() {
playingCtrl.close();
completedCtrl.close();
bufferingCtrl.close();
positionCtrl.close();
durationCtrl.close();
seekableCtrl.close();
bufferCtrl.close();
volumeCtrl.close();
rateCtrl.close();
tracksCtrl.close();
trackCtrl.close();
logCtrl.close();
errorCtrl.close();
audioDeviceCtrl.close();
audioDevicesCtrl.close();
bufferRangesCtrl.close();
playbackRestartCtrl.close();
fileLoadedCtrl.close();
backendSwitchedCtrl.close();
trackTransitionCtrl.close();
}
@override
PlayerState get state => _state;
@override
PlayerStreams get streams => _streams;
@override
Duration get currentPosition => _state.position;
@override
bool get audioPassthroughActive => false;
@override
int? get textureId => null;
@override
String get playerType => 'fake';
@override
Future<void> open(
Media media, {
bool play = true,
bool isLive = false,
List<SubtitleTrack>? externalSubtitles,
Duration timelineOffset = Duration.zero,
Duration? timelineDuration,
}) async {
openedUris.add(media.uri);
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
if (play) playingCtrl.add(true);
}
@override
Future<void> play() async {
playCalls++;
_state = _state.copyWith(playing: true, completed: false);
playingCtrl.add(true);
}
@override
Future<void> pause() async {
pauseCalls++;
_state = _state.copyWith(playing: false);
playingCtrl.add(false);
}
@override
Future<void> playOrPause() => _state.playing ? pause() : play();
@override
Future<void> stop() async {
stopCalls++;
_state = _state.copyWith(playing: false, position: Duration.zero);
}
@override
Future<void> seek(Duration position) async {
seeks.add(position);
_state = _state.copyWith(position: position, completed: false);
}
@override
Future<void> setNext(Media? media) async {
setNextCalls.add(media);
_armedMedia = media;
}
@override
bool get disposed => _disposed;
@override
Future<void> dispose({bool preserveDisplayMode = false}) async {
_disposed = true;
}
// Inert surface below — never exercised by the music engine.
@override
Future<void> selectAudioTrack(AudioTrack track) async {}
@override
Future<void> selectSubtitleTrack(SubtitleTrack track) async {}
@override
Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}
@override
bool get supportsSecondarySubtitles => false;
@override
bool get attachesExternalSubtitlesAtOpen => true;
@override
bool get detectsFpsAfterRender => false;
@override
bool get needsDecoderRefreshAfterDisplaySwitch => false;
@override
bool get providesNativeStats => false;
@override
Future<void> addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> setRate(double rate) async {}
@override
Future<void> setAudioDevice(AudioDevice device) async {}
@override
Future<void> setProperty(String name, String value) async {}
@override
Future<String?> getProperty(String name) async => null;
@override
Future<void> setLogLevel(String level) async {}
@override
Future<void> command(List<String> args) async {}
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {}
@override
Future<void> configureSubtitleFonts() async {}
@override
Future<void> setAudioPassthrough(bool enabled) async {}
@override
Future<void> setAudioNormalization(bool enabled) async {}
@override
Future<void> setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {}
@override
Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async => true;
@override
Future<void> updateFrame() async {}
@override
Future<bool> setVideoFrameRate(
double fps,
int durationMs, {
int extraDelayMs = 0,
int videoWidth = 0,
int videoHeight = 0,
}) async => false;
@override
Future<void> clearVideoFrameRate() async {}
@override
Future<void> setSubtitleStyle({
required double fontSize,
required String textColor,
required double borderSize,
required String borderColor,
required String bgColor,
required int bgOpacity,
int subtitlePosition = 100,
bool bold = false,
bool italic = false,
}) async {}
@override
Future<void> setBoxFitMode(int mode) async {}
@override
Future<void> setVideoZoom(double scale) async {}
@override
Future<Map<String, dynamic>> getStats() async => {};
@override
Future<String> runtimePlayerType() async => 'fake';
@override
Future<bool> requestAudioFocus() async => true;
@override
Future<void> abandonAudioFocus() async {}
}
class RecordedReport {
final String state;
final String itemId;
final Duration position;
const RecordedReport(this.state, this.itemId, this.position);
@override
String toString() => '$state($itemId @ ${position.inSeconds}s)';
}
/// Records the progress-report surface; everything else is unimplemented
/// (the engine and tracker never touch it in these tests).
class FakeMediaServerClient extends Fake implements MediaServerClient {
final List<RecordedReport> reports = [];
final List<String> markedWatched = [];
Iterable<RecordedReport> reportsFor(String state) => reports.where((r) => r.state == state);
@override
ServerId get serverId => ServerId('srv');
@override
double get watchedThreshold => 0.9;
@override
bool get marksWatchedOnPlaybackStopped => false;
@override
Future<void> markWatched(MediaItem item) async {
markedWatched.add(item.id);
}
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
reports.add(RecordedReport('started', itemId, position));
}
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) async {
reports.add(RecordedReport(isPaused ? 'paused' : 'progress', itemId, position));
}
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
reports.add(RecordedReport('stopped', itemId, position));
}
}
class FakeMusicSourceResolver implements MusicSourceResolver {
FakeMusicSourceResolver({this.client});
final MediaServerClient? client;
final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {};
@override
Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}');
}
return MusicSource(
url: _urlFor(track),
playSessionId: 'ps-${track.id}',
playMethod: 'DirectPlay',
reportingClient: client,
);
}
}
/// Keeps the OS media session out of the tests: overrides every platform
/// touchpoint and feeds control events from a local controller.
class FakeMediaControlsManager extends MediaControlsManager {
final eventsCtrl = StreamController<MediaControlEvent>.broadcast(sync: true);
final List<String> metadataTitles = [];
bool cleared = false;
void closeControllers() {
eventsCtrl.close();
}
@override
Stream<MediaControlEvent> get controlEvents => eventsCtrl.stream;
@override
Future<void> updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async {
metadataTitles.add(metadata.title ?? '');
}
@override
Future<void> updatePlaybackState({
required bool isPlaying,
required Duration position,
required double speed,
bool force = false,
}) async {}
@override
Future<void> setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {}
@override
Future<void> clear() async {
cleared = true;
}
}
class _Harness {
_Harness._(this.service, this.resolver, this.client, this.controls, this.players);
final MusicPlaybackServiceImpl service;
final FakeMusicSourceResolver resolver;
final FakeMediaServerClient client;
final FakeMediaControlsManager controls;
final List<FakePlayer> players;
FakePlayer get player => players.last;
factory _Harness.create() {
final client = FakeMediaServerClient();
final resolver = FakeMusicSourceResolver(client: client);
final controls = FakeMediaControlsManager();
final players = <FakePlayer>[];
final service = MusicPlaybackServiceImpl(
serverManager: MultiServerManager(),
resolver: resolver,
audioPlayerFactory: () {
final player = FakePlayer();
players.add(player);
return player;
},
mediaControlsFactory: () => controls,
// Collapse the boundary-pulse confirmation window so completed-driven
// paths resolve within pumpEventQueue.
completedConfirmDelay: Duration.zero,
);
return _Harness._(service, resolver, client, controls, players);
}
Future<void> playTracks(List<MediaItem> tracks, {MediaItem? startTrack, bool shuffle = false}) async {
await service.playFromList(
tracks: tracks,
startTrack: startTrack,
playContext: const MusicPlayContext(title: 'Test', kind: MusicPlayContextKind.album),
shuffle: shuffle,
);
await pumpEventQueue();
}
}
void main() {
final t1 = _track('t1');
final t2 = _track('t2');
final t3 = _track('t3');
late _Harness h;
setUp(() {
h = _Harness.create();
});
tearDown(() {
h.service.dispose();
for (final player in h.players) {
player.closeControllers();
}
h.controls.closeControllers();
});
test('playFromList opens the first track and arms the second', () async {
await h.playTracks([t1, t2, t3]);
expect(h.player.openedUris, [_urlFor(t1)]);
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.service.currentTrack?.id, 't1');
expect(h.service.currentIndex, 0);
expect(h.player.armed?.uri, _urlFor(t2));
// Track services bound: session started + OS metadata pushed.
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1']);
expect(h.controls.metadataTitles, ['Track t1']);
});
test('trackTransition advances the cursor, re-arms, and reports the previous track stopped at duration', () async {
await h.playTracks([t1, t2, t3]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.currentIndex, 1);
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.player.armed?.uri, _urlFor(t3));
// No second open — the backend advanced gaplessly.
expect(h.player.openedUris, [_urlFor(t1)]);
final stopped = h.client.reportsFor('stopped').toList();
expect(stopped, hasLength(1));
expect(stopped.single.itemId, 't1');
expect(stopped.single.position, _trackDuration);
// Full playout crossed the watched threshold.
expect(h.client.markedWatched, ['t1']);
// New session started for the new track.
expect(h.client.reportsFor('started').map((r) => r.itemId), ['t1', 't2']);
});
test('completed with nothing armed parks paused at the end and keeps the track', () async {
await h.playTracks([t1, t2]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
expect(h.player.armed, isNull); // last track, repeat off
h.player.emitCompleted();
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.service.currentTrack?.id, 't2');
expect(h.service.queue, hasLength(2));
final stopped = h.client.reportsFor('stopped').toList();
expect(stopped.map((r) => r.itemId), ['t1', 't2']);
expect(stopped.last.position, _trackDuration);
});
test('completed with a failed arm falls back to opening the next track', () async {
h.resolver.failingIds.add('t2'); // arming t2 fails silently
await h.playTracks([t1, t2]);
expect(h.player.armed, isNull);
h.resolver.failingIds.clear(); // the explicit open retries the resolve
h.player.emitCompleted();
await pumpEventQueue();
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.service.currentTrack?.id, 't2');
expect(h.service.status, MusicPlaybackStatus.playing);
});
test('player error surfaces and auto-skips to the next track', () async {
await h.playTracks([t1, t2, t3]);
final errors = <Object>[];
final sub = h.service.errors.listen(errors.add);
h.player.emitError('boom');
await pumpEventQueue();
expect(errors, hasLength(1));
expect(h.service.currentTrack?.id, 't2');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.service.status, MusicPlaybackStatus.playing);
await sub.cancel();
});
test('three consecutive failures stop the session with an error status', () async {
await h.playTracks([t1, t2, t3]);
h.resolver.failingIds.addAll(['t2', 't3']);
final errors = <Object>[];
final sub = h.service.errors.listen(errors.add);
// Strike 1: player error on t1 -> skip to t2; strikes 2 and 3: t2/t3
// resolves fail -> stop as error.
h.player.emitError('boom');
await pumpEventQueue();
expect(errors, hasLength(3));
expect(h.service.status, MusicPlaybackStatus.error);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.players.single.disposed, isTrue);
await sub.cancel();
});
test('playback progress after an error resets the strike counter', () async {
await h.playTracks([t1, t2, t3, _track('t4')]);
h.player.emitError('boom'); // strike 1 -> skips to t2
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 5)); // t2 actually plays -> reset
h.player.emitError('boom'); // strike 1 again (not 2) -> skips to t3
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 5));
h.player.emitError('boom'); // still an isolated strike -> skips to t4
await pumpEventQueue();
// Without the reset this would have been the third strike (error stop).
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.service.currentTrack?.id, 't4');
});
test('claimVideo stops the session and disposes the audio core', () async {
await h.playTracks([t1, t2]);
final player = h.player;
await PlaybackCoordinator.instance.claimVideo();
expect(player.disposed, isTrue);
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.controls.cleared, isTrue);
// The played track's session was closed on the way out.
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
// A new playback after the claim recreates the player.
await h.playTracks([t3]);
expect(h.players, hasLength(2));
expect(h.player.openedUris, [_urlFor(t3)]);
expect(h.service.status, MusicPlaybackStatus.playing);
});
test('repeat-one arms the same uri without a new resolve and repeats on transition', () async {
await h.playTracks([t1, t2]);
expect(h.player.armed?.uri, _urlFor(t2));
h.service.setRepeatMode(MusicRepeatMode.one);
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t1));
expect(h.resolver.resolveCounts['t1'], 1); // reused the current source
h.player.emitTransition(_urlFor(t1));
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't1');
expect(h.service.currentIndex, 0);
expect(h.player.armed?.uri, _urlFor(t1)); // re-armed for the next loop
expect(h.resolver.resolveCounts['t1'], 1);
});
test('queue edits that keep the same next track do not re-arm or re-resolve', () async {
await h.playTracks([t1, t2, t3]);
final armCallsBefore = h.player.setNextCalls.length;
h.service.addToEnd([_track('t4')]);
await pumpEventQueue();
expect(h.player.setNextCalls.length, armCallsBefore);
expect(h.resolver.resolveCounts['t2'], 1);
});
test('previous restarts the track past 3s and steps back before that', () async {
await h.playTracks([t1, t2]);
h.player.emitTransition(_urlFor(t2));
await pumpEventQueue();
h.player.setPosition(const Duration(seconds: 10));
await h.service.previous();
expect(h.player.seeks, [Duration.zero]);
expect(h.service.currentTrack?.id, 't2');
h.player.setPosition(const Duration(seconds: 1));
await h.service.previous();
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't1');
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t1)]);
});
test('stop clears the session and notifies', () async {
await h.playTracks([t1, t2]);
var notified = 0;
h.service.addListener(() => notified++);
await h.service.stop();
await pumpEventQueue();
expect(notified, greaterThan(0));
expect(h.service.status, MusicPlaybackStatus.idle);
expect(h.service.currentTrack, isNull);
expect(h.service.queue, isEmpty);
expect(h.service.playContext, isNull);
expect(h.player.disposed, isTrue);
expect(h.controls.cleared, isTrue);
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('interruption pauses and resumes when the system says shouldResume', () async {
await h.playTracks([t1, t2]);
h.controls.eventsCtrl.add(const AudioInterruptionBeganEvent());
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.player.pauseCalls, 1);
h.controls.eventsCtrl.add(const AudioInterruptionEndedEvent(shouldResume: true));
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.playing);
expect(h.player.playCalls, 1);
});
test('interruption without shouldResume stays paused', () async {
await h.playTracks([t1]);
h.controls.eventsCtrl.add(const AudioInterruptionBeganEvent());
await pumpEventQueue();
h.controls.eventsCtrl.add(const AudioInterruptionEndedEvent(shouldResume: false));
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.player.playCalls, 0);
});
test('removing the current track opens the next one', () async {
await h.playTracks([t1, t2, t3]);
h.service.removeAt(0);
await pumpEventQueue();
expect(h.service.currentTrack?.id, 't2');
expect(h.service.queue.map((t) => t.id), ['t2', 't3']);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('end-of-track sleep timer suppresses arming and pauses at completion', () async {
await h.playTracks([t1, t2]);
expect(h.player.armed?.uri, _urlFor(t2));
h.service.setSleepTimer(null, endOfTrack: true);
await pumpEventQueue();
expect(h.service.sleepTimerActive, isTrue);
expect(h.player.armed, isNull);
h.player.emitCompleted();
await pumpEventQueue();
expect(h.service.status, MusicPlaybackStatus.paused);
expect(h.service.currentTrack?.id, 't1');
expect(h.service.sleepTimerActive, isFalse);
});
}
@@ -0,0 +1,229 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/services/music/music_playback_service.dart';
import 'package:plezy/services/music/music_queue_controller.dart';
MediaItem _track(String id) =>
MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: 'Track $id', serverId: 'srv');
List<String> _ids(List<MediaItem> items) => [for (final i in items) i.id];
void main() {
final tracks = [for (var i = 0; i < 6; i++) _track('t$i')];
MusicQueueController controller({int seed = 42}) => MusicQueueController(random: Random(seed));
group('load', () {
test('starts at startIndex in canonical order', () {
final q = controller()..load(tracks, startIndex: 2);
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5']);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
expect(q.shuffled, isFalse);
});
test('shuffle anchors the start track first', () {
final q = controller()..load(tracks, startIndex: 3, shuffle: true);
expect(q.shuffled, isTrue);
expect(q.cursor, 0);
expect(q.current!.id, 't3');
expect(_ids(q.queue).first, 't3');
expect(_ids(q.queue).toSet(), _ids(tracks).toSet());
});
test('empty load leaves an idle queue', () {
final q = controller()..load(const []);
expect(q.isEmpty, isTrue);
expect(q.cursor, -1);
expect(q.current, isNull);
expect(q.nextIndex(), isNull);
});
});
group('advancement (nextIndex/previousIndex)', () {
test('repeat off walks forward and ends after the last track', () {
final q = controller()..load(tracks, startIndex: 4);
expect(q.nextIndex(), 5);
q.jumpTo(5);
expect(q.nextIndex(), isNull);
expect(q.nextIndex(manual: true), isNull);
});
test('repeat all wraps both directions', () {
final q = controller()..load(tracks, startIndex: 5);
q.repeatMode = MusicRepeatMode.all;
expect(q.nextIndex(), 0);
q.jumpTo(0);
expect(q.previousIndex(), 5);
});
test('repeat one repeats naturally but steps on manual next', () {
final q = controller()..load(tracks, startIndex: 1);
q.repeatMode = MusicRepeatMode.one;
expect(q.nextIndex(), 1);
expect(q.nextIndex(manual: true), 2);
});
test('repeat one on the last track ends on manual next', () {
final q = controller()..load(tracks, startIndex: 5);
q.repeatMode = MusicRepeatMode.one;
expect(q.nextIndex(), 5);
expect(q.nextIndex(manual: true), isNull);
});
test('previousIndex steps back and stops at the head with repeat off', () {
final q = controller()..load(tracks, startIndex: 1);
expect(q.previousIndex(), 0);
q.jumpTo(0);
expect(q.previousIndex(), isNull);
});
});
group('jumpTo', () {
test('moves the cursor within bounds only', () {
final q = controller()..load(tracks);
q.jumpTo(4);
expect(q.current!.id, 't4');
q.jumpTo(99);
expect(q.cursor, 4);
q.jumpTo(-1);
expect(q.cursor, 4);
});
});
group('addNext / addToEnd', () {
test('addNext inserts directly after the current track', () {
final q = controller()..load(tracks, startIndex: 2);
q.addNext([_track('n1'), _track('n2')]);
expect(_ids(q.queue), ['t0', 't1', 't2', 'n1', 'n2', 't3', 't4', 't5']);
expect(q.current!.id, 't2');
});
test('addToEnd appends after everything', () {
final q = controller()..load(tracks, startIndex: 2);
q.addToEnd([_track('e1')]);
expect(_ids(q.queue).last, 'e1');
expect(q.current!.id, 't2');
});
test('added tracks survive an unshuffle in canonical order', () {
final q = controller()..load(tracks, startIndex: 0, shuffle: true);
q.addToEnd([_track('e1')]);
q.toggleShuffle(); // off — canonical = insertion order
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5', 'e1']);
});
});
group('removeAt', () {
test('before the cursor shifts the cursor back', () {
final q = controller()..load(tracks, startIndex: 3);
final wasCurrent = q.removeAt(1);
expect(wasCurrent, isFalse);
expect(q.current!.id, 't3');
expect(q.cursor, 2);
expect(_ids(q.queue), ['t0', 't2', 't3', 't4', 't5']);
});
test('after the cursor leaves the cursor alone', () {
final q = controller()..load(tracks, startIndex: 3);
expect(q.removeAt(5), isFalse);
expect(q.current!.id, 't3');
expect(q.cursor, 3);
});
test('at the cursor keeps the cursor on the following track', () {
final q = controller()..load(tracks, startIndex: 3);
expect(q.removeAt(3), isTrue);
expect(q.cursor, 3);
expect(q.current!.id, 't4');
});
test('at the cursor on the last track clamps back', () {
final q = controller()..load(tracks, startIndex: 5);
expect(q.removeAt(5), isTrue);
expect(q.cursor, 4);
expect(q.current!.id, 't4');
});
test('removing the only track empties the queue', () {
final q = controller()..load([_track('solo')]);
expect(q.removeAt(0), isTrue);
expect(q.isEmpty, isTrue);
expect(q.cursor, -1);
});
});
group('reorder (move)', () {
test('moving the current track moves the cursor with it', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(2, 4);
expect(q.cursor, 4);
expect(q.current!.id, 't2');
expect(_ids(q.queue), ['t0', 't1', 't3', 't4', 't2', 't5']);
});
test('moving an entry across the cursor adjusts it', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(0, 5);
expect(q.cursor, 1);
expect(q.current!.id, 't2');
q.move(5, 0);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
});
test('moving entries on one side keeps the cursor', () {
final q = controller()..load(tracks, startIndex: 2);
q.move(3, 5);
expect(q.cursor, 2);
expect(q.current!.id, 't2');
});
});
group('toggleShuffle', () {
test('on: current track anchors first, rest shuffled after', () {
final q = controller()..load(tracks, startIndex: 2);
q.toggleShuffle();
expect(q.shuffled, isTrue);
expect(q.cursor, 0);
expect(q.current!.id, 't2');
expect(_ids(q.queue).toSet(), _ids(tracks).toSet());
});
test('off: canonical order restored, cursor follows current', () {
final q = controller()..load(tracks, startIndex: 2);
q.toggleShuffle();
q.jumpTo(3); // some shuffled position
final current = q.current!.id;
q.toggleShuffle();
expect(q.shuffled, isFalse);
expect(_ids(q.queue), ['t0', 't1', 't2', 't3', 't4', 't5']);
expect(q.current!.id, current);
});
});
group('clearUpcoming', () {
test('drops everything after the current track', () {
final q = controller()..load(tracks, startIndex: 2);
q.clearUpcoming();
expect(_ids(q.queue), ['t0', 't1', 't2']);
expect(q.current!.id, 't2');
expect(q.nextIndex(), isNull);
});
test('while shuffled also drops the canonical items', () {
final q = controller()..load(tracks, startIndex: 0, shuffle: true);
final kept = _ids(q.queue.sublist(0, 2));
q.jumpTo(1);
q.clearUpcoming();
expect(_ids(q.queue), kept);
q.toggleShuffle();
expect(_ids(q.queue).toSet(), kept.toSet());
expect(q.current!.id, kept[1]);
});
});
}
+8
View File
@@ -15,6 +15,8 @@
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */; }; 7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */; };
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; }; 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */; };
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */; };
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; }; 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
81325C1CD13794375A81AC02 /* messages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CD411CCD84E381C4BF4C1B /* messages.g.swift */; }; 81325C1CD13794375A81AC02 /* messages.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34CD411CCD84E381C4BF4C1B /* messages.g.swift */; };
@@ -102,6 +104,8 @@
BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; BBCB49C8AE9E90DEF97A87CA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; }; C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = "<group>"; };
D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; }; D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = "<group>"; };
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = "<source_root>"; };
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = "<source_root>"; };
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; }; D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = "<source_root>"; };
F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; }; F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = "<group>"; };
F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; }; F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = "<group>"; };
@@ -251,6 +255,8 @@
children = ( children = (
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */, D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */,
73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */, 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */,
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */,
7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */, 7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */,
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */, A12B8610AE5D580077264851 /* MpvPlayerCore.swift */,
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */, 9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */,
@@ -480,6 +486,8 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */, 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */,
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */, 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */,
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */, 7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */,
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */, 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */,
8E5EED3DDAC9455D4DAA9776 /* MpvPlayerPlugin.swift in Sources */, 8E5EED3DDAC9455D4DAA9776 /* MpvPlayerPlugin.swift in Sources */,
+3
View File
@@ -138,6 +138,9 @@ import wakelock_plus
if let r = self.registrar(forPlugin: "MpvPlayerPlugin") { if let r = self.registrar(forPlugin: "MpvPlayerPlugin") {
MpvPlayerPlugin.register(with: r) MpvPlayerPlugin.register(with: r)
} }
if let r = self.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "AtmosProbePlugin") { if let r = self.registrar(forPlugin: "AtmosProbePlugin") {
AtmosProbePlugin.register(with: r) AtmosProbePlugin.register(with: r)
} }
+3 -1
View File
@@ -98,9 +98,11 @@ bool FlutterWindow::OnCreate() {
} }
RegisterPlugins(flutter_controller_->engine()); RegisterPlugins(flutter_controller_->engine());
// Register mpv player plugin. // Register mpv player plugins (video + dedicated audio-only music core).
OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n"); OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n");
MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin")); MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin"));
MpvAudioPlayerPluginRegisterWithRegistrar(
flutter_controller_->engine()->GetRegistrarForPlugin("MpvAudioPlayerPlugin"));
OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n"); OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n");
RegisterWindowChannel(); RegisterWindowChannel();
+56 -36
View File
@@ -73,7 +73,7 @@ void EnsureMpvInnerSubclassed(HWND host) {
} // namespace } // namespace
MpvPlayer::MpvPlayer() {} MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
MpvPlayer::~MpvPlayer() { Dispose(); } MpvPlayer::~MpvPlayer() { Dispose(); }
@@ -88,31 +88,43 @@ bool MpvPlayer::Initialize(HWND view) {
return false; return false;
} }
// Create a child window for mpv to render into, parented to the Flutter if (audio_only_) {
// |view|. The video child then sits in the view's own per-window layer // Windowless music core: no HWND, no VO, no video decode. vid=no keeps
// stack, above the view's (never-painted) layer-1 content and below the // embedded cover art from ever becoming a video track, and
// engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it // force-window/audio-display make sure mpv never opens a video output
// from painting over neighboring view children. Mouse input over the video // for it either.
// is delivered to mpv's own inner window (on mpv's thread); the subclass mpv_set_option_string(mpv_, "vid", "no");
// installed in EnsureMpvInnerSubclassed forwards it back to the view. mpv_set_option_string(mpv_, "force-window", "no");
hwnd_ = ::CreateWindowExW( mpv_set_option_string(mpv_, "audio-display", "no");
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", WS_CHILD | WS_CLIPSIBLINGS, 0, 0, 100, 100, view, nullptr, mpv_set_option_string(mpv_, "gapless-audio", "weak");
GetModuleHandle(nullptr), nullptr); } else {
if (!hwnd_) { // Create a child window for mpv to render into, parented to the Flutter
mpv_destroy(mpv_); // |view|. The video child then sits in the view's own per-window layer
mpv_ = nullptr; // stack, above the view's (never-painted) layer-1 content and below the
return false; // engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it
} // from painting over neighboring view children. Mouse input over the video
g_forward_target_view = view; // is delivered to mpv's own inner window (on mpv's thread); the subclass
// installed in EnsureMpvInnerSubclassed forwards it back to the view.
hwnd_ = ::CreateWindowExW(
WS_EX_NOPARENTNOTIFY, L"STATIC", L"", WS_CHILD | WS_CLIPSIBLINGS, 0, 0, 100, 100, view, nullptr,
GetModuleHandle(nullptr), nullptr);
if (!hwnd_) {
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
g_forward_target_view = view;
// Set the wid option to embed mpv in our window. // Set the wid option to embed mpv in our window.
int64_t wid = reinterpret_cast<int64_t>(hwnd_); int64_t wid = reinterpret_cast<int64_t>(hwnd_);
mpv_set_option(mpv_, "wid", MPV_FORMAT_INT64, &wid); mpv_set_option(mpv_, "wid", MPV_FORMAT_INT64, &wid);
mpv_set_option_string(mpv_, "vo", "gpu-next");
mpv_set_option_string(mpv_, "gpu-api", "auto");
// hwdec is set from Flutter via setProperty based on user preference
}
// Configure mpv for embedded playback. // Configure mpv for embedded playback.
mpv_set_option_string(mpv_, "vo", "gpu-next");
mpv_set_option_string(mpv_, "gpu-api", "auto");
// hwdec is set from Flutter via setProperty based on user preference
mpv_set_option_string(mpv_, "keep-open", "yes"); mpv_set_option_string(mpv_, "keep-open", "yes");
mpv_set_option_string(mpv_, "idle", "yes"); mpv_set_option_string(mpv_, "idle", "yes");
mpv_set_option_string(mpv_, "input-default-bindings", "no"); mpv_set_option_string(mpv_, "input-default-bindings", "no");
@@ -122,12 +134,14 @@ bool MpvPlayer::Initialize(HWND view) {
mpv_set_option_string(mpv_, "input-media-keys", "no"); mpv_set_option_string(mpv_, "input-media-keys", "no");
mpv_set_option_string(mpv_, "osc", "no"); mpv_set_option_string(mpv_, "osc", "no");
// Let mpv use display/context detection instead of forcing HDR signaling. if (!audio_only_) {
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto"); // Let mpv use display/context detection instead of forcing HDR signaling.
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto");
// Fallback tone mapping when display doesn't support HDR // Fallback tone mapping when display doesn't support HDR
mpv_set_option_string(mpv_, "tone-mapping", "auto"); mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto"); mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
}
// When WASAPI becomes unavailable (sleep, device unplug), fall back to null // When WASAPI becomes unavailable (sleep, device unplug), fall back to null
// audio output instead of permanently dropping the audio track. Recovery is // audio output instead of permanently dropping the audio track. Recovery is
@@ -140,15 +154,19 @@ bool MpvPlayer::Initialize(HWND view) {
// Initialize mpv. // Initialize mpv.
int err = mpv_initialize(mpv_); int err = mpv_initialize(mpv_);
if (err < 0) { if (err < 0) {
::DestroyWindow(hwnd_); if (hwnd_) {
hwnd_ = nullptr; ::DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
mpv_destroy(mpv_); mpv_destroy(mpv_);
mpv_ = nullptr; mpv_ = nullptr;
return false; return false;
} }
// Observe video-params/sig-peak for HDR detection // Observe video-params/sig-peak for HDR detection (video core only).
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE); if (!audio_only_) {
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
}
mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING); mpv_observe_property(mpv_, 0, "current-ao", MPV_FORMAT_STRING);
// Native observation so audio recovery doesn't depend on the Dart side // Native observation so audio recovery doesn't depend on the Dart side
// choosing to observe the device list. // choosing to observe the device list.
@@ -192,11 +210,13 @@ void MpvPlayer::Dispose() {
if (hwnd_) { if (hwnd_) {
::DestroyWindow(hwnd_); ::DestroyWindow(hwnd_);
hwnd_ = nullptr; hwnd_ = nullptr;
}
// The subclassed inner window died with hwnd_; clear the forwarding state. // The subclassed inner window died with hwnd_; clear the forwarding
g_mpv_inner_hwnd = nullptr; // state. Only the owner of the window may do this: the audio-only core
g_mpv_inner_original_proc = nullptr; // (which never has an hwnd_) must not wipe the video instance's state.
g_mpv_inner_hwnd = nullptr;
g_mpv_inner_original_proc = nullptr;
}
observed_properties_.clear(); observed_properties_.clear();
} }
+6 -2
View File
@@ -23,13 +23,16 @@ class MpvPlayer {
public: public:
using EventCallback = std::function<void(const flutter::EncodableValue&)>; using EventCallback = std::function<void(const flutter::EncodableValue&)>;
MpvPlayer(); // |audio_only| runs mpv as a windowless music core: no child HWND, no VO,
// video decode disabled entirely (vid=no).
explicit MpvPlayer(bool audio_only = false);
~MpvPlayer(); ~MpvPlayer();
// Initializes mpv and creates the video window as a child of the Flutter // Initializes mpv and creates the video window as a child of the Flutter
// |view| window. The flutter-plezy engine presents the UI on a topmost // |view| window. The flutter-plezy engine presents the UI on a topmost
// DirectComposition visual, so the video child composites beneath it in the // DirectComposition visual, so the video child composites beneath it in the
// same HWND. // same HWND. In audio-only mode |view| is ignored (pass nullptr) and no
// window is created.
bool Initialize(HWND view); bool Initialize(HWND view);
// Disposes mpv and the video window. // Disposes mpv and the video window.
@@ -97,6 +100,7 @@ class MpvPlayer {
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback); uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id); GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
const bool audio_only_;
mpv_handle* mpv_ = nullptr; mpv_handle* mpv_ = nullptr;
HWND hwnd_ = nullptr; HWND hwnd_ = nullptr;
+55 -25
View File
@@ -13,29 +13,41 @@ void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef regis
flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar)); flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
} }
void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar) {
mpv::MpvPlayerPlugin::RegisterWithRegistrar(
flutter::PluginRegistrarManager::GetInstance()->GetRegistrar<flutter::PluginRegistrarWindows>(registrar),
"com.plezy/mpv_audio_player", /*audio_only=*/true);
}
namespace mpv { namespace mpv {
namespace { namespace {
constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50; constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50;
} constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x4D51;
} // namespace
void MpvPlayerPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) { void MpvPlayerPlugin::RegisterWithRegistrar(
auto plugin = std::make_unique<MpvPlayerPlugin>(registrar); flutter::PluginRegistrarWindows* registrar, const std::string& channel_name, bool audio_only) {
auto plugin = std::make_unique<MpvPlayerPlugin>(registrar, channel_name, audio_only);
registrar->AddPlugin(std::move(plugin)); registrar->AddPlugin(std::move(plugin));
} }
MpvPlayerPlugin::MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar) MpvPlayerPlugin::MpvPlayerPlugin(
: registrar_(registrar), platform_thread_id_(::GetCurrentThreadId()) { flutter::PluginRegistrarWindows* registrar, const std::string& channel_name, bool audio_only)
: registrar_(registrar),
platform_thread_id_(::GetCurrentThreadId()),
audio_only_(audio_only),
platform_task_message_(audio_only ? kAudioPlatformTaskMessage : kPlatformTaskMessage) {
// Create method channel. // Create method channel.
method_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>( method_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
registrar->messenger(), "com.plezy/mpv_player", &flutter::StandardMethodCodec::GetInstance()); registrar->messenger(), channel_name, &flutter::StandardMethodCodec::GetInstance());
method_channel_->SetMethodCallHandler( method_channel_->SetMethodCallHandler(
[this](const auto& call, auto result) { HandleMethodCall(call, std::move(result)); }); [this](const auto& call, auto result) { HandleMethodCall(call, std::move(result)); });
// Create event channel. // Create event channel.
event_channel_ = std::make_unique<flutter::EventChannel<flutter::EncodableValue>>( event_channel_ = std::make_unique<flutter::EventChannel<flutter::EncodableValue>>(
registrar->messenger(), "com.plezy/mpv_player/events", &flutter::StandardMethodCodec::GetInstance()); registrar->messenger(), channel_name + "/events", &flutter::StandardMethodCodec::GetInstance());
auto handler = std::make_unique<flutter::StreamHandlerFunctions<flutter::EncodableValue>>( auto handler = std::make_unique<flutter::StreamHandlerFunctions<flutter::EncodableValue>>(
[this]( [this](
@@ -92,7 +104,7 @@ void MpvPlayerPlugin::PostToPlatformThread(std::function<void()> task) {
} }
} }
if (post_wakeup && !::PostMessage(flutter_window_, kPlatformTaskMessage, 0, 0)) { if (post_wakeup && !::PostMessage(flutter_window_, platform_task_message_, 0, 0)) {
// Wakeup lost (e.g. message queue full during a log storm); let the next // Wakeup lost (e.g. message queue full during a log storm); let the next
// enqueue retry instead of stranding the queue. // enqueue retry instead of stranding the queue.
std::lock_guard<std::mutex> lock(platform_tasks_mutex_); std::lock_guard<std::mutex> lock(platform_tasks_mutex_);
@@ -133,7 +145,7 @@ void MpvPlayerPlugin::HandleMethodCall(
// topmost DComp visual — there is no separate container window to manage. // topmost DComp visual — there is no separate container window to manage.
proc_id_ = proc_id_ =
registrar_->RegisterTopLevelWindowProcDelegate([this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) { registrar_->RegisterTopLevelWindowProcDelegate([this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == kPlatformTaskMessage) { if (message == platform_task_message_) {
DrainPlatformTasks(); DrainPlatformTasks();
return std::optional<HRESULT>(0); return std::optional<HRESULT>(0);
} }
@@ -156,18 +168,21 @@ void MpvPlayerPlugin::HandleMethodCall(
// and below the view's topmost DComp visual carrying the UI (layer 4). As // and below the view's topmost DComp visual carrying the UI (layer 4). As
// a *sibling* of the view, either the view's never-painted white content // a *sibling* of the view, either the view's never-painted white content
// covers the video or the video covers the UI — the in-subtree placement // covers the video or the video covers the UI — the in-subtree placement
// is the only ordering that yields white < video < UI. // is the only ordering that yields white < video < UI. The audio-only
HWND view = GetChildWindow(); // core is windowless, so it gets no view at all.
HWND view = audio_only_ ? nullptr : GetChildWindow();
player_ = std::make_unique<MpvPlayer>(); player_ = std::make_unique<MpvPlayer>(audio_only_);
bool success = player_->Initialize(view); bool success = player_->Initialize(view);
if (success) { if (success) {
// Set up event callback. // Set up event callback.
player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); }); player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); });
// Start hidden. if (!audio_only_) {
player_->SetVisible(false); // Start hidden.
player_->SetVisible(false);
}
result->Success(flutter::EncodableValue(true)); result->Success(flutter::EncodableValue(true));
} else { } else {
player_.reset(); // Clear the player so we don't have a half-initialized state player_.reset(); // Clear the player so we don't have a half-initialized state
@@ -343,6 +358,12 @@ void MpvPlayerPlugin::HandleMethodCall(
std::get<int32_t>(id_it->second)); std::get<int32_t>(id_it->second));
result->Success(); result->Success();
} else if (method == "setVisible") { } else if (method == "setVisible") {
if (audio_only_) {
// Windowless core: nothing to show or hide, tolerate as a success no-op.
result->Success();
return;
}
const auto* args = method_call.arguments(); const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) { if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument"); result->Error("INVALID_ARGS", "Expected map argument");
@@ -365,6 +386,12 @@ void MpvPlayerPlugin::HandleMethodCall(
result->Success(); result->Success();
} else if (method == "setVideoRect") { } else if (method == "setVideoRect") {
if (audio_only_) {
// Windowless core: no rect to position, tolerate as a success no-op.
result->Success();
return;
}
const auto* args = method_call.arguments(); const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) { if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument"); result->Error("INVALID_ARGS", "Expected map argument");
@@ -404,13 +431,16 @@ void MpvPlayerPlugin::HandleMethodCall(
player_->SetRect(rect, dpr); player_->SetRect(rect, dpr);
} }
result->Success();
} else if (audio_only_ && method == "updateFrame") {
// No frames to pump on the windowless core; tolerate as a success no-op.
result->Success(); result->Success();
} else if (method == "isInitialized") { } else if (method == "isInitialized") {
bool initialized = player_ && player_->IsInitialized(); bool initialized = player_ && player_->IsInitialized();
result->Success(flutter::EncodableValue(initialized)); result->Success(flutter::EncodableValue(initialized));
// --- Display mode matching --- // --- Display mode matching (video instance only) ---
} else if (method == "getDisplayModes") { } else if (!audio_only_ && method == "getDisplayModes") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
auto modes = display_mode_manager_.EnumerateDisplayModes(hwnd); auto modes = display_mode_manager_.EnumerateDisplayModes(hwnd);
flutter::EncodableList list; flutter::EncodableList list;
@@ -418,11 +448,11 @@ void MpvPlayerPlugin::HandleMethodCall(
list.push_back(flutter::EncodableValue(DisplayModeToMap(mode))); list.push_back(flutter::EncodableValue(DisplayModeToMap(mode)));
} }
result->Success(flutter::EncodableValue(list)); result->Success(flutter::EncodableValue(list));
} else if (method == "getCurrentDisplayMode") { } else if (!audio_only_ && method == "getCurrentDisplayMode") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
auto mode = display_mode_manager_.GetCurrentMode(hwnd); auto mode = display_mode_manager_.GetCurrentMode(hwnd);
result->Success(flutter::EncodableValue(DisplayModeToMap(mode))); result->Success(flutter::EncodableValue(DisplayModeToMap(mode)));
} else if (method == "setDisplayMode") { } else if (!audio_only_ && method == "setDisplayMode") {
const auto* args = method_call.arguments(); const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) { if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument"); result->Error("INVALID_ARGS", "Expected map argument");
@@ -438,17 +468,17 @@ void MpvPlayerPlugin::HandleMethodCall(
bool success = bool success =
display_mode_manager_.SetDisplayMode(hwnd, get_int("width"), get_int("height"), get_int("refreshRate")); display_mode_manager_.SetDisplayMode(hwnd, get_int("width"), get_int("height"), get_int("refreshRate"));
result->Success(flutter::EncodableValue(success)); result->Success(flutter::EncodableValue(success));
} else if (method == "restoreDisplayMode") { } else if (!audio_only_ && method == "restoreDisplayMode") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalMode(hwnd); bool success = display_mode_manager_.RestoreOriginalMode(hwnd);
result->Success(flutter::EncodableValue(success)); result->Success(flutter::EncodableValue(success));
} else if (method == "isHDRSupported") { } else if (!audio_only_ && method == "isHDRSupported") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRSupported(hwnd))); result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRSupported(hwnd)));
} else if (method == "isHDREnabled") { } else if (!audio_only_ && method == "isHDREnabled") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDREnabled(hwnd))); result->Success(flutter::EncodableValue(display_mode_manager_.IsHDREnabled(hwnd)));
} else if (method == "setSystemHDR") { } else if (!audio_only_ && method == "setSystemHDR") {
const auto* args = method_call.arguments(); const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) { if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument"); result->Error("INVALID_ARGS", "Expected map argument");
@@ -464,13 +494,13 @@ void MpvPlayerPlugin::HandleMethodCall(
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
bool success = display_mode_manager_.SetHDREnabled(hwnd, enabled); bool success = display_mode_manager_.SetHDREnabled(hwnd, enabled);
result->Success(flutter::EncodableValue(success)); result->Success(flutter::EncodableValue(success));
} else if (method == "restoreSystemHDR") { } else if (!audio_only_ && method == "restoreSystemHDR") {
HWND hwnd = GetWindow(); HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalHDRState(hwnd); bool success = display_mode_manager_.RestoreOriginalHDRState(hwnd);
result->Success(flutter::EncodableValue(success)); result->Success(flutter::EncodableValue(success));
} else if (method == "isModeChanged") { } else if (!audio_only_ && method == "isModeChanged") {
result->Success(flutter::EncodableValue(display_mode_manager_.IsModeChanged())); result->Success(flutter::EncodableValue(display_mode_manager_.IsModeChanged()));
} else if (method == "isHDRChanged") { } else if (!audio_only_ && method == "isHDRChanged") {
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRChanged())); result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRChanged()));
} else { } else {
result->NotImplemented(); result->NotImplemented();
+18 -3
View File
@@ -13,20 +13,30 @@
#include <mutex> #include <mutex>
#include <optional> #include <optional>
#include <queue> #include <queue>
#include <string>
#include "display_mode_manager.h" #include "display_mode_manager.h"
#include "mpv_player.h" #include "mpv_player.h"
// C-style registration function for the plugin. // C-style registration functions for the video and audio-only plugin
// instances.
void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar); void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar);
void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar);
namespace mpv { namespace mpv {
class MpvPlayerPlugin : public flutter::Plugin { class MpvPlayerPlugin : public flutter::Plugin {
public: public:
static void RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar); // |channel_name| is the method channel name; the event channel is
// |channel_name| + "/events". |audio_only| runs a windowless music core:
// no child HWND, no display-mode handling (see MpvPlayer).
static void RegisterWithRegistrar(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name = "com.plezy/mpv_player",
bool audio_only = false);
MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar); MpvPlayerPlugin(
flutter::PluginRegistrarWindows* registrar, const std::string& channel_name = "com.plezy/mpv_player",
bool audio_only = false);
virtual ~MpvPlayerPlugin(); virtual ~MpvPlayerPlugin();
private: private:
@@ -43,6 +53,11 @@ class MpvPlayerPlugin : public flutter::Plugin {
flutter::PluginRegistrarWindows* registrar_; flutter::PluginRegistrarWindows* registrar_;
DWORD platform_thread_id_; DWORD platform_thread_id_;
const bool audio_only_;
// Per-instance wakeup message: the first window-proc delegate that handles
// a message consumes it, so the video and audio instances must not share
// one message id or one instance's wakeup would strand the other's queue.
const UINT platform_task_message_;
HWND flutter_window_ = nullptr; HWND flutter_window_ = nullptr;
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> method_channel_; std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> method_channel_;
std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>> event_channel_; std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>> event_channel_;