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

Audio-only mpv core on every platform (dedicated
com.plezy/mpv_audio_player channels): parameterized android/windows/
linux mpv plugins and a new apple MpvAudioPlayerCore, all skipping
video/window paths (vid=no, audio-display=no, gapless-audio=weak).
MusicPlaybackService drives an in-memory queue with shuffle/repeat,
file-loaded-event gapless arming (property edges coalesce and the
android bridge drops them), per-track progress reporting, OS media
controls, audio focus, sleep timer, and error auto-skip.
PlaybackCoordinator enforces one live native player: starting video
disposes the audio core first.
This commit is contained in:
edde746
2026-07-05 19:26:28 +02:00
parent 05a631415e
commit 422db75b5b
41 changed files with 3353 additions and 269 deletions
@@ -0,0 +1,58 @@
import Foundation
import Libmpv
/// Audio-only mpv core for music playback.
///
/// Reuses [MpvPlayerCoreBase]'s context/event/property machinery (all of it
/// instance-scoped) but never touches a render surface: video decoding is
/// disabled outright, embedded cover art must not surface as a video track
/// (`audio-display=no`), and none of the display-criteria/EDR/PiP paths apply.
/// Lives alongside and independently of the video core, so it can be
/// created and destroyed repeatedly regardless of the video plugin's state.
class MpvAudioPlayerCore: MpvPlayerCoreBase {
private var isDisposed = false
func initialize() -> Bool {
guard !isInitialized else {
print("[MpvAudioPlayerCore] Already initialized")
return true
}
let created = createMpvContext { [self] in
guard let mpv else { return }
checkError(mpv_set_option_string(mpv, "vid", "no"))
// Critical: without this, embedded cover art is exposed as a video
// track and mpv would try to present it.
checkError(mpv_set_option_string(mpv, "audio-display", "no"))
checkError(mpv_set_option_string(mpv, "force-window", "no"))
// Gapless track transitions when the next playlist entry matches the
// current audio format (the Dart side arms it via `loadfile append`).
checkError(mpv_set_option_string(mpv, "gapless-audio", "weak"))
// Match the video core: hold the final track at EOF (eof-reached flips
// true) instead of unloading, so Dart's completed handling still works.
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
}
guard created else { return false }
isInitialized = true
print("[MpvAudioPlayerCore] Initialized successfully")
return true
}
func dispose() {
// Guard double-dispose: the plugin calls dispose() then drops the strong
// ref, which fires deinit dispose() again (same pattern as the video
// cores).
guard !isDisposed else { return }
isDisposed = true
disposeSharedState(destroySynchronously: false)
isInitialized = false
print("[MpvAudioPlayerCore] Disposed")
}
deinit {
dispose()
}
}
@@ -0,0 +1,127 @@
#if os(iOS) || os(tvOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#endif
/// Flutter plugin for the dedicated audio-only mpv core (music playback).
///
/// Registers `com.plezy/mpv_audio_player` + `/events` and delegates all
/// generic property/command/observe traffic to the shared [MpvPluginShared]
/// handlers. There is no render layer, so the visual hooks are no-ops and
/// `setVisible`/`updateFrame` succeed without doing anything. Shared across
/// iOS, tvOS, and macOS unlike the video plugin there is nothing
/// platform-specific beyond the messenger accessor.
class MpvAudioPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
private var playerCore: MpvAudioPlayerCore?
var eventSink: FlutterEventSink?
var nameToId: [String: Int] = [:]
// MpvPluginShared conformance the audio core has no visual surface.
var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) {}
func updatePlayerFrame() {}
func didSetPauseProperty(value _: String) {}
// MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) {
#if os(macOS)
let messenger = registrar.messenger
#else
let messenger = registrar.messenger()
#endif
let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_audio_player",
binaryMessenger: messenger
)
let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_audio_player/events",
binaryMessenger: messenger
)
let instance = MpvAudioPlayerPlugin()
registrar.addMethodCallDelegate(instance, channel: methodChannel)
eventChannel.setStreamHandler(instance)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
// MARK: - FlutterPlugin Method Handler
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "initialize":
handleInitialize(result: result)
case "dispose":
handleDispose(result: result)
case "setProperty":
handleSetProperty(call: call, result: result)
case "getProperty":
handleGetProperty(call: call, result: result)
case "observeProperty":
handleObserveProperty(call: call, result: result)
case "command":
handleCommand(call: call, result: result)
case "isInitialized":
result(playerCore?.isInitialized ?? false)
case "setVisible", "updateFrame":
// No render layer succeed so shared Dart call sites stay unconditional.
result(nil)
case "setLogLevel":
handleSetLogLevel(call: call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func handleInitialize(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self else {
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
return
}
if self.playerCore?.isInitialized == true {
result(true)
return
}
let core = MpvAudioPlayerCore()
core.delegate = self
guard core.initialize() else {
result(
FlutterError(
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV audio core", details: nil))
return
}
self.playerCore = core
result(true)
}
}
private func handleDispose(result: @escaping FlutterResult) {
DispatchQueue.main.async { [weak self] in
guard let self else { result(nil); return }
self.playerCore?.dispose()
self.playerCore = nil
result(nil)
}
}
}
+36 -22
View File
@@ -346,6 +346,41 @@ class MpvPlayerCoreBase: NSObject {
applyDvConversionModeEnvironment()
let created = createMpvContext { [self] in
guard let mpv else { return }
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque()))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
}
guard created, let mpv else { return false }
mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE)
mpv_observe_property(
mpv, Self.internalDoviProfileObserverId,
"current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalDoviLevelObserverId,
"current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalContainerFpsObserverId,
"container-fps", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING)
mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING)
mpv_observe_property(
mpv, Self.internalVideoColorMatrixObserverId,
"video-params/colormatrix", MPV_FORMAT_STRING)
return true
}
/// Create the mpv context, apply pre-init options via `configure`, run
/// `mpv_initialize`, and install the wakeup callback. Everything here is
/// instance-scoped (per-instance dispatch queue, request table, and retained
/// wakeup context), so the video core and the audio-only core can each own
/// an independent context and be created/destroyed at any time.
func createMpvContext(configure: () -> Void) -> Bool {
mpv = mpv_create()
guard let mpv else {
print("[MpvPlayerCore] Failed to create MPV context")
@@ -357,10 +392,7 @@ class MpvPlayerCoreBase: NSObject {
// subtitle-timing investigation.
checkError(mpv_request_log_messages(mpv, "v"))
var layer = Int64(Int(bitPattern: Unmanaged.passUnretained(renderLayer).toOpaque()))
checkError(mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &layer))
applySharedMpvOptions()
configurePlatformMpvOptions()
configure()
let initResult = mpv_initialize(mpv)
if initResult < 0 {
@@ -384,24 +416,6 @@ class MpvPlayerCoreBase: NSObject {
},
wakeupContext
)
mpv_observe_property(mpv, Self.internalSigPeakObserverId, "video-params/sig-peak", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalWidthObserverId, "width", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalHeightObserverId, "height", MPV_FORMAT_DOUBLE)
mpv_observe_property(
mpv, Self.internalDoviProfileObserverId,
"current-tracks/video/dolby-vision-profile", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalDoviLevelObserverId,
"current-tracks/video/dolby-vision-level", MPV_FORMAT_INT64)
mpv_observe_property(
mpv, Self.internalContainerFpsObserverId,
"container-fps", MPV_FORMAT_DOUBLE)
mpv_observe_property(mpv, Self.internalVideoGammaObserverId, "video-params/gamma", MPV_FORMAT_STRING)
mpv_observe_property(mpv, Self.internalVideoPrimariesObserverId, "video-params/primaries", MPV_FORMAT_STRING)
mpv_observe_property(
mpv, Self.internalVideoColorMatrixObserverId,
"video-params/colormatrix", MPV_FORMAT_STRING)
return true
}