diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index 8584da05..39aa4898 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index 28c81d8c..29b0f615 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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(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(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(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(android.R.id.content) + val contentView = if (audioOnly) null else activity.findViewById(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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt index c32e9028..f1f49e76 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerPlugin.kt @@ -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() 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("videoWidth")?.toInt() ?: 0 val videoHeight = call.argument("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) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt index a9c387dc..b0c80275 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/AudioFocusManager.kt @@ -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) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c96f0156..aa11be93 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 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 */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e5fcd496..57f41c00 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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) } diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 9287ccfc..23ad5cd5 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -96,6 +96,16 @@ abstract class Player { /// Seek to a specific position. Future 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 setNext(Media? media); + /// Select an audio track. Future 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'); + } } diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index f88d6137..865e6761 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -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 setNext(Media? media) async {} + late final PlayerStreams _streams; @override diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index cf6f9cd1..11f9a0d8 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -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? 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 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 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 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 selectAudioTrack(AudioTrack track) async { await setProperty('aid', track.id); @@ -346,7 +491,7 @@ class PlayerNative extends PlayerBase { @override Future 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(), diff --git a/lib/mpv/player/player_stream_controllers.dart b/lib/mpv/player/player_stream_controllers.dart index baa5daa4..6f3ca0e6 100644 --- a/lib/mpv/player/player_stream_controllers.dart +++ b/lib/mpv/player/player_stream_controllers.dart @@ -23,6 +23,7 @@ mixin PlayerStreamControllersMixin { final playbackRestartController = StreamController.broadcast(); final fileLoadedController = StreamController.broadcast(); final backendSwitchedController = StreamController.broadcast(); + final trackTransitionController = StreamController.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(); } } diff --git a/lib/mpv/player/player_streams.dart b/lib/mpv/player/player_streams.dart index bc91ed52..2eea1e58 100644 --- a/lib/mpv/player/player_streams.dart +++ b/lib/mpv/player/player_streams.dart @@ -63,6 +63,12 @@ class PlayerStreams { /// Only emitted on Android when ExoPlayer encounters an unsupported format. final Stream 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 trackTransition; + const PlayerStreams({ required this.playing, required this.completed, @@ -83,5 +89,6 @@ class PlayerStreams { required this.playbackRestart, this.fileLoaded = const Stream.empty(), required this.backendSwitched, + this.trackTransition = const Stream.empty(), }); } diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index c5c2d502..a79baed4 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -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 { }, ), 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(create: (context) => StubMusicPlaybackService()), + // Profile-session scope so a profile switch tears the music + // session down (dispose stops playback + releases the audio + // core). + ChangeNotifierProvider( + create: (context) => MusicPlaybackServiceImpl( + serverManager: context.read().serverManager, + database: context.read(), + offlineWatchService: context.read(), + ), + ), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ChangeNotifierProvider( create: (context) { diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 9b2befc1..0159dd27 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -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 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; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index bbf15e04..17b807d1 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -876,8 +876,15 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { /// since those preserve the container shape (Series rows, PlaylistItemId). /// @override - Future> fetchPlayableDescendants(String parentId) { - return _fetchAllPlayableDescendants(parentId, includeItemTypes: _playableDescendantTypes); + Future> 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> _fetchAllPlayableDescendants(String parentId, {required String includeItemTypes}) async { + Future> _fetchAllPlayableDescendants( + String parentId, { + required String includeItemTypes, + bool byAlbumArtist = false, + }) async { final all = []; 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(), diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 655bfef6..5f098b68 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 = []; diff --git a/lib/services/music/music_playback_service.dart b/lib/services/music/music_playback_service.dart index 516a3426..0d6fc1e6 100644 --- a/lib/services/music/music_playback_service.dart +++ b/lib/services/music/music_playback_service.dart @@ -110,6 +110,22 @@ abstract class MusicPlaybackService extends ChangeNotifier { /// Stop playback and clear the session (mini-player disappears). Future 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 fetchLyrics(MediaItem track); @@ -211,6 +227,18 @@ class StubMusicPlaybackService extends MusicPlaybackService { @override Future 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 fetchLyrics(MediaItem track) async => null; } diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart new file mode 100644 index 00000000..d30cd00a --- /dev/null +++ b/lib/services/music/music_playback_service_impl.dart @@ -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> _playerSubs = []; + + MediaControlsManager? _mediaControls; + StreamSubscription? _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 _positionController = StreamController.broadcast(); + final StreamController _errorsController = StreamController.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 get positionStream => _positionController.stream; + + @override + List 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 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 playFromList({ + required List tracks, + MediaItem? startTrack, + required MusicPlayContext playContext, + bool shuffle = false, + }) { + return _startQueue(tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); + } + + @override + Future playInstantMix(MediaItem seed) async { + final client = _clientFor(seed); + if (client == null) { + _errorsController.add(StateError('No server available for instant mix')); + return; + } + List 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 _startQueue({ + required List 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 _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 _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 _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 _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 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 pause() async { + final player = _player; + if (player == null) return; + await player.pause(); + _setStatus(MusicPlaybackStatus.paused); + } + + @override + Future togglePlayPause() { + final player = _player; + if (player == null) return Future.value(); + return player.state.isActive ? pause() : play(); + } + + @override + Future next() async { + final nextCursor = _queue.nextIndex(manual: true); + if (nextCursor == null) return; + await _advanceTo(nextCursor); + } + + @override + Future 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 seek(Duration position) async { + await _player?.seek(position); + } + + @override + Future 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 tracks) => _enqueue(tracks, next: true); + + @override + void addToEnd(List 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 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 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 _stopForVideoClaim() => _stopSession(endStatus: MusicPlaybackStatus.idle); + + Future _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 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(); + } +} diff --git a/lib/services/music/music_queue_controller.dart b/lib/services/music/music_queue_controller.dart new file mode 100644 index 00000000..3a3867a4 --- /dev/null +++ b/lib/services/music/music_queue_controller.dart @@ -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 _items = []; + + /// Playback order: indexes into [_items]. Identity when unshuffled. + List _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 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 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 tracks) { + if (tracks.isEmpty) return; + _order.insertAll(_cursor < 0 ? 0 : _cursor + 1, _append(tracks)); + if (_cursor < 0) _cursor = 0; + } + + void addToEnd(List tracks) { + if (tracks.isEmpty) return; + _order.addAll(_append(tracks)); + if (_cursor < 0) _cursor = 0; + } + + List _append(List 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]--; + } + } + } +} diff --git a/lib/services/music/music_source_resolver.dart b/lib/services/music/music_source_resolver.dart new file mode 100644 index 00000000..4481321e --- /dev/null +++ b/lib/services/music/music_source_resolver.dart @@ -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? 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 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 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, + ); + } +} diff --git a/lib/services/playback_coordinator.dart b/lib/services/playback_coordinator.dart new file mode 100644 index 00000000..77a6a1b8 --- /dev/null +++ b/lib/services/playback_coordinator.dart @@ -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 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 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 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 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 claimMusic() async {} +} diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 8da585b0..cf007b14 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -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, diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index ab0d26ef..bae80697 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -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 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, diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 2c7da7cd..48bc5e05 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -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. } diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index cadf715d..7fb386b5 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -34,7 +34,10 @@ using RedrawCallback = std::function; /// 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; diff --git a/linux/runner/mpv/mpv_plugin.cc b/linux/runner/mpv/mpv_plugin.cc index 98406c4c..09988169 100644 --- a/linux/runner/mpv/mpv_plugin.cc +++ b/linux/runner/mpv/mpv_plugin.cc @@ -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(); + 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(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(/*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)))); diff --git a/linux/runner/mpv/mpv_plugin.h b/linux/runner/mpv/mpv_plugin.h index b4c31646..45a9a960 100644 --- a/linux/runner/mpv/mpv_plugin.h +++ b/linux/runner/mpv/mpv_plugin.h @@ -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_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 3aea47e3..f5ab1cd4 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -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)); } diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 0401bcee..887943f1 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; @@ -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 */, diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index de62a9b9..82c442a2 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -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")) diff --git a/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift b/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift new file mode 100644 index 00000000..f0364fcb --- /dev/null +++ b/shared/apple/MpvPlayer/MpvAudioPlayerCore.swift @@ -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() + } +} diff --git a/shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift b/shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift new file mode 100644 index 00000000..c687d19c --- /dev/null +++ b/shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift @@ -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) + } + } +} diff --git a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift index 5fbfb4e9..b2bb5cbf 100644 --- a/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift +++ b/shared/apple/MpvPlayer/MpvPlayerCoreBase.swift @@ -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 } diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart new file mode 100644 index 00000000..96bea525 --- /dev/null +++ b/test/services/music/music_playback_service_test.dart @@ -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.broadcast(sync: true); + final completedCtrl = StreamController.broadcast(sync: true); + final bufferingCtrl = StreamController.broadcast(sync: true); + final positionCtrl = StreamController.broadcast(sync: true); + final durationCtrl = StreamController.broadcast(sync: true); + final seekableCtrl = StreamController.broadcast(sync: true); + final bufferCtrl = StreamController.broadcast(sync: true); + final volumeCtrl = StreamController.broadcast(sync: true); + final rateCtrl = StreamController.broadcast(sync: true); + final tracksCtrl = StreamController.broadcast(sync: true); + final trackCtrl = StreamController.broadcast(sync: true); + final logCtrl = StreamController.broadcast(sync: true); + final errorCtrl = StreamController.broadcast(sync: true); + final audioDeviceCtrl = StreamController.broadcast(sync: true); + final audioDevicesCtrl = StreamController>.broadcast(sync: true); + final bufferRangesCtrl = StreamController>.broadcast(sync: true); + final playbackRestartCtrl = StreamController.broadcast(sync: true); + final fileLoadedCtrl = StreamController.broadcast(sync: true); + final backendSwitchedCtrl = StreamController.broadcast(sync: true); + final trackTransitionCtrl = StreamController.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 openedUris = []; + final List setNextCalls = []; + final List 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 open( + Media media, { + bool play = true, + bool isLive = false, + List? 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 play() async { + playCalls++; + _state = _state.copyWith(playing: true, completed: false); + playingCtrl.add(true); + } + + @override + Future pause() async { + pauseCalls++; + _state = _state.copyWith(playing: false); + playingCtrl.add(false); + } + + @override + Future playOrPause() => _state.playing ? pause() : play(); + + @override + Future stop() async { + stopCalls++; + _state = _state.copyWith(playing: false, position: Duration.zero); + } + + @override + Future seek(Duration position) async { + seeks.add(position); + _state = _state.copyWith(position: position, completed: false); + } + + @override + Future setNext(Media? media) async { + setNextCalls.add(media); + _armedMedia = media; + } + + @override + bool get disposed => _disposed; + + @override + Future dispose({bool preserveDisplayMode = false}) async { + _disposed = true; + } + + // Inert surface below — never exercised by the music engine. + @override + Future selectAudioTrack(AudioTrack track) async {} + + @override + Future selectSubtitleTrack(SubtitleTrack track) async {} + + @override + Future 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 addSubtitleTrack({required String uri, String? title, String? language, bool select = false}) async {} + + @override + Future setVolume(double volume) async {} + + @override + Future setRate(double rate) async {} + + @override + Future setAudioDevice(AudioDevice device) async {} + + @override + Future setProperty(String name, String value) async {} + + @override + Future getProperty(String name) async => null; + + @override + Future setLogLevel(String level) async {} + + @override + Future command(List args) async {} + + @override + Future setDisplayCriteria(MediaDisplayCriteria? criteria, {int extraDelayMs = 0}) async {} + + @override + Future configureSubtitleFonts() async {} + + @override + Future setAudioPassthrough(bool enabled) async {} + + @override + Future setAudioNormalization(bool enabled) async {} + + @override + Future setAudioDownmix({required bool enabled, required int centerBoostDb, required bool normalize}) async {} + + @override + Future setVisible(bool visible, {bool restoreOnWindowVisible = false}) async => true; + + @override + Future updateFrame() async {} + + @override + Future setVideoFrameRate( + double fps, + int durationMs, { + int extraDelayMs = 0, + int videoWidth = 0, + int videoHeight = 0, + }) async => false; + + @override + Future clearVideoFrameRate() async {} + + @override + Future 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 setBoxFitMode(int mode) async {} + + @override + Future setVideoZoom(double scale) async {} + + @override + Future> getStats() async => {}; + + @override + Future runtimePlayerType() async => 'fake'; + + @override + Future requestAudioFocus() async => true; + + @override + Future 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 reports = []; + final List markedWatched = []; + + Iterable 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 markWatched(MediaItem item) async { + markedWatched.add(item.id); + } + + @override + Future 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 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 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 failingIds = {}; + final Map resolveCounts = {}; + + @override + Future 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.broadcast(sync: true); + final List metadataTitles = []; + bool cleared = false; + + void closeControllers() { + eventsCtrl.close(); + } + + @override + Stream get controlEvents => eventsCtrl.stream; + + @override + Future updateMetadata({required MediaItem metadata, MediaServerClient? client, Duration? duration}) async { + metadataTitles.add(metadata.title ?? ''); + } + + @override + Future updatePlaybackState({ + required bool isPlaying, + required Duration position, + required double speed, + bool force = false, + }) async {} + + @override + Future setControlsEnabled({bool canGoNext = false, bool canGoPrevious = false, bool canSeek = false}) async {} + + @override + Future 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 players; + + FakePlayer get player => players.last; + + factory _Harness.create() { + final client = FakeMediaServerClient(); + final resolver = FakeMusicSourceResolver(client: client); + final controls = FakeMediaControlsManager(); + final players = []; + 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 playTracks(List 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 = []; + 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 = []; + 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); + }); +} diff --git a/test/services/music/music_queue_controller_test.dart b/test/services/music/music_queue_controller_test.dart new file mode 100644 index 00000000..2d4b9a34 --- /dev/null +++ b/test/services/music/music_queue_controller_test.dart @@ -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 _ids(List 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]); + }); + }); +} diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj index ec789873..88b7fda7 100644 --- a/tvos/Runner.xcodeproj/project.pbxproj +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; C0455EBA0EF4A61D3B71D2D7 /* SharedPreferencesPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedPreferencesPlugin.swift; sourceTree = ""; }; D41AA251EF365516E2AC5287 /* PathProviderPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PathProviderPlugin.swift; sourceTree = ""; }; + B1D51A6A2F00110000000015 /* MpvAudioPlayerCore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerCore.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerCore.swift; sourceTree = ""; }; + B1D51A6A2F00110000000017 /* MpvAudioPlayerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvAudioPlayerPlugin.swift; path = ../shared/apple/MpvPlayer/MpvAudioPlayerPlugin.swift; sourceTree = ""; }; D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = ""; }; F2F829B3F190657106F66379 /* TopShelfProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TopShelfProvider.swift; sourceTree = ""; }; F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackageInfoPlusPlugin.swift; sourceTree = ""; }; @@ -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 */, diff --git a/tvos/Runner/AppDelegate.swift b/tvos/Runner/AppDelegate.swift index 1243a869..0eee747b 100644 --- a/tvos/Runner/AppDelegate.swift +++ b/tvos/Runner/AppDelegate.swift @@ -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) } diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index e8d0dbfe..e08cb6cc 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -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(); diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 4d0258d2..8f658c15 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -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(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(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(); } diff --git a/windows/runner/mpv/mpv_player.h b/windows/runner/mpv/mpv_player.h index 44494c21..3240ecb6 100644 --- a/windows/runner/mpv/mpv_player.h +++ b/windows/runner/mpv/mpv_player.h @@ -23,13 +23,16 @@ class MpvPlayer { public: using EventCallback = std::function; - 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; diff --git a/windows/runner/mpv/mpv_plugin.cpp b/windows/runner/mpv/mpv_plugin.cpp index ca9b7d44..139ab001 100644 --- a/windows/runner/mpv/mpv_plugin.cpp +++ b/windows/runner/mpv/mpv_plugin.cpp @@ -13,29 +13,41 @@ void MpvPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef regis flutter::PluginRegistrarManager::GetInstance()->GetRegistrar(registrar)); } +void MpvAudioPlayerPluginRegisterWithRegistrar(FlutterDesktopPluginRegistrarRef registrar) { + mpv::MpvPlayerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance()->GetRegistrar(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(registrar); +void MpvPlayerPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar, const std::string& channel_name, bool audio_only) { + auto plugin = std::make_unique(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>( - 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>( - registrar->messenger(), "com.plezy/mpv_player/events", &flutter::StandardMethodCodec::GetInstance()); + registrar->messenger(), channel_name + "/events", &flutter::StandardMethodCodec::GetInstance()); auto handler = std::make_unique>( [this]( @@ -92,7 +104,7 @@ void MpvPlayerPlugin::PostToPlatformThread(std::function 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 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(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(); + player_ = std::make_unique(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(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(*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(*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(*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(*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(); diff --git a/windows/runner/mpv/mpv_plugin.h b/windows/runner/mpv/mpv_plugin.h index 376f1531..0331c0e9 100644 --- a/windows/runner/mpv/mpv_plugin.h +++ b/windows/runner/mpv/mpv_plugin.h @@ -13,20 +13,30 @@ #include #include #include +#include #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> method_channel_; std::unique_ptr> event_channel_;