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:
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
/// Übersetzt die JSON-Zeilen des mediaremote-adapter.
|
||||
///
|
||||
/// Der Adapter sendet im `stream`-Modus Zeilen der Form
|
||||
/// `{"type":"data","diff":false,"payload":{…}}`. Bei `diff = true` enthält
|
||||
/// `payload` **nur die geänderten Felder** — ein vollständiges Ersetzen würde
|
||||
/// dann Titel und Cover löschen, sobald sich nur die Position ändert.
|
||||
public enum AdapterPayload {
|
||||
|
||||
public struct Envelope: Decodable {
|
||||
public let type: String
|
||||
public let diff: Bool?
|
||||
public let payload: [String: AnyDecodable]?
|
||||
}
|
||||
|
||||
/// Wendet eine Zeile auf den bisherigen Stand an.
|
||||
///
|
||||
/// - Returns: der neue Stand, oder `nil` wenn die Zeile nichts aussagt.
|
||||
public static func apply(line: String, to previous: NowPlaying?) -> NowPlaying? {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
|
||||
envelope.type == "data" else { return nil }
|
||||
|
||||
guard let payload = envelope.payload, !payload.isEmpty else {
|
||||
// Ein leerer Payload bei einer Vollmeldung heißt: es läuft nichts.
|
||||
// Bei einer Teilmeldung heißt er gar nichts und wird verworfen.
|
||||
return envelope.diff == true ? previous : nil
|
||||
}
|
||||
|
||||
// Bei einer Teilmeldung auf dem alten Stand aufbauen, sonst neu beginnen.
|
||||
var state = (envelope.diff == true ? previous : nil) ?? NowPlaying()
|
||||
state.origin = .adapter
|
||||
|
||||
if let value = payload["title"]?.string { state.title = value }
|
||||
if let value = payload["artist"]?.string { state.artist = value }
|
||||
if let value = payload["album"]?.string { state.album = value }
|
||||
if let value = payload["playing"]?.bool { state.isPlaying = value }
|
||||
if let value = payload["duration"]?.double { state.duration = value > 0 ? value : nil }
|
||||
if let value = payload["elapsedTime"]?.double { state.elapsed = value }
|
||||
if let value = payload["playbackRate"]?.double { state.playbackRate = value }
|
||||
if let value = payload["bundleIdentifier"]?.string { state.bundleIdentifier = value }
|
||||
if let value = payload["artworkData"]?.string {
|
||||
state.artwork = Data(base64Encoded: value, options: .ignoreUnknownCharacters)
|
||||
}
|
||||
|
||||
// `timestamp` ist der Zeitpunkt, zu dem `elapsedTime` galt — nicht der
|
||||
// Empfangszeitpunkt. Ohne ihn springt der Scrubber bei jedem Ereignis.
|
||||
if let raw = payload["timestamp"]?.string,
|
||||
let parsed = ISO8601DateFormatter().date(from: raw) {
|
||||
state.asOf = parsed
|
||||
} else {
|
||||
state.asOf = Date()
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimaler dynamischer Wert — der Payload hat kein festes Schema, und ein
|
||||
/// vollständiger `Decodable`-Typ müsste bei jedem neuen Feld nachgezogen werden.
|
||||
public struct AnyDecodable: Decodable, Sendable {
|
||||
private enum Storage: Sendable {
|
||||
case string(String), double(Double), bool(Bool), other
|
||||
}
|
||||
private let storage: Storage
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
// Reihenfolge zählt: `Bool` vor `Double`, sonst wird `true` zu 1.
|
||||
if let value = try? container.decode(Bool.self) { storage = .bool(value) }
|
||||
else if let value = try? container.decode(Double.self) { storage = .double(value) }
|
||||
else if let value = try? container.decode(String.self) { storage = .string(value) }
|
||||
else { storage = .other }
|
||||
}
|
||||
|
||||
public var string: String? { if case .string(let v) = storage { v } else { nil } }
|
||||
public var double: Double? { if case .double(let v) = storage { v } else { nil } }
|
||||
public var bool: Bool? { if case .bool(let v) = storage { v } else { nil } }
|
||||
}
|
||||
Reference in New Issue
Block a user