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