import Foundation import AppKit import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media") /// Zweite Stufe: AppleScript für Spotify und Musik. /// /// Deckt weit weniger ab als der Adapter — Browser und alle anderen Programme /// fallen heraus. Dafür ist es offiziell unterstützt und überlebt /// macOS-Aktualisierungen, die den Adapter brechen könnten. public enum AppleScriptSource { private struct Player { let bundleID: String let name: String } private static let players = [ Player(bundleID: "com.spotify.client", name: "Spotify"), Player(bundleID: "com.apple.Music", name: "Music"), ] /// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen — /// ein AppleScript an ein beendetes Programm würde es sonst **starten**. public static func poll() -> NowPlaying? { for player in players { guard NSRunningApplication .runningApplications(withBundleIdentifier: player.bundleID) .isEmpty == false else { continue } if let state = query(player) { return state } } return nil } private static func query(_ player: Player) -> NowPlaying? { let script = """ tell application id "\(player.bundleID)" if player state is playing or player state is paused then set t to name of current track set a to artist of current track set al to album of current track set dur to duration of current track set pos to player position set st to (player state as text) return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st end if end tell """ var error: NSDictionary? guard let result = NSAppleScript(source: script)? .executeAndReturnError(&error).stringValue else { if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") } return nil } let parts = result.components(separatedBy: "\n") guard parts.count >= 6 else { return nil } // Spotify gibt die Länge in Millisekunden, Musik in Sekunden. let rawDuration = Double(parts[3]) ?? 0 let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration return NowPlaying(title: parts[0], artist: parts[1], album: parts[2], isPlaying: parts[5].hasPrefix("playing"), duration: duration > 0 ? duration : nil, elapsed: Double(parts[4]) ?? 0, playbackRate: 1, asOf: Date(), artwork: nil, bundleIdentifier: player.bundleID, origin: .appleScript) } public static func send(_ command: MediaCommand) -> Bool { for player in players { guard !NSRunningApplication .runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue } let action = switch command { case .playPause: "playpause" case .next: "next track" case .previous: "previous track" case .seek(let position): "set player position to \(position)" } var error: NSDictionary? NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")? .executeAndReturnError(&error) if error == nil { return true } } return false } } /// Letzte Stufe: die Medientasten der Tastatur. /// /// Sie kennt keine Titel und keine Position — aber sie steuert **jedes** /// Programm, auch solche, die weder der Adapter noch AppleScript erreichen. /// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die /// Now-Playing-Schnittstelle weiter zumacht. public enum MediaKeys { private enum Key: Int32 { case playPause = 16 // NX_KEYTYPE_PLAY case next = 17 // NX_KEYTYPE_NEXT case previous = 18 // NX_KEYTYPE_PREVIOUS } public static func send(_ command: MediaCommand) { let key: Key? = switch command { case .playPause: .playPause case .next: .next case .previous: .previous case .seek: nil // Über Tasten nicht möglich. } guard let key else { return } post(key, down: true) post(key, down: false) } private static func post(_ key: Key, down: Bool) { // Medientasten sind keine gewöhnlichen Tastendrücke, sondern // NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme // nirgends an. let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00)) let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8)) guard let event = NSEvent.otherEvent(with: .systemDefined, location: .zero, modifierFlags: flags, timestamp: 0, windowNumber: 0, context: nil, subtype: 8, data1: data1, data2: -1)?.cgEvent else { return } event.post(tap: .cghidEventTap) } }