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.
130 lines
4.7 KiB
Swift
130 lines
4.7 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import OSLog
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
|
|
|
/// Führt die drei Stufen zusammen.
|
|
///
|
|
/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für
|
|
/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern
|
|
/// immer, auch ohne jede Angabe.
|
|
///
|
|
/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die
|
|
/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der
|
|
/// Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion.
|
|
@MainActor
|
|
@Observable
|
|
public final class MediaModel {
|
|
|
|
public private(set) var nowPlaying: NowPlaying?
|
|
/// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur
|
|
/// solange etwas abspielt und das Panel offen ist.
|
|
public private(set) var tick = Date()
|
|
|
|
private let adapter = AdapterProcess()
|
|
private var fallbackTimer: Timer?
|
|
private var tickTimer: Timer?
|
|
private var consumers = 0
|
|
|
|
public init() {
|
|
adapter.onUpdate = { [weak self] state in
|
|
guard let self else { return }
|
|
// Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt.
|
|
if let state {
|
|
nowPlaying = state
|
|
} else {
|
|
nowPlaying = AppleScriptSource.poll()
|
|
}
|
|
updateTicking()
|
|
}
|
|
}
|
|
|
|
/// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein
|
|
/// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der
|
|
/// Adapter soll genau einmal laufen.
|
|
public func addConsumer() {
|
|
consumers += 1
|
|
guard consumers == 1 else { return }
|
|
|
|
if AdapterProcess.isAvailable {
|
|
adapter.start()
|
|
} else {
|
|
log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten")
|
|
}
|
|
|
|
// AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für
|
|
// eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist.
|
|
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() }
|
|
}
|
|
pollFallbackIfNeeded()
|
|
}
|
|
|
|
public func removeConsumer() {
|
|
consumers = max(consumers - 1, 0)
|
|
guard consumers == 0 else { return }
|
|
adapter.stop()
|
|
fallbackTimer?.invalidate()
|
|
fallbackTimer = nil
|
|
tickTimer?.invalidate()
|
|
tickTimer = nil
|
|
}
|
|
|
|
private func pollFallbackIfNeeded() {
|
|
// Nur einspringen, wenn der Adapter nichts liefert — sonst würde die
|
|
// schwächere Quelle die bessere überschreiben.
|
|
guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return }
|
|
nowPlaying = AppleScriptSource.poll()
|
|
updateTicking()
|
|
}
|
|
|
|
/// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei
|
|
/// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen.
|
|
private func updateTicking() {
|
|
let shouldTick = nowPlaying?.isPlaying == true
|
|
if shouldTick, tickTimer == nil {
|
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
|
MainActor.assumeIsolated { [weak self] in self?.tick = Date() }
|
|
}
|
|
} else if !shouldTick {
|
|
tickTimer?.invalidate()
|
|
tickTimer = nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Steuerung
|
|
|
|
public func send(_ command: MediaCommand) {
|
|
switch nowPlaying?.origin {
|
|
case .adapter:
|
|
adapter.send(command)
|
|
case .appleScript:
|
|
if !AppleScriptSource.send(command) { MediaKeys.send(command) }
|
|
case .mediaKeys, .none:
|
|
// Ohne bekannte Quelle bleibt die Taste — sie erreicht auch
|
|
// Programme, die keine der beiden anderen Stufen kennt.
|
|
MediaKeys.send(command)
|
|
}
|
|
|
|
// Sofort umschalten statt auf das nächste Ereignis zu warten. Ein
|
|
// Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt
|
|
// an; kommt das echte Ereignis, korrigiert es den Stand ohnehin.
|
|
if case .playPause = command, var state = nowPlaying {
|
|
state.elapsed = state.elapsed(at: Date())
|
|
state.asOf = Date()
|
|
state.isPlaying.toggle()
|
|
nowPlaying = state
|
|
updateTicking()
|
|
}
|
|
}
|
|
|
|
/// Öffnet das Programm, das gerade abspielt.
|
|
public func activateSource() {
|
|
guard let bundleID = nowPlaying?.bundleIdentifier,
|
|
let app = NSRunningApplication
|
|
.runningApplications(withBundleIdentifier: bundleID).first else { return }
|
|
app.activate()
|
|
}
|
|
}
|