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:
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user