mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
194 lines
6.9 KiB
Swift
194 lines
6.9 KiB
Swift
|
|
//
|
||
|
|
// MediaRemoteSource.swift
|
||
|
|
// MioIsland Music Plugin
|
||
|
|
//
|
||
|
|
// Dynamically loads /System/Library/PrivateFrameworks/MediaRemote.framework
|
||
|
|
// so we can read system Now Playing info without linking a private API.
|
||
|
|
//
|
||
|
|
// Known caveat on macOS 15.4+: Apple restricted MRMediaRemoteGetNowPlayingInfo
|
||
|
|
// to callers with a specific entitlement. For regular third party apps the
|
||
|
|
// callback returns an empty dictionary. When this happens we surface the
|
||
|
|
// empty result and NowPlayingState falls through to AppleScript sources.
|
||
|
|
//
|
||
|
|
|
||
|
|
import AppKit
|
||
|
|
|
||
|
|
// MARK: - MediaRemote function signatures
|
||
|
|
|
||
|
|
private typealias MRMediaRemoteRegisterForNowPlayingNotificationsFunction =
|
||
|
|
@convention(c) (DispatchQueue) -> Void
|
||
|
|
private typealias MRMediaRemoteGetNowPlayingInfoFunction =
|
||
|
|
@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
|
||
|
|
private typealias MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction =
|
||
|
|
@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
|
||
|
|
private typealias MRMediaRemoteSendCommandFunction =
|
||
|
|
@convention(c) (UInt32, UnsafeMutableRawPointer?) -> Bool
|
||
|
|
private typealias MRMediaRemoteSetElapsedTimeFunction =
|
||
|
|
@convention(c) (Double) -> Void
|
||
|
|
|
||
|
|
// MARK: - Command enum (public API of this file)
|
||
|
|
|
||
|
|
enum MediaRemoteCommand: UInt32 {
|
||
|
|
case play = 0
|
||
|
|
case pause = 1
|
||
|
|
case togglePlayPause = 2
|
||
|
|
case stop = 3
|
||
|
|
case nextTrack = 4
|
||
|
|
case previousTrack = 5
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Payload struct
|
||
|
|
|
||
|
|
struct MediaRemoteInfo {
|
||
|
|
var title: String = ""
|
||
|
|
var artist: String = ""
|
||
|
|
var album: String = ""
|
||
|
|
var artwork: NSImage?
|
||
|
|
var duration: TimeInterval = 0
|
||
|
|
var elapsedTime: TimeInterval = 0
|
||
|
|
var playbackRate: Double = 0
|
||
|
|
var isPlaying: Bool = false
|
||
|
|
var bundleIdentifier: String = ""
|
||
|
|
|
||
|
|
var hasTrack: Bool { !title.isEmpty }
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Info dictionary keys
|
||
|
|
|
||
|
|
private let kTitle = "kMRMediaRemoteNowPlayingInfoTitle"
|
||
|
|
private let kArtist = "kMRMediaRemoteNowPlayingInfoArtist"
|
||
|
|
private let kAlbum = "kMRMediaRemoteNowPlayingInfoAlbum"
|
||
|
|
private let kArtworkData = "kMRMediaRemoteNowPlayingInfoArtworkData"
|
||
|
|
private let kDuration = "kMRMediaRemoteNowPlayingInfoDuration"
|
||
|
|
private let kElapsedTime = "kMRMediaRemoteNowPlayingInfoElapsedTime"
|
||
|
|
private let kPlaybackRate = "kMRMediaRemoteNowPlayingInfoPlaybackRate"
|
||
|
|
|
||
|
|
// MARK: - Source
|
||
|
|
|
||
|
|
final class MediaRemoteSource {
|
||
|
|
private var handle: UnsafeMutableRawPointer?
|
||
|
|
private var registerFn: MRMediaRemoteRegisterForNowPlayingNotificationsFunction?
|
||
|
|
private var getInfoFn: MRMediaRemoteGetNowPlayingInfoFunction?
|
||
|
|
private var getIsPlayingFn: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction?
|
||
|
|
private var sendCommandFn: MRMediaRemoteSendCommandFunction?
|
||
|
|
private var setElapsedTimeFn: MRMediaRemoteSetElapsedTimeFunction?
|
||
|
|
|
||
|
|
private var notificationObservers: [NSObjectProtocol] = []
|
||
|
|
|
||
|
|
init() {
|
||
|
|
loadFramework()
|
||
|
|
}
|
||
|
|
|
||
|
|
deinit {
|
||
|
|
for token in notificationObservers {
|
||
|
|
NotificationCenter.default.removeObserver(token)
|
||
|
|
}
|
||
|
|
if let handle {
|
||
|
|
dlclose(handle)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Loading
|
||
|
|
|
||
|
|
private func loadFramework() {
|
||
|
|
let path = "/System/Library/PrivateFrameworks/MediaRemote.framework/MediaRemote"
|
||
|
|
guard let h = dlopen(path, RTLD_NOW) else {
|
||
|
|
NSLog("[mio-plugin-music] MediaRemote dlopen failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handle = h
|
||
|
|
|
||
|
|
if let sym = dlsym(h, "MRMediaRemoteRegisterForNowPlayingNotifications") {
|
||
|
|
registerFn = unsafeBitCast(sym, to: MRMediaRemoteRegisterForNowPlayingNotificationsFunction.self)
|
||
|
|
}
|
||
|
|
if let sym = dlsym(h, "MRMediaRemoteGetNowPlayingInfo") {
|
||
|
|
getInfoFn = unsafeBitCast(sym, to: MRMediaRemoteGetNowPlayingInfoFunction.self)
|
||
|
|
}
|
||
|
|
if let sym = dlsym(h, "MRMediaRemoteGetNowPlayingApplicationIsPlaying") {
|
||
|
|
getIsPlayingFn = unsafeBitCast(sym, to: MRMediaRemoteGetNowPlayingApplicationIsPlayingFunction.self)
|
||
|
|
}
|
||
|
|
if let sym = dlsym(h, "MRMediaRemoteSendCommand") {
|
||
|
|
sendCommandFn = unsafeBitCast(sym, to: MRMediaRemoteSendCommandFunction.self)
|
||
|
|
}
|
||
|
|
if let sym = dlsym(h, "MRMediaRemoteSetElapsedTime") {
|
||
|
|
setElapsedTimeFn = unsafeBitCast(sym, to: MRMediaRemoteSetElapsedTimeFunction.self)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Public API
|
||
|
|
|
||
|
|
/// Pull the current Now Playing dictionary. completion runs on the main queue.
|
||
|
|
/// On macOS 15.4+ the callback may deliver an empty dict; caller should
|
||
|
|
/// treat a nil MediaRemoteInfo (or one where hasTrack is false) as a miss.
|
||
|
|
func fetchInfo(completion: @escaping (MediaRemoteInfo?) -> Void) {
|
||
|
|
guard let getInfoFn else {
|
||
|
|
completion(nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
getInfoFn(DispatchQueue.main) { dict in
|
||
|
|
guard !dict.isEmpty else {
|
||
|
|
completion(nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var info = MediaRemoteInfo()
|
||
|
|
info.title = dict[kTitle] as? String ?? ""
|
||
|
|
info.artist = dict[kArtist] as? String ?? ""
|
||
|
|
info.album = dict[kAlbum] as? String ?? ""
|
||
|
|
info.duration = dict[kDuration] as? TimeInterval ?? 0
|
||
|
|
info.elapsedTime = dict[kElapsedTime] as? TimeInterval ?? 0
|
||
|
|
info.playbackRate = dict[kPlaybackRate] as? Double ?? 0
|
||
|
|
info.isPlaying = info.playbackRate > 0
|
||
|
|
|
||
|
|
if let data = dict[kArtworkData] as? Data {
|
||
|
|
info.artwork = NSImage(data: data)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Title empty and no artwork means MediaRemote returned a stale /
|
||
|
|
// blocked payload. Treat as miss.
|
||
|
|
if info.title.isEmpty {
|
||
|
|
completion(nil)
|
||
|
|
} else {
|
||
|
|
completion(info)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Fire and forget control command.
|
||
|
|
func sendCommand(_ cmd: MediaRemoteCommand) {
|
||
|
|
guard let sendCommandFn else { return }
|
||
|
|
_ = sendCommandFn(cmd.rawValue, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Adjust the playhead position of whatever is currently playing.
|
||
|
|
func setElapsedTime(_ t: TimeInterval) {
|
||
|
|
setElapsedTimeFn?(max(0, t))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Register for MediaRemote change notifications. The closure is dispatched
|
||
|
|
/// on the main queue so callers can touch UI state directly.
|
||
|
|
func registerForNotifications(onChange: @escaping () -> Void) {
|
||
|
|
registerFn?(DispatchQueue.main)
|
||
|
|
|
||
|
|
let names = [
|
||
|
|
"kMRMediaRemoteNowPlayingInfoDidChangeNotification",
|
||
|
|
"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification",
|
||
|
|
"kMRMediaRemoteNowPlayingApplicationDidChangeNotification",
|
||
|
|
"kMRMediaRemoteNowPlayingPlaybackQueueChangedNotification"
|
||
|
|
]
|
||
|
|
|
||
|
|
let center = NotificationCenter.default
|
||
|
|
for raw in names {
|
||
|
|
let token = center.addObserver(
|
||
|
|
forName: NSNotification.Name(raw),
|
||
|
|
object: nil,
|
||
|
|
queue: .main
|
||
|
|
) { _ in
|
||
|
|
onChange()
|
||
|
|
}
|
||
|
|
notificationObservers.append(token)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|