Phase 4: Medien-Widget mit dreistufiger Kaskade

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.
This commit is contained in:
Guido Schmit
2026-08-10 21:02:13 +02:00
parent 03dd7b945e
commit 433cdcc3dd
53 changed files with 4888 additions and 2 deletions

View File

@@ -0,0 +1,138 @@
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)
}
}