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,83 @@
import Foundation
/// Woher die Angaben stammen. Bestimmt, was steuerbar ist.
public enum MediaOrigin: String, Equatable, Sendable {
/// mediaremote-adapter kennt alle Quellen inkl. Browser.
case adapter
/// AppleScript für Spotify oder Musik.
case appleScript
/// Nur Medientasten: Steuerung ohne Angaben.
case mediaKeys
}
/// Was gerade läuft.
public struct NowPlaying: Equatable, Sendable {
public var title: String
public var artist: String
public var album: String
public var isPlaying: Bool
/// Gesamtlänge in Sekunden. `nil` bei Livestreams.
public var duration: TimeInterval?
/// Position **zum Zeitpunkt `asOf`** nicht jetzt. Siehe `elapsed(at:)`.
public var elapsed: TimeInterval
public var playbackRate: Double
/// Wann die Angaben gültig waren.
public var asOf: Date
public var artwork: Data?
public var bundleIdentifier: String?
public var origin: MediaOrigin
public init(title: String = "", artist: String = "", album: String = "",
isPlaying: Bool = false, duration: TimeInterval? = nil,
elapsed: TimeInterval = 0, playbackRate: Double = 1,
asOf: Date = Date(), artwork: Data? = nil,
bundleIdentifier: String? = nil, origin: MediaOrigin = .adapter) {
self.title = title
self.artist = artist
self.album = album
self.isPlaying = isPlaying
self.duration = duration
self.elapsed = elapsed
self.playbackRate = playbackRate
self.asOf = asOf
self.artwork = artwork
self.bundleIdentifier = bundleIdentifier
self.origin = origin
}
/// Die Position zum angegebenen Zeitpunkt.
///
/// Der Adapter liefert `elapsedTime` **nicht** laufend nach bei
/// unveränderter Wiedergabe kommt minutenlang kein Ereignis. Ein Scrubber,
/// der stur `elapsed` anzeigt, steht deshalb still, obwohl der Titel läuft.
/// Er muss zwischen den Ereignissen selbst hochzählen, ausgehend von
/// `asOf` und `playbackRate`.
public func elapsed(at date: Date) -> TimeInterval {
guard isPlaying else { return clamp(elapsed) }
let advanced = elapsed + date.timeIntervalSince(asOf) * playbackRate
return clamp(advanced)
}
/// Fortschritt von 0 bis 1, oder `nil` ohne bekannte Länge.
public func progress(at date: Date) -> Double? {
guard let duration, duration > 0 else { return nil }
return min(max(elapsed(at: date) / duration, 0), 1)
}
public var hasMetadata: Bool { !title.isEmpty || !artist.isEmpty }
private func clamp(_ value: TimeInterval) -> TimeInterval {
// Nie über die Länge hinaus: sonst zeigt die Anzeige nach dem Ende
// eines Titels weiter steigende Zahlen, solange kein Ereignis kommt.
guard let duration, duration > 0 else { return max(value, 0) }
return min(max(value, 0), duration)
}
}
/// Was das Widget auslösen kann.
public enum MediaCommand: Equatable, Sendable {
case playPause
case next
case previous
case seek(to: TimeInterval)
}