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

Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
This commit is contained in:
edde746
2026-07-05 19:26:28 +02:00
parent 05a631415e
commit 422db75b5b
41 changed files with 3353 additions and 269 deletions
@@ -30,6 +30,7 @@ import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.core.content.FileProvider
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
import com.edde746.plezy.shared.DeviceQuirks
import com.edde746.plezy.shared.ThemeHelper
@@ -490,6 +491,7 @@ class MainActivity : FlutterActivity() {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(MpvPlayerPlugin())
flutterEngine.plugins.add(ExoPlayerPlugin())
flutterEngine.plugins.add(MpvAudioPlayerPlugin())
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
@@ -1,8 +1,10 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.media.AudioAttributes
import android.media.ImageReader
import android.os.Handler
import android.os.Looper
@@ -22,12 +24,31 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
/**
* mpv playback core. Two modes:
* - Video (default): [context] is the host Activity, which is needed for the
* SurfaceView/window hierarchy, display refresh-rate reads and frame-rate
* matching.
* - Audio-only ([audioOnly]): the music core. Built on the application
* context (no Activity dependency, so it survives activity teardown);
* never creates a surface, view, or frame-rate manager, and mpv is
* configured before init to never open a video output (`vid=no`,
* `force-window=no`, `audio-display=no`, plus `gapless-audio=weak`).
*/
class MpvPlayerCore(
private val context: Context,
private val audioOnly: Boolean = false
) : SurfaceHolder.Callback {
companion object {
private const val TAG = "MpvPlayerCore"
}
/** Video-only paths. The plugin always constructs video cores with the
* host Activity, and audio-only mode never touches these paths. */
private val activity: Activity
get() = context as Activity
private var surfaceView: SurfaceView? = null
private var surfaceContainer: android.widget.FrameLayout? = null
private var overlayLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
@@ -63,6 +84,16 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper())
// Result-callback marshaling. Separate from [handler], whose queued
// messages dispose() clears — pending method-channel results must still
// complete after dispose.
private val mainHandler = Handler(Looper.getMainLooper())
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
// Audio focus
private var audioFocusManager: AudioFocusManager? = null
@@ -88,7 +119,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private var flutterOverlayApplied = false
private fun ensureFlutterOverlayOnTop() {
if (disposing || flutterOverlayApplied) return
if (audioOnly || disposing || flutterOverlayApplied) return
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.post {
if (disposing || !isInitialized) return@post
@@ -112,6 +143,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
private fun currentDisplayFpsOverride(): String? {
if (audioOnly) return null
val refreshRate = activity.display?.mode?.refreshRate ?: return null
if (refreshRate <= 0f) return null
return refreshRate.toString()
@@ -167,12 +199,15 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
lastAppliedSurfaceSize = null
lastKnownSurfaceWidth = 0
lastKnownSurfaceHeight = 0
ensurePlaceholderSurface()
if (!audioOnly) ensurePlaceholderSurface()
// Initialize audio focus handling
// Initialize audio focus handling. mpv has none built in, so both modes
// use the shared manager: pause on (transient) loss, auto-resume on
// regain when the loss interrupted active playback.
audioFocusManager = AudioFocusManager(
context = activity,
context = context,
handler = handler,
contentType = if (audioOnly) AudioAttributes.CONTENT_TYPE_MUSIC else AudioAttributes.CONTENT_TYPE_MOVIE,
onPause = {
scope.launch {
try {
@@ -187,57 +222,59 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
},
isPaused = { cachedPaused }
)
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
log = { emitLog("info", "framerate", it) }
)
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
if (!audioOnly) {
frameRateManager = FrameRateManager(
activity = activity,
handler = handler,
log = { emitLog("info", "framerate", it) }
)
setBackgroundColor(Color.BLACK)
}
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Create FrameLayout container for video
surfaceContainer = android.widget.FrameLayout(activity).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
setBackgroundColor(Color.BLACK)
}
// Add SurfaceView to container
surfaceContainer!!.addView(surfaceView)
// Create SurfaceView for video rendering
surfaceView = SurfaceView(activity).apply {
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT
)
holder.addCallback(this@MpvPlayerCore)
setZOrderOnTop(false)
setZOrderMediaOverlay(false)
FlutterOverlayHelper.applyCompositionOrder(this, -2)
}
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Add SurfaceView to container
surfaceContainer!!.addView(surfaceView)
// Find FlutterView and set it on top of our video surface.
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
// Insert container at bottom of view hierarchy (behind Flutter)
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
contentView.addView(surfaceContainer, 0)
// Find FlutterView and set it on top of our video surface.
// compositionOrder maps directly to SurfaceView mSubLayer on API 36+:
// negative is hole-punched behind the parent canvas, non-negative is above.
// Stack (back → front): video (-2, hole-punched) → parent canvas → Flutter UI (+1).
FlutterOverlayHelper.findFlutterContainer(contentView, surfaceContainer)?.let { container ->
FlutterOverlayHelper.configureFlutterZOrder(contentView, container, compositionOrder = 1)
flutterOverlayApplied = true
}
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
ensureFlutterOverlayOnTop()
val sv = surfaceView
if (sv != null) applySurfaceSize(sv.width, sv.height)
}
contentView.viewTreeObserver.addOnGlobalLayoutListener(overlayLayoutListener)
Log.d(TAG, "SurfaceView added to content view")
Log.d(TAG, "SurfaceView added to content view")
}
// Create MpvPlayer on background thread via coroutine
scope.launch {
@@ -247,18 +284,31 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
return@launch
}
val displayFpsOverride = currentDisplayFpsOverride()
val p = MpvPlayer.create(activity.applicationContext) {
setOption("vo", "gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
val p = MpvPlayer.create(context.applicationContext) {
if (audioOnly) {
// Pure audio core (all set before mpv_initialize, mirroring the
// Windows/Linux audio instances): vid=no keeps embedded cover
// art from ever becoming a video track, force-window and
// audio-display make sure mpv never opens a video output for
// it, and gapless-audio splices the pre-armed next playlist
// entry into the running audio stream.
setOption("vid", "no")
setOption("force-window", "no")
setOption("audio-display", "no")
setOption("gapless-audio", "weak")
} else {
setOption("vo", "gpu")
setOption("gpu-context", "android")
setOption("opengl-es", "yes")
setOption("vd-lavc-film-grain", "cpu")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
setOption("ao", "audiotrack,opensles")
// Pause on the last frame at EOF instead of unloading the file, so a
// seek after the video ends still works (matches Linux/Windows).
setOption("keep-open", "yes")
if (displayFpsOverride != null) {
setOption("display-fps-override", displayFpsOverride)
}
}
if (displayFpsOverride != null) {
Log.d(TAG, "Initial display-fps-override=$displayFpsOverride")
@@ -273,7 +323,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
player = p
isInitialized = true
refreshVideoOutput("initialize")
if (!audioOnly) refreshVideoOutput("initialize")
// Start collecting events/properties/logs
collectEvents(p)
@@ -408,7 +458,9 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
private fun hasAttachedRealSurface(): Boolean = hasAttachedSurface && !attachedToPlaceholder && (attachedSurface?.isValid == true)
private fun hasReadyVideoOutput(): Boolean = hasAttachedRealSurface() && !videoOutputRestoring
// Audio-only mode has no video output to wait for — playback and resume
// paths gated on output readiness must always proceed there.
private fun hasReadyVideoOutput(): Boolean = audioOnly || (hasAttachedRealSurface() && !videoOutputRestoring)
private fun isCurrentVideoOutputEpoch(epoch: Long): Boolean = !disposing && epoch == videoOutputEpoch
@@ -419,7 +471,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
private fun refreshVideoOutput(reason: String) {
if (disposing) return
if (audioOnly || disposing) return
rememberCurrentSurfaceSize()
val p = player
@@ -730,7 +782,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Thread {
val value = getPropertyBlocking(name)
activity.runOnUiThread {
runOnMain {
onResult(if (!disposing && isInitialized) value else null)
}
}.start()
@@ -824,9 +876,10 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
fun setVisible(visible: Boolean) {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
// Audio-only: no render layer to show or hide — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
if (disposing) return@runOnMain
surfaceContainer?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
if (visible) {
flutterOverlayApplied = false
@@ -852,16 +905,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
}
fun updateFrame() {
if (disposing) return
activity.runOnUiThread {
if (disposing) return@runOnUiThread
// Audio-only: no surface to refresh — tolerated no-op.
if (audioOnly || disposing) return
runOnMain {
if (disposing) return@runOnMain
flutterOverlayApplied = false
ensureFlutterOverlayOnTop()
rememberCurrentSurfaceSize()
val p = player
if (p == null) {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because player is not ready")
return@runOnUiThread
return@runOnMain
}
if (!hasReadyVideoOutput()) {
val surface = currentCandidateSurface()
@@ -871,7 +925,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
} else {
Log.d(TAG, "updateFrame(): skipping Android MPV surface refresh because no surface is attached")
}
return@runOnUiThread
return@runOnMain
}
scope.launch {
try {
@@ -957,17 +1011,17 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
videoOutputEpoch += 1L
}
// Capture locals for deferred cleanup
// Capture locals for deferred cleanup (audio-only has no views)
val sv = surfaceView
val container = surfaceContainer
val contentView = activity.findViewById<ViewGroup>(android.R.id.content)
val contentView = if (audioOnly) null else activity.findViewById<ViewGroup>(android.R.id.content)
surfaceContainer = null
surfaceView = null
// Remove layout listener synchronously
overlayLayoutListener?.let { listener ->
contentView.viewTreeObserver.removeOnGlobalLayoutListener(listener)
contentView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
}
overlayLayoutListener = null
@@ -989,15 +1043,18 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
if (p != null) {
Thread {
try {
// Detach surface BEFORE close to prevent GPU mutex contention with view removal
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
// Detach surface BEFORE close to prevent GPU mutex contention with
// view removal (audio-only never attached one)
if (!audioOnly) {
try {
runBlocking {
p.setProperty("force-window", "no")
p.setProperty("vo", "null")
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
p.detachSurface()
} catch (e: Exception) {
Log.w(TAG, "Failed to detach surface during dispose", e)
}
p.close()
} catch (e: Exception) {
@@ -1008,7 +1065,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).post {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
contentView?.removeView(container)
}
onComplete?.invoke()
}
@@ -1018,7 +1075,7 @@ class MpvPlayerCore(private val activity: Activity) : SurfaceHolder.Callback {
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
sv?.holder?.removeCallback(this)
if (container?.parent != null) {
contentView.removeView(container)
contentView?.removeView(container)
}
}
onComplete?.invoke()
@@ -1,7 +1,10 @@
package com.edde746.plezy.mpv
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
@@ -10,18 +13,27 @@ import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MpvPlayerPlugin :
FlutterPlugin,
/**
* Channel plumbing for [MpvPlayerCore]. The default instance is the video
* player; the [audioOnly] instance (see [MpvAudioPlayerPlugin]) drives the
* dedicated music core on its own channel pair with two lifecycle
* differences:
* - the core is built on the application context, not the Activity, so
* background music playback survives activity teardown — it is only
* disposed on explicit Dart `dispose` or engine detach, never in
* [onDetachedFromActivity];
* - all video-only surface work is skipped inside the core.
*/
open class MpvPlayerPlugin(
private val channelBase: String = "com.plezy/mpv_player",
private val audioOnly: Boolean = false
) : FlutterPlugin,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler,
ActivityAware,
com.edde746.plezy.shared.PlayerDelegate {
companion object {
private const val TAG = "MpvPlayerPlugin"
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
}
private val tag = if (audioOnly) "MpvAudioPlayerPlugin" else "MpvPlayerPlugin"
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
@@ -29,9 +41,17 @@ class MpvPlayerPlugin :
private var playerCore: MpvPlayerCore? = null
private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = null
private var applicationContext: Context? = null
private val nameToId = mutableMapOf<String, Int>()
private var sessionGeneration = 0
private val mainHandler = Handler(Looper.getMainLooper())
/** Same semantics as Activity.runOnUiThread, without needing an Activity. */
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
}
// Pending `MethodChannel.Result`s for an init that is currently in flight.
// Concurrent `invoke('initialize')` calls share the same outcome instead
// of each tearing down the in-flight core and starting their own — which
@@ -43,19 +63,36 @@ class MpvPlayerPlugin :
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
applicationContext = binding.applicationContext
methodChannel = MethodChannel(binding.binaryMessenger, channelBase)
methodChannel.setMethodCallHandler(this)
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
eventChannel = EventChannel(binding.binaryMessenger, "$channelBase/events")
eventChannel.setStreamHandler(this)
Log.d(TAG, "Attached to engine")
Log.d(tag, "Attached to engine")
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
Log.d(TAG, "Detached from engine")
if (audioOnly) {
// The audio core is not activity-bound; engine detach is its terminal
// native lifecycle event (mirrors the video core's activity detach).
disposeCoreForTeardown()
}
applicationContext = null
Log.d(tag, "Detached from engine")
}
private fun disposeCoreForTeardown() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
}
// ActivityAware
@@ -63,43 +100,42 @@ class MpvPlayerPlugin :
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Attached to activity")
Log.d(tag, "Attached to activity")
}
override fun onDetachedFromActivity() {
++sessionGeneration
playerCore?.dispose()
playerCore = null
// Any in-flight init callback would never fire (its scope is cancelled
// by dispose), so close out queued callers explicitly.
completePendingInits(success = false)
// The audio-only core deliberately outlives the activity (background
// music); it is torn down on engine detach / Dart dispose instead.
if (!audioOnly) {
disposeCoreForTeardown()
}
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity")
Log.d(tag, "Detached from activity")
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
activityBinding = binding
Log.d(TAG, "Reattached to activity for config changes")
Log.d(tag, "Reattached to activity for config changes")
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
activityBinding = null
Log.d(TAG, "Detached from activity for config changes")
Log.d(tag, "Detached from activity for config changes")
}
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
Log.d(TAG, "Event stream connected")
Log.d(tag, "Event stream connected")
}
override fun onCancel(arguments: Any?) {
eventSink = null
Log.d(TAG, "Event stream disconnected")
Log.d(tag, "Event stream disconnected")
}
// MethodChannel.MethodCallHandler
@@ -127,14 +163,20 @@ class MpvPlayerPlugin :
}
private fun handleInitialize(result: MethodChannel.Result) {
val currentActivity = activity
if (currentActivity == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
// Video cores need the Activity (surface/view hierarchy); the audio-only
// core is built on the application context so it can outlive it.
val coreContext: Context? = if (audioOnly) applicationContext else activity
if (coreContext == null) {
if (audioOnly) {
result.error("NO_CONTEXT", "Application context not available", null)
} else {
result.error("NO_ACTIVITY", "Activity not available", null)
}
return
}
if (playerCore?.isInitialized == true) {
Log.d(TAG, "Already initialized")
Log.d(tag, "Already initialized")
result.success(true)
return
}
@@ -146,13 +188,13 @@ class MpvPlayerPlugin :
synchronized(pendingInitResults) {
pendingInitResults += result
if (isInitializing) {
Log.d(TAG, "Init already in flight, queuing caller")
Log.d(tag, "Init already in flight, queuing caller")
return
}
isInitializing = true
}
currentActivity.runOnUiThread {
runOnMain {
val gen: Int
val core: MpvPlayerCore
try {
@@ -160,31 +202,32 @@ class MpvPlayerPlugin :
// OR `playerCore?.isInitialized == true` and we early-exited
// above. We never tear down a core that's mid-initialization.
if (playerCore != null && playerCore?.isInitialized != true) {
Log.w(TAG, "Discarding stale uninitialized core before re-init")
Log.w(tag, "Discarding stale uninitialized core before re-init")
playerCore?.dispose()
playerCore = null
}
gen = ++sessionGeneration
core = MpvPlayerCore(currentActivity).apply {
core = MpvPlayerCore(coreContext, audioOnly).apply {
delegate = this@MpvPlayerPlugin
}
playerCore = core
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize: ${e.message}", e)
Log.e(tag, "Failed to initialize: ${e.message}", e)
completePendingInits(success = false, errorMessage = e.message)
return@runOnUiThread
return@runOnMain
}
core.initialize { success ->
val stale = gen != sessionGeneration || playerCore !== core
if (stale) {
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
Log.d(tag, "Stale init callback (gen=$gen, current=$sessionGeneration)")
} else {
// Start hidden - now safe because setVisible operates on the container,
// not the SurfaceView directly (matching ExoPlayer's approach)
// not the SurfaceView directly (matching ExoPlayer's approach).
// No-op on the audio-only core, which has no render layer.
core.setVisible(false)
Log.d(TAG, "Initialized: $success")
Log.d(tag, "Initialized: $success")
}
completePendingInits(success = !stale && success)
}
@@ -208,7 +251,7 @@ class MpvPlayerPlugin :
}
private fun handleDispose(result: MethodChannel.Result) {
activity?.runOnUiThread {
runOnMain {
val core = playerCore
++sessionGeneration
playerCore = null
@@ -218,10 +261,10 @@ class MpvPlayerPlugin :
completePendingInits(success = false)
core?.dispose {
Log.d(TAG, "Disposed")
Log.d(tag, "Disposed")
result.success(null)
} ?: result.success(null)
} ?: result.success(null)
}
}
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
@@ -269,9 +312,8 @@ class MpvPlayerPlugin :
}
private fun handleGetStats(result: MethodChannel.Result) {
val currentActivity = activity
val core = playerCore
if (currentActivity == null || core == null) {
if (core == null) {
result.success(mapOf("playerType" to "mpv"))
return
}
@@ -279,7 +321,7 @@ class MpvPlayerPlugin :
val gen = sessionGeneration
Thread {
val stats = core.getStats()
currentActivity.runOnUiThread {
runOnMain {
if (gen != sessionGeneration || playerCore !== core) {
result.success(mapOf("playerType" to "mpv"))
} else {
@@ -346,7 +388,7 @@ class MpvPlayerPlugin :
val videoWidth = call.argument<Number>("videoWidth")?.toInt() ?: 0
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
Log.d(tag, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
val core = playerCore
if (core == null) {
result.success(false)
@@ -358,19 +400,19 @@ class MpvPlayerPlugin :
}
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate")
Log.d(tag, "clearVideoFrameRate")
playerCore?.clearVideoFrameRate()
result.success(null)
}
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus")
Log.d(tag, "requestAudioFocus")
val granted = playerCore?.requestAudioFocus() ?: false
result.success(granted)
}
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus")
Log.d(tag, "abandonAudioFocus")
playerCore?.abandonAudioFocus()
result.success(null)
}
@@ -382,9 +424,11 @@ class MpvPlayerPlugin :
return
}
val contentResolver = activity?.contentResolver
// The audio instance may run without an Activity (background music), so
// resolve SAF content URIs through the application context there.
val contentResolver = (if (audioOnly) applicationContext else activity)?.contentResolver
if (contentResolver == null) {
result.error("NO_ACTIVITY", "Activity not available", null)
result.error(if (audioOnly) "NO_CONTEXT" else "NO_ACTIVITY", "Context not available", null)
return
}
@@ -394,18 +438,18 @@ class MpvPlayerPlugin :
val uri = Uri.parse(uriString)
val pfd = contentResolver.openFileDescriptor(uri, "r")
if (pfd == null) {
activity?.runOnUiThread {
runOnMain {
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
}
return@Thread
}
val fd = pfd.detachFd()
Log.d(TAG, "Opened content FD $fd for $uriString")
activity?.runOnUiThread { result.success(fd) }
Log.d(tag, "Opened content FD $fd for $uriString")
runOnMain { result.success(fd) }
} catch (e: Exception) {
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
Log.e(tag, "Failed to open content FD: ${e.message}", e)
runOnMain { result.error("OPEN_FAILED", e.message, null) }
}
}.start()
}
@@ -426,3 +470,11 @@ class MpvPlayerPlugin :
eventSink?.success(event)
}
}
/**
* The audio-only music instance on `com.plezy/mpv_audio_player[/events]`.
* A distinct class (not just a configured [MpvPlayerPlugin]) because
* FlutterEngine's plugin registry keys plugins by class and would silently
* drop a second [MpvPlayerPlugin] registration.
*/
class MpvAudioPlayerPlugin : MpvPlayerPlugin(channelBase = "com.plezy/mpv_audio_player", audioOnly = true)
@@ -14,7 +14,8 @@ class AudioFocusManager(
private val onPause: () -> Unit,
private val onResume: () -> Unit,
private val isPaused: () -> Boolean,
private val log: (String) -> Unit = { Log.d(TAG, it) }
private val log: (String) -> Unit = { Log.d(TAG, it) },
private val contentType: Int = AudioAttributes.CONTENT_TYPE_MOVIE
) {
companion object {
private const val TAG = "AudioFocusManager"
@@ -62,7 +63,7 @@ class AudioFocusManager(
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.setContentType(contentType)
.build()
)
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
+8
View File
@@ -22,6 +22,8 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.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 */; };
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
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>"; };
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; };
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; };
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; };
@@ -128,6 +132,8 @@
children = (
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F0011000000000D /* MpvAudioPlayerCore.swift */,
B1D51A6A2F0011000000000F /* MpvAudioPlayerPlugin.swift */,
B1D51A6A2F0011000000000B /* AtmosProbePlugin.swift */,
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
@@ -420,6 +426,8 @@
files = (
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F0011000000000C /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F0011000000000E /* MpvAudioPlayerPlugin.swift in Sources */,
B1D51A6A2F0011000000000A /* AtmosProbePlugin.swift in Sources */,
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
+5
View File
@@ -36,6 +36,11 @@ import MediaPlayer
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") {
AtmosProbePlugin.register(with: registrar)
}
+28
View File
@@ -96,6 +96,16 @@ abstract class Player {
/// Seek to a specific 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.
Future<void> selectAudioTrack(AudioTrack track);
@@ -379,4 +389,22 @@ abstract class Player {
}
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
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;
@override
+153 -8
View File
@@ -1,32 +1,58 @@
import 'dart:async' show unawaited;
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../../media/media_display_criteria.dart';
import '../../utils/app_logger.dart';
import '../models.dart';
import 'player_base.dart';
/// MPV-backed player for platforms where AetherEngine is not the native route.
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;
String _dvConversionMode = 'auto';
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
int? get textureId => _textureIdValue;
static const _methodChannel = MethodChannel('com.plezy/mpv_player');
static const _eventChannel = EventChannel('com.plezy/mpv_player/events');
/// Whether this instance drives the audio-only core.
final bool audioOnly;
@override
MethodChannel get methodChannel => _methodChannel;
final MethodChannel methodChannel;
@override
EventChannel get eventChannel => _eventChannel;
final EventChannel eventChannel;
@override
String get logPrefix => 'MPV';
String get logPrefix => audioOnly ? 'MPV-audio' : 'MPV';
@override
String get playerType => 'mpv';
@@ -61,6 +87,12 @@ class PlayerNative extends PlayerBase {
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) {
return value.replaceAll(r'\', r'\\').replaceAll(separator, '\\$separator');
}
@@ -78,6 +110,16 @@ class PlayerNative extends PlayerBase {
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) {
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', '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;
} catch (e) {
_initFuture = null;
@@ -171,6 +225,10 @@ class PlayerNative extends PlayerBase {
}) async {
if (disposed) return;
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;
configureTimeline(offset: timelineOffset, duration: timelineDuration);
clearTracks();
@@ -178,7 +236,7 @@ class PlayerNative extends PlayerBase {
resetPlaybackProgress(startPosition);
setSeekable(false);
await setVisible(true);
if (!audioOnly) await setVisible(true);
if (media.headers != null && media.headers!.isNotEmpty) {
final headerList = media.headers!.entries.map((e) => '${e.key}: ${e.value}').toList();
@@ -216,6 +274,7 @@ class PlayerNative extends PlayerBase {
if (loadfileOption != null) {
loadfileArgs.addAll(['-1', loadfileOption]);
}
if (audioOnly) _expectOpenFileLoad = true;
await command(loadfileArgs);
// mpv's pause property survives loadfile; in-place reloads pause the old
@@ -239,9 +298,11 @@ class PlayerNative extends PlayerBase {
@override
Future<void> stop() async {
_hasArmedNext = false;
_armedNextUri = null;
await command(['stop']);
setSeekable(false);
await invoke('setVisible', {'visible': false});
if (!audioOnly) await invoke('setVisible', {'visible': false});
}
@override
@@ -250,6 +311,90 @@ class PlayerNative extends PlayerBase {
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
Future<void> selectAudioTrack(AudioTrack track) async {
await setProperty('aid', track.id);
@@ -346,7 +491,7 @@ class PlayerNative extends PlayerBase {
@override
Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {
if (disposed || !Platform.isIOS) return;
if (disposed || audioOnly || !Platform.isIOS) return;
await _ensureInitialized();
await invoke('setDisplayCriteria', {
'criteria': _effectiveDisplayCriteria(criteria)?.toJson(),
@@ -23,6 +23,7 @@ mixin PlayerStreamControllersMixin {
final playbackRestartController = StreamController<void>.broadcast();
final fileLoadedController = StreamController<void>.broadcast();
final backendSwitchedController = StreamController<void>.broadcast();
final trackTransitionController = StreamController<String>.broadcast();
PlayerStreams createStreams() {
return PlayerStreams(
@@ -45,6 +46,7 @@ mixin PlayerStreamControllersMixin {
playbackRestart: playbackRestartController.stream,
fileLoaded: fileLoadedController.stream,
backendSwitched: backendSwitchedController.stream,
trackTransition: trackTransitionController.stream,
);
}
@@ -68,5 +70,6 @@ mixin PlayerStreamControllersMixin {
await playbackRestartController.close();
await fileLoadedController.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.
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({
required this.playing,
required this.completed,
@@ -83,5 +89,6 @@ class PlayerStreams {
required this.playbackRestart,
this.fileLoaded = const Stream<void>.empty(),
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/trackers_provider.dart';
import '../providers/watch_state_store.dart';
import '../database/app_database.dart';
import '../screens/main_screen.dart';
import '../services/api_cache.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 '../utils/app_logger.dart';
import '../watch_together/providers/watch_together_provider.dart';
@@ -166,9 +169,16 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
},
),
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
// Stub until the music playback engine binds a real service;
// profile-session scope so a profile switch ends the session.
ChangeNotifierProvider<MusicPlaybackService>(create: (context) => StubMusicPlaybackService()),
// Profile-session scope so a profile switch tears the music
// session down (dispose stops playback + releases the audio
// 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) {
+8
View File
@@ -47,6 +47,7 @@ import '../services/episode_navigation_service.dart';
import '../services/app_foreground_service.dart';
import '../services/apple_tv_remote_touch_service.dart';
import '../services/media_controls_manager.dart';
import '../services/playback_coordinator.dart';
import '../services/playback_initialization_service.dart';
import '../services/playback_context.dart';
import '../services/local_playback_history.dart';
@@ -668,6 +669,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
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';
final currentPlayer = Player(useExoPlayer: useExoPlayer);
player = currentPlayer;
+19 -4
View File
@@ -876,8 +876,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
/// since those preserve the container shape (Series rows, PlaylistItemId).
///
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) {
return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes);
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
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
@@ -887,7 +894,11 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
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>[];
var start = 0;
while (true) {
@@ -896,6 +907,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
start: start,
size: _pagedListPageSize,
includeItemTypes: includeItemTypes,
byAlbumArtist: byAlbumArtist,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
@@ -927,6 +939,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
int? size,
AbortController? abort,
required String includeItemTypes,
bool byAlbumArtist = false,
}) async {
final offset = start ?? 0;
final pageSize = size ?? _pagedListPageSize;
@@ -934,7 +947,9 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'/Items',
queryParameters: {
'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',
'IncludeItemTypes': includeItemTypes,
'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_item.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../utils/app_logger.dart';
/// Manages OS media controls integration for video playback.
@@ -59,6 +60,8 @@ class MediaControlsManager {
MediaMetadata(
title: metadata.title ?? '',
artist: _buildArtist(metadata),
// Music-only: null for video content, so video behavior is untouched.
album: metadata.kind == MediaKind.track ? metadata.albumTitle : null,
artworkUrl: artworkUrl,
duration: duration,
),
@@ -185,10 +188,16 @@ class MediaControlsManager {
/// Build artist string from metadata
///
/// For music tracks: the performing artist
/// For episodes: "Show Name - Season X Episode Y"
/// For movies: Director or studio
/// For other content: Fallback to year or empty
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) {
final parts = <String>[];
@@ -110,6 +110,22 @@ abstract class MusicPlaybackService extends ChangeNotifier {
/// Stop playback and clear the session (mini-player disappears).
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
/// to `MediaServerClient.fetchLyrics`; null = none available.
Future<Lyrics?> fetchLyrics(MediaItem track);
@@ -211,6 +227,18 @@ class StubMusicPlaybackService extends MusicPlaybackService {
@override
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
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_server_client.dart';
import '../media/media_source_info.dart';
import '../models/audio_quality_preset.dart';
import '../models/download_models.dart';
import '../models/transcode_quality_preset.dart';
import '../mpv/models.dart';
@@ -156,6 +157,7 @@ class PlaybackInitializationService {
String? preferredVersionSignature,
bool preferOffline = false,
TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
String? sessionIdentifier,
String? transcodeSessionId,
@@ -200,6 +202,7 @@ class PlaybackInitializationService {
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
@@ -3,6 +3,7 @@ import '../media/ids.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_server_client.dart';
import '../models/audio_quality_preset.dart';
import '../models/transcode_quality_preset.dart';
import 'multi_server_manager.dart';
import 'playback_context.dart';
@@ -17,6 +18,10 @@ class PlaybackSourceResolver {
/// [preferOffline] overrides the default downloaded-copy preference
/// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for
/// 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({
required MediaItem metadata,
required int selectedMediaIndex,
@@ -24,6 +29,7 @@ class PlaybackSourceResolver {
String? preferredVersionSignature,
required bool offlineLibraryMode,
required TranscodeQualityPreset qualityPreset,
AudioQualityPreset? audioQualityPreset,
int? selectedAudioStreamId,
String? sessionIdentifier,
String? transcodeSessionId,
@@ -38,6 +44,7 @@ class PlaybackSourceResolver {
preferredVersionSignature: preferredVersionSignature,
preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal),
qualityPreset: qualityPreset,
audioQualityPreset: audioQualityPreset,
selectedAudioStreamId: selectedAudioStreamId,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
+26 -9
View File
@@ -22,7 +22,7 @@ static void* get_opengl_proc_address(void* ctx, const char* name) {
namespace mpv {
MpvPlayer::MpvPlayer() {}
MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
MpvPlayer::~MpvPlayer() { Dispose(); }
@@ -41,15 +41,27 @@ bool MpvPlayer::Initialize() {
return false;
}
// Configure mpv for embedded playback.
mpv_set_option_string(mpv_, "vo", "libmpv");
mpv_set_option_string(mpv_, "hwdec", "auto");
if (audio_only_) {
// Music core: no VO, no video decode. vid=no keeps embedded cover art
// 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");
// HDR tone mapping
mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "target-colorspace-hint", "no");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
if (!audio_only_) {
// HDR tone mapping
mpv_set_option_string(mpv_, "tone-mapping", "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_, "input-default-bindings", "no");
mpv_set_option_string(mpv_, "input-vo-keyboard", "no");
@@ -71,11 +83,16 @@ bool MpvPlayer::Initialize() {
// Set up event wakeup callback.
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;
}
bool MpvPlayer::InitRenderContext() {
if (audio_only_) {
g_warning("MPV: InitRenderContext called on an audio-only player");
return false;
}
if (mpv_gl_) {
return true; // Already created.
}
+9 -3
View File
@@ -34,7 +34,10 @@ using RedrawCallback = std::function<void()>;
/// commands, properties, and event dispatching.
class MpvPlayer {
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();
/// Initializes the mpv instance and configures options.
@@ -45,6 +48,7 @@ class MpvPlayer {
/// Creates the mpv OpenGL render context.
/// 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.
bool InitRenderContext();
@@ -60,8 +64,9 @@ class MpvPlayer {
/// Disposes mpv and releases resources.
void Dispose();
/// Returns true if mpv is initialized (has both mpv handle and render context).
bool IsInitialized() const { return mpv_ != nullptr && mpv_gl_ != nullptr; }
/// Returns true if mpv is initialized (has both mpv handle and render
/// 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.
bool IsDisposed() const { return disposed_.load(); }
@@ -140,6 +145,7 @@ class MpvPlayer {
/// Helper to convert mpv_node to FlValue.
::_FlValue* NodeToFlValue(mpv_node* node);
const bool audio_only_;
mpv_handle* mpv_ = nullptr;
mpv_render_context* mpv_gl_ = nullptr;
+42 -10
View File
@@ -16,6 +16,7 @@ struct _MpvPlugin {
MpvTexture* texture; // owned via GObject ref
gboolean visible;
gboolean initialized;
gboolean audio_only;
};
G_DEFINE_TYPE(MpvPlugin, mpv_plugin, G_TYPE_OBJECT)
@@ -67,31 +68,43 @@ static void mpv_plugin_init(MpvPlugin* self) {
self->initialized = FALSE;
self->texture = 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));
self->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar));
self->texture_registrar = fl_plugin_registrar_get_texture_registrar(registrar);
self->player = std::make_unique<mpv::MpvPlayer>();
self->audio_only = audio_only;
// 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();
self->method_channel = fl_method_channel_new(
fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player", FL_METHOD_CODEC(codec));
self->method_channel =
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);
self->event_channel = fl_event_channel_new(
fl_plugin_registrar_get_messenger(registrar), "com.plezy/mpv_player/events", FL_METHOD_CODEC(codec));
g_autofree gchar* event_channel_name = g_strconcat(channel_name, "/events", nullptr);
self->event_channel =
fl_event_channel_new(fl_plugin_registrar_get_messenger(registrar), event_channel_name, FL_METHOD_CODEC(codec));
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_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.
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;
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
response =
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
/// Plugin for MPV video playback on Linux.
/// Plugin for MPV playback on Linux.
///
/// This plugin renders mpv video through Flutter's GPU-accelerated
/// texture pipeline via FlTextureGL.
/// The video instance renders mpv video through Flutter's GPU-accelerated
/// 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())
G_DECLARE_FINAL_TYPE(MpvPlugin, mpv_plugin, MPV, PLUGIN, GObject)
/// Creates a new MpvPlugin instance.
MpvPlugin* mpv_plugin_new(FlPluginRegistrar* registrar);
/// Creates a new MpvPlugin instance on the given method channel name (the
/// 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);
/// Registers the audio-only (music) plugin with Flutter.
void mpv_audio_plugin_register_with_registrar(FlPluginRegistrar* registrar);
G_END_DECLS
#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");
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_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 */; };
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.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 */; };
/* 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; };
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; };
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>"; };
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>"; };
@@ -228,6 +232,8 @@
children = (
B1D51A6A2F00110000000004 /* MpvPlayerCoreBase.swift */,
B1D51A6A2F00110000000008 /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000011 /* MpvAudioPlayerCore.swift */,
B1D51A6A2F00110000000013 /* MpvAudioPlayerPlugin.swift */,
6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */,
6AD8B16B2ED7B50000E9E1B6 /* MpvPipController.swift */,
6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */,
@@ -485,6 +491,8 @@
335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */,
B1D51A6A2F00110000000003 /* MpvPlayerCoreBase.swift in Sources */,
B1D51A6A2F00110000000007 /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000010 /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F00110000000012 /* MpvAudioPlayerPlugin.swift in Sources */,
6AD8B1612ED7B50000E9E1B4 /* MpvPlayerCore.swift in Sources */,
6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */,
6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */,
+4
View File
@@ -30,6 +30,10 @@ class MainFlutterWindow: NSWindow {
MpvPlayerPlugin.register(
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
WindowUtilsPlugin.register(
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()
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()
guard let mpv else {
print("[MpvPlayerCore] Failed to create MPV context")
@@ -357,10 +392,7 @@ class MpvPlayerCoreBase: NSObject {
// subtitle-timing investigation.
checkError(mpv_request_log_messages(mpv, "v"))
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque()))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
configure()
let initResult = mpv_initialize(mpv)
if initResult < 0 {
@@ -384,24 +416,6 @@ class MpvPlayerCoreBase: NSObject {
},
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
}
@@ -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 */; };
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A11A7C50113007825B00A02 /* AtmosProbePlugin.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 */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.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>"; };
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>"; };
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>"; };
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>"; };
@@ -251,6 +255,8 @@
children = (
D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */,
73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */,
B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */,
B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */,
7A11A7C50113007825B00A02 /* AtmosProbePlugin.swift */,
A12B8610AE5D580077264851 /* MpvPlayerCore.swift */,
9D7A998830EDC8F77BF521D1 /* MpvPlayerPlugin.swift */,
@@ -480,6 +486,8 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */,
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */,
B1D51A6A2F00110000000014 /* MpvAudioPlayerCore.swift in Sources */,
B1D51A6A2F00110000000016 /* MpvAudioPlayerPlugin.swift in Sources */,
7A11A7C50113007825B00A01 /* AtmosProbePlugin.swift in Sources */,
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.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") {
MpvPlayerPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: r)
}
if let r = self.registrar(forPlugin: "AtmosProbePlugin") {
AtmosProbePlugin.register(with: r)
}
+3 -1
View File
@@ -98,9 +98,11 @@ bool FlutterWindow::OnCreate() {
}
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");
MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin"));
MpvAudioPlayerPluginRegisterWithRegistrar(
flutter_controller_->engine()->GetRegistrarForPlugin("MpvAudioPlayerPlugin"));
OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n");
RegisterWindowChannel();
+56 -36
View File
@@ -73,7 +73,7 @@ void EnsureMpvInnerSubclassed(HWND host) {
} // namespace
MpvPlayer::MpvPlayer() {}
MpvPlayer::MpvPlayer(bool audio_only) : audio_only_(audio_only) {}
MpvPlayer::~MpvPlayer() { Dispose(); }
@@ -88,31 +88,43 @@ bool MpvPlayer::Initialize(HWND view) {
return false;
}
// Create a child window for mpv to render into, parented to the Flutter
// |view|. The video child then sits in the view's own per-window layer
// stack, above the view's (never-painted) layer-1 content and below the
// engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it
// from painting over neighboring view children. Mouse input over the video
// 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;
if (audio_only_) {
// Windowless music core: no HWND, no VO, no video decode. vid=no keeps
// embedded cover art 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 {
// Create a child window for mpv to render into, parented to the Flutter
// |view|. The video child then sits in the view's own per-window layer
// stack, above the view's (never-painted) layer-1 content and below the
// engine's topmost DComp visual carrying the UI. WS_CLIPSIBLINGS keeps it
// from painting over neighboring view children. Mouse input over the video
// 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.
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
mpv_set_option(mpv_, "wid", MPV_FORMAT_INT64, &wid);
// Set the wid option to embed mpv in our window.
int64_t wid = reinterpret_cast<int64_t>(hwnd_);
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.
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_, "idle", "yes");
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_, "osc", "no");
// Let mpv use display/context detection instead of forcing HDR signaling.
mpv_set_option_string(mpv_, "target-colorspace-hint", "auto");
if (!audio_only_) {
// 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
mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
// Fallback tone mapping when display doesn't support HDR
mpv_set_option_string(mpv_, "tone-mapping", "auto");
mpv_set_option_string(mpv_, "hdr-compute-peak", "auto");
}
// When WASAPI becomes unavailable (sleep, device unplug), fall back to null
// audio output instead of permanently dropping the audio track. Recovery is
@@ -140,15 +154,19 @@ bool MpvPlayer::Initialize(HWND view) {
// Initialize mpv.
int err = mpv_initialize(mpv_);
if (err < 0) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
if (hwnd_) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
mpv_destroy(mpv_);
mpv_ = nullptr;
return false;
}
// Observe video-params/sig-peak for HDR detection
mpv_observe_property(mpv_, 0, "video-params/sig-peak", MPV_FORMAT_DOUBLE);
// Observe video-params/sig-peak for HDR detection (video core only).
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);
// Native observation so audio recovery doesn't depend on the Dart side
// choosing to observe the device list.
@@ -192,11 +210,13 @@ void MpvPlayer::Dispose() {
if (hwnd_) {
::DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
// The subclassed inner window died with hwnd_; clear the forwarding state.
g_mpv_inner_hwnd = nullptr;
g_mpv_inner_original_proc = nullptr;
// The subclassed inner window died with hwnd_; clear the forwarding
// state. Only the owner of the window may do this: the audio-only core
// (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();
}
+6 -2
View File
@@ -23,13 +23,16 @@ class MpvPlayer {
public:
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();
// 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
// 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);
// Disposes mpv and the video window.
@@ -97,6 +100,7 @@ class MpvPlayer {
uint64_t RegisterGetPropertyRequest(GetPropertyCallback callback);
GetPropertyCallback TakeGetPropertyRequest(uint64_t request_id);
const bool audio_only_;
mpv_handle* mpv_ = nullptr;
HWND hwnd_ = nullptr;
+55 -25
View File
@@ -13,29 +13,41 @@ void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef regis
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 {
constexpr UINT kPlatformTaskMessage = WM_APP + 0x4D50;
}
constexpr UINT kAudioPlatformTaskMessage = WM_APP + 0x4D51;
} // namespace
void MpvPlayerPlugin::RegisterWithRegistrar(flutter::PluginRegistrarWindows* registrar) {
auto plugin = std::make_unique<MpvPlayerPlugin>(registrar);
void MpvPlayerPlugin::RegisterWithRegistrar(
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));
}
MpvPlayerPlugin::MpvPlayerPlugin(flutter::PluginRegistrarWindows* registrar)
: registrar_(registrar), platform_thread_id_(::GetCurrentThreadId()) {
MpvPlayerPlugin::MpvPlayerPlugin(
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.
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(
[this](const auto& call, auto result) { HandleMethodCall(call, std::move(result)); });
// Create event channel.
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>>(
[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
// enqueue retry instead of stranding the queue.
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.
proc_id_ =
registrar_->RegisterTopLevelWindowProcDelegate([this](HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == kPlatformTaskMessage) {
if (message == platform_task_message_) {
DrainPlatformTasks();
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
// 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
// is the only ordering that yields white < video < UI.
HWND view = GetChildWindow();
// is the only ordering that yields white < video < UI. The audio-only
// 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);
if (success) {
// Set up event callback.
player_->SetEventCallback([this](const flutter::EncodableValue& event) { SendEvent(event); });
// Start hidden.
player_->SetVisible(false);
if (!audio_only_) {
// Start hidden.
player_->SetVisible(false);
}
result->Success(flutter::EncodableValue(true));
} else {
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));
result->Success();
} 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();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -365,6 +386,12 @@ void MpvPlayerPlugin::HandleMethodCall(
result->Success();
} 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();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -404,13 +431,16 @@ void MpvPlayerPlugin::HandleMethodCall(
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();
} else if (method == "isInitialized") {
bool initialized = player_ && player_->IsInitialized();
result->Success(flutter::EncodableValue(initialized));
// --- Display mode matching ---
} else if (method == "getDisplayModes") {
// --- Display mode matching (video instance only) ---
} else if (!audio_only_ && method == "getDisplayModes") {
HWND hwnd = GetWindow();
auto modes = display_mode_manager_.EnumerateDisplayModes(hwnd);
flutter::EncodableList list;
@@ -418,11 +448,11 @@ void MpvPlayerPlugin::HandleMethodCall(
list.push_back(flutter::EncodableValue(DisplayModeToMap(mode)));
}
result->Success(flutter::EncodableValue(list));
} else if (method == "getCurrentDisplayMode") {
} else if (!audio_only_ && method == "getCurrentDisplayMode") {
HWND hwnd = GetWindow();
auto mode = display_mode_manager_.GetCurrentMode(hwnd);
result->Success(flutter::EncodableValue(DisplayModeToMap(mode)));
} else if (method == "setDisplayMode") {
} else if (!audio_only_ && method == "setDisplayMode") {
const auto* args = method_call.arguments();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -438,17 +468,17 @@ void MpvPlayerPlugin::HandleMethodCall(
bool success =
display_mode_manager_.SetDisplayMode(hwnd, get_int("width"), get_int("height"), get_int("refreshRate"));
result->Success(flutter::EncodableValue(success));
} else if (method == "restoreDisplayMode") {
} else if (!audio_only_ && method == "restoreDisplayMode") {
HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalMode(hwnd);
result->Success(flutter::EncodableValue(success));
} else if (method == "isHDRSupported") {
} else if (!audio_only_ && method == "isHDRSupported") {
HWND hwnd = GetWindow();
result->Success(flutter::EncodableValue(display_mode_manager_.IsHDRSupported(hwnd)));
} else if (method == "isHDREnabled") {
} else if (!audio_only_ && method == "isHDREnabled") {
HWND hwnd = GetWindow();
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();
if (!args || !std::holds_alternative<flutter::EncodableMap>(*args)) {
result->Error("INVALID_ARGS", "Expected map argument");
@@ -464,13 +494,13 @@ void MpvPlayerPlugin::HandleMethodCall(
HWND hwnd = GetWindow();
bool success = display_mode_manager_.SetHDREnabled(hwnd, enabled);
result->Success(flutter::EncodableValue(success));
} else if (method == "restoreSystemHDR") {
} else if (!audio_only_ && method == "restoreSystemHDR") {
HWND hwnd = GetWindow();
bool success = display_mode_manager_.RestoreOriginalHDRState(hwnd);
result->Success(flutter::EncodableValue(success));
} else if (method == "isModeChanged") {
} else if (!audio_only_ && method == "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()));
} else {
result->NotImplemented();
+18 -3
View File
@@ -13,20 +13,30 @@
#include <mutex>
#include <optional>
#include <queue>
#include <string>
#include "display_mode_manager.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 MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar);
namespace mpv {
class MpvPlayerPlugin : public flutter::Plugin {
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();
private:
@@ -43,6 +53,11 @@ class MpvPlayerPlugin : public flutter::Plugin {
flutter::PluginRegistrarWindows* registrar_;
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;
std::unique_ptr<flutter::MethodChannel<flutter::EncodableValue>> method_channel_;
std::unique_ptr<flutter::EventChannel<flutter::EncodableValue>> event_channel_;