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