Adapter für alles inklusive Browser, AppleScript für Spotify und Musik, Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann bedienbar, wenn gar keine Metadaten da sind. Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die Anzeige nach dem Ende weiter steigende Zahlen. Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen. In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true zu 1 und die Wiedergabe gilt für immer als angehalten. Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte Ereignis korrigiert den Stand ohnehin. Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von /usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung. 124 Tests grün.
139 lines
5.4 KiB
Swift
139 lines
5.4 KiB
Swift
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)
|
|
}
|
|
}
|