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