904b9b3d-c0eb-42f3-acef-958.../Sources/sources/MediaRemoteSource.swift

194 lines
6.9 KiB
Swift
Raw Permalink Normal View History

v2.0.0: full rewrite with multi-source NowPlaying Replaces the v1.0.0 shell (MediaRemote-only, non-functional on macOS 15.4+) with a layered design that handles four playback sources with sticky source priority routing: NowPlayingState (orchestrator, @MainActor, 3s poll + notifications) ├─ MediaRemote (private framework, dlopen) ├─ Spotify AppleScript (desktop) ├─ Apple Music AppleScript (desktop) └─ Chrome JS injection (YouTube / SoundCloud / web music) UI: - Large album art with color-extracted gradient background - Title / artist / album + source badge - Draggable seek bar with hover-grow affordance - Prev / Play·Pause (56pt lime button) / Next controls - Header slot: 20x20 icon + 3-bar pseudo-spectrum that pulses while playing - Bi-lingual (zh / en), follows host appLanguage Graceful degradation: - Host < v2.1.7 → upgrade banner (NSAppleEventsUsageDescription required) - QQ Music / NetEase / Kugou detected → "desktop unsupported, try web version" - Empty state with hint to play something in supported apps Build layout: Sources/ root (MioPlugin.swift contract + MusicPlugin principal) Sources/sources/ data sources Sources/ui/ SwiftUI views Sources/support/ ChineseAppDetector / HostVersionCheck / Localization build.sh now recursively finds .swift under Sources/. Breaking-ish: plugin id ("music-player") and bundle ID preserved. Users on v1.0.0 can upgrade in place via the plugin store. Requires: MioIsland host >= v2.1.7 for full functionality. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:27:21 +00:00
//
// 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)
}
}
}