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.
84 lines
3.1 KiB
Swift
84 lines
3.1 KiB
Swift
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)
|
|
}
|