Files
onyx/Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Guido Schmit 433cdcc3dd 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.
2026-08-10 21:02:13 +02:00

81 lines
3.7 KiB
Swift

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 } }
}