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

@@ -15,6 +15,7 @@ let package = Package(
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
.library(name: "MediaProvider", targets: ["MediaProvider"]),
],
targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt
@@ -41,5 +42,9 @@ let package = Package(
.target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]),
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
]
)

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

View File

@@ -0,0 +1,166 @@
import Foundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom.
///
/// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen:
/// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und
/// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein
/// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte
/// Framework und gibt die Daten zeilenweise aus.
@MainActor
public final class AdapterProcess {
/// Befehlskennungen aus `MediaRemote.h` des Adapters.
private enum Command: Int {
case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5
}
public private(set) var isRunning = false
/// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts.
public var onUpdate: ((NowPlaying?) -> Void)?
private var process: Process?
private var buffer = Data()
private var current: NowPlaying?
private var restartAttempts = 0
private var restartTimer: Timer?
private let perl = URL(fileURLWithPath: "/usr/bin/perl")
public init() {}
/// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die
/// nächste Stufe der Kaskade ohne dass irgendwo ein Prozess scheitert.
public static var isAvailable: Bool { resources != nil }
private static var resources: (script: URL, framework: URL)? {
guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil }
let script = vendor.appendingPathComponent("mediaremote-adapter.pl")
let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework")
guard FileManager.default.fileExists(atPath: script.path),
FileManager.default.fileExists(atPath: framework.path) else { return nil }
return (script, framework)
}
// MARK: - Lebenszyklus
public func start() {
guard !isRunning, let resources = Self.resources else { return }
let process = Process()
process.executableURL = perl
process.arguments = [resources.script.path, resources.framework.path, "stream"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
Task { @MainActor in self?.consume(chunk) }
}
process.terminationHandler = { [weak self] _ in
Task { @MainActor in self?.handleTermination() }
}
do {
try process.run()
self.process = process
isRunning = true
log.notice("MediaRemote-Adapter gestartet")
} catch {
log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)")
scheduleRestart()
}
}
public func stop() {
restartTimer?.invalidate()
restartTimer = nil
(process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
process?.terminationHandler = nil
process?.terminate()
process = nil
isRunning = false
}
private func handleTermination() {
isRunning = false
process = nil
// Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein
// Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem
// echten Defekt in einer Schleife zu landen.
scheduleRestart()
}
private func scheduleRestart() {
guard restartAttempts < 5 else {
log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück")
onUpdate?(nil)
return
}
let delay = pow(2.0, Double(restartAttempts))
restartAttempts += 1
restartTimer?.invalidate()
restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
MainActor.assumeIsolated { [weak self] in self?.start() }
}
}
// MARK: - Datenstrom
private func consume(_ chunk: Data) {
buffer.append(chunk)
// Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe
// enthalten. Cover kommen base64-kodiert und sprengen einen Puffer
// schnell, deshalb wird der Rest aufgehoben statt verworfen.
while let newline = buffer.firstIndex(of: 0x0A) {
let lineData = buffer[buffer.startIndex..<newline]
buffer.removeSubrange(buffer.startIndex...newline)
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
restartAttempts = 0
if let updated = AdapterPayload.apply(line: line, to: current) {
current = updated
onUpdate?(updated)
} else if line.contains("\"payload\":{}") {
current = nil
onUpdate?(nil)
}
}
}
// MARK: - Steuerung
public func send(_ command: MediaCommand) {
switch command {
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
case .next: run(["send", String(Command.next.rawValue)])
case .previous: run(["send", String(Command.previous.rawValue)])
case .seek(let position):
// Der Adapter erwartet Mikrosekunden.
run(["seek", String(Int(position * 1_000_000))])
}
}
/// Befehle laufen als eigener, kurzlebiger Prozess.
///
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
private func run(_ arguments: [String]) {
guard let resources = Self.resources else { return }
let process = Process()
process.executableURL = perl
process.arguments = [resources.script.path, resources.framework.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
}
}

View File

@@ -0,0 +1,25 @@
{
"sourceLanguage" : "en",
"strings" : {
"widget.media.name" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Medien" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Media" } }
}
},
"widget.media.nothing" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts läuft gerade" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing playing" } }
}
},
"widget.media.viaAppleScript" : {
"comment" : "Hinweis, dass die schwächere Quelle greift",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Über AppleScript — ohne Cover, nur Spotify und Musik" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Via AppleScript — no artwork, Spotify and Music only" } }
}
}
},
"version" : "1.0"
}

View File

@@ -0,0 +1,138 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Zweite Stufe: AppleScript für Spotify und Musik.
///
/// Deckt weit weniger ab als der Adapter Browser und alle anderen Programme
/// fallen heraus. Dafür ist es offiziell unterstützt und überlebt
/// macOS-Aktualisierungen, die den Adapter brechen könnten.
public enum AppleScriptSource {
private struct Player {
let bundleID: String
let name: String
}
private static let players = [
Player(bundleID: "com.spotify.client", name: "Spotify"),
Player(bundleID: "com.apple.Music", name: "Music"),
]
/// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen
/// ein AppleScript an ein beendetes Programm würde es sonst **starten**.
public static func poll() -> NowPlaying? {
for player in players {
guard NSRunningApplication
.runningApplications(withBundleIdentifier: player.bundleID)
.isEmpty == false else { continue }
if let state = query(player) { return state }
}
return nil
}
private static func query(_ player: Player) -> NowPlaying? {
let script = """
tell application id "\(player.bundleID)"
if player state is playing or player state is paused then
set t to name of current track
set a to artist of current track
set al to album of current track
set dur to duration of current track
set pos to player position
set st to (player state as text)
return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st
end if
end tell
"""
var error: NSDictionary?
guard let result = NSAppleScript(source: script)?
.executeAndReturnError(&error).stringValue else {
if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") }
return nil
}
let parts = result.components(separatedBy: "\n")
guard parts.count >= 6 else { return nil }
// Spotify gibt die Länge in Millisekunden, Musik in Sekunden.
let rawDuration = Double(parts[3]) ?? 0
let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration
return NowPlaying(title: parts[0], artist: parts[1], album: parts[2],
isPlaying: parts[5].hasPrefix("playing"),
duration: duration > 0 ? duration : nil,
elapsed: Double(parts[4]) ?? 0,
playbackRate: 1, asOf: Date(), artwork: nil,
bundleIdentifier: player.bundleID, origin: .appleScript)
}
public static func send(_ command: MediaCommand) -> Bool {
for player in players {
guard !NSRunningApplication
.runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue }
let action = switch command {
case .playPause: "playpause"
case .next: "next track"
case .previous: "previous track"
case .seek(let position): "set player position to \(position)"
}
var error: NSDictionary?
NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")?
.executeAndReturnError(&error)
if error == nil { return true }
}
return false
}
}
/// Letzte Stufe: die Medientasten der Tastatur.
///
/// Sie kennt keine Titel und keine Position aber sie steuert **jedes**
/// Programm, auch solche, die weder der Adapter noch AppleScript erreichen.
/// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die
/// Now-Playing-Schnittstelle weiter zumacht.
public enum MediaKeys {
private enum Key: Int32 {
case playPause = 16 // NX_KEYTYPE_PLAY
case next = 17 // NX_KEYTYPE_NEXT
case previous = 18 // NX_KEYTYPE_PREVIOUS
}
public static func send(_ command: MediaCommand) {
let key: Key? = switch command {
case .playPause: .playPause
case .next: .next
case .previous: .previous
case .seek: nil // Über Tasten nicht möglich.
}
guard let key else { return }
post(key, down: true)
post(key, down: false)
}
private static func post(_ key: Key, down: Bool) {
// Medientasten sind keine gewöhnlichen Tastendrücke, sondern
// NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme
// nirgends an.
let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00))
let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8))
guard let event = NSEvent.otherEvent(with: .systemDefined,
location: .zero,
modifierFlags: flags,
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: data1,
data2: -1)?.cgEvent else { return }
event.post(tap: .cghidEventTap)
}
}

View File

@@ -0,0 +1,129 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Führt die drei Stufen zusammen.
///
/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für
/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern
/// immer, auch ohne jede Angabe.
///
/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die
/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der
/// Adapter aus, verliert das Widget seine Anzeige aber nicht seine Funktion.
@MainActor
@Observable
public final class MediaModel {
public private(set) var nowPlaying: NowPlaying?
/// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur
/// solange etwas abspielt und das Panel offen ist.
public private(set) var tick = Date()
private let adapter = AdapterProcess()
private var fallbackTimer: Timer?
private var tickTimer: Timer?
private var consumers = 0
public init() {
adapter.onUpdate = { [weak self] state in
guard let self else { return }
// Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt.
if let state {
nowPlaying = state
} else {
nowPlaying = AppleScriptSource.poll()
}
updateTicking()
}
}
/// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein
/// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der
/// Adapter soll genau einmal laufen.
public func addConsumer() {
consumers += 1
guard consumers == 1 else { return }
if AdapterProcess.isAvailable {
adapter.start()
} else {
log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten")
}
// AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für
// eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist.
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() }
}
pollFallbackIfNeeded()
}
public func removeConsumer() {
consumers = max(consumers - 1, 0)
guard consumers == 0 else { return }
adapter.stop()
fallbackTimer?.invalidate()
fallbackTimer = nil
tickTimer?.invalidate()
tickTimer = nil
}
private func pollFallbackIfNeeded() {
// Nur einspringen, wenn der Adapter nichts liefert sonst würde die
// schwächere Quelle die bessere überschreiben.
guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return }
nowPlaying = AppleScriptSource.poll()
updateTicking()
}
/// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei
/// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen.
private func updateTicking() {
let shouldTick = nowPlaying?.isPlaying == true
if shouldTick, tickTimer == nil {
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.tick = Date() }
}
} else if !shouldTick {
tickTimer?.invalidate()
tickTimer = nil
}
}
// MARK: - Steuerung
public func send(_ command: MediaCommand) {
switch nowPlaying?.origin {
case .adapter:
adapter.send(command)
case .appleScript:
if !AppleScriptSource.send(command) { MediaKeys.send(command) }
case .mediaKeys, .none:
// Ohne bekannte Quelle bleibt die Taste sie erreicht auch
// Programme, die keine der beiden anderen Stufen kennt.
MediaKeys.send(command)
}
// Sofort umschalten statt auf das nächste Ereignis zu warten. Ein
// Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt
// an; kommt das echte Ereignis, korrigiert es den Stand ohnehin.
if case .playPause = command, var state = nowPlaying {
state.elapsed = state.elapsed(at: Date())
state.asOf = Date()
state.isPlaying.toggle()
nowPlaying = state
updateTicking()
}
}
/// Öffnet das Programm, das gerade abspielt.
public func activateSource() {
guard let bundleID = nowPlaying?.bundleIdentifier,
let app = NSRunningApplication
.runningApplications(withBundleIdentifier: bundleID).first else { return }
app.activate()
}
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
public struct MediaWidget: OnyxWidget {
public let id = "media"
public var displayName: String { String(localized: "widget.media.name", bundle: .module) }
public let symbolName = "play.circle"
public let supportedSizes: [WidgetSize] = [.medium, .wide]
private let model: MediaModel
public init(model: MediaModel) { self.model = model }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(MediaWidgetView(model: model, size: size))
}
}
private struct MediaWidgetView: View {
let model: MediaModel
let size: WidgetSize
var body: some View {
Group {
if let state = model.nowPlaying, state.hasMetadata {
Playing(model: model, state: state, size: size)
} else {
// Auch ohne Angaben bleibt das Widget bedienbar: die
// Medientasten erreichen jedes Programm. Ein Widget, das bei
// fehlenden Metadaten gar nichts mehr kann, wäre die schlechtere
// Antwort auf ein Problem, das nur die Anzeige betrifft.
Idle(model: model)
}
}
.onAppear { model.addConsumer() }
.onDisappear { model.removeConsumer() }
}
}
private struct Idle: View {
let model: MediaModel
var body: some View {
VStack(spacing: 8) {
Text("widget.media.nothing", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
TransportControls(model: model, size: 13)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct Playing: View {
let model: MediaModel
let state: NowPlaying
let size: WidgetSize
var body: some View {
HStack(spacing: 10) {
Artwork(data: state.artwork)
.frame(width: artworkSize, height: artworkSize)
VStack(alignment: .leading, spacing: 2) {
Text(state.title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(state.artist)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 2)
Scrubber(model: model, state: state)
HStack {
TransportControls(model: model, size: 15)
Spacer(minLength: 0)
if state.origin == .appleScript {
// Sichtbar machen, dass gerade die schwächere Quelle
// greift: dann fehlen Cover und Browser-Wiedergaben,
// und das soll erklärbar sein statt rätselhaft.
Image(systemName: "applescript")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
.help(Text("widget.media.viaAppleScript", bundle: .module))
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.activateSource() }
}
private var artworkSize: CGFloat { size == .wide ? 64 : 56 }
}
private struct Artwork: View {
let data: Data?
var body: some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
Group {
if let data, let image = NSImage(data: data) {
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
} else {
// Kein Cover ist der Normalfall bei Browser-Wiedergaben und
// beim AppleScript-Rückfall deshalb ein ruhiger Platzhalter
// statt eines Fehlerzeichens.
shape.fill(Onyx.Color.elevated)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 18))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
.clipShape(shape)
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
}
}
private struct Scrubber: View {
let model: MediaModel
let state: NowPlaying
var body: some View {
// `model.tick` wird sekündlich gesetzt; das Lesen hier sorgt dafür,
// dass die Ansicht zwischen den Adapter-Ereignissen mitläuft.
let now = model.tick
let progress = state.progress(at: now)
VStack(spacing: 2) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.hairline)
Capsule()
.fill(Onyx.Color.accent)
.frame(width: geometry.size.width * (progress ?? 0))
}
.contentShape(Rectangle())
.onTapGesture { location in
guard let duration = state.duration, duration > 0 else { return }
let ratio = min(max(location.x / geometry.size.width, 0), 1)
model.send(.seek(to: duration * ratio))
}
}
.frame(height: 3)
if state.duration != nil {
HStack {
Text(format(state.elapsed(at: now)))
Spacer(minLength: 0)
Text(format(state.duration ?? 0))
}
.font(.system(size: 8))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.animation(.linear(duration: 0.9), value: progress)
}
private func format(_ seconds: TimeInterval) -> String {
let total = Int(seconds.rounded())
return String(format: "%d:%02d", total / 60, total % 60)
}
}
private struct TransportControls: View {
let model: MediaModel
let size: CGFloat
var body: some View {
HStack(spacing: 12) {
button("backward.fill") { model.send(.previous) }
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
model.send(.playPause)
}
button("forward.fill") { model.send(.next) }
}
}
private func button(_ symbol: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: size))
.foregroundStyle(Onyx.Color.textPrimary)
}
.buttonStyle(.plain)
}
}

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

View File

@@ -0,0 +1,181 @@
import Testing
import Foundation
@testable import MediaProvider
@Suite("Wiedergabe-Fortschritt")
struct NowPlayingProgressTests {
private let t0 = Date(timeIntervalSince1970: 1_800_000_000)
private func playing(elapsed: TimeInterval = 30, duration: TimeInterval? = 200,
rate: Double = 1) -> NowPlaying {
NowPlaying(title: "Titel", isPlaying: true, duration: duration,
elapsed: elapsed, playbackRate: rate, asOf: t0)
}
@Test("Während der Wiedergabe zählt die Position zwischen den Ereignissen weiter")
func elapsedAdvancesWhilePlaying() {
// Der Adapter liefert `elapsedTime` nicht laufend nach. Ohne dieses
// Weiterzählen steht der Scrubber still, obwohl der Titel läuft.
let state = playing(elapsed: 30)
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 40)
}
@Test("Im pausierten Zustand bleibt die Position stehen")
func elapsedFreezesWhenPaused() {
var state = playing(elapsed: 30)
state.isPlaying = false
#expect(state.elapsed(at: t0.addingTimeInterval(60)) == 30)
}
@Test("Doppelte Geschwindigkeit zählt doppelt so schnell")
func playbackRateIsHonoured() {
let state = playing(elapsed: 0, rate: 2)
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 20)
}
@Test("Die Position läuft nie über die Länge hinaus")
func elapsedNeverExceedsDuration() {
// Sonst zeigt die Anzeige nach dem Ende eines Titels weiter steigende
// Zahlen, solange kein neues Ereignis kommt.
let state = playing(elapsed: 190, duration: 200)
#expect(state.elapsed(at: t0.addingTimeInterval(3600)) == 200)
}
@Test("Ohne bekannte Länge zählt sie einfach weiter — Livestreams haben kein Ende")
func liveStreamKeepsCounting() {
let state = playing(elapsed: 10, duration: nil)
#expect(state.elapsed(at: t0.addingTimeInterval(100)) == 110)
}
@Test("Eine negative Position wird auf null gezogen")
func elapsedNeverGoesNegative() {
var state = playing(elapsed: 5)
state.isPlaying = false
state.elapsed = -3
#expect(state.elapsed(at: t0) == 0)
}
@Test("Fortschritt liegt zwischen 0 und 1")
func progressIsNormalised() {
let state = playing(elapsed: 50, duration: 200)
#expect(state.progress(at: t0) == 0.25)
}
@Test("Ohne Länge gibt es keinen Fortschritt statt eines falschen Wertes")
func progressIsNilWithoutDuration() {
#expect(playing(duration: nil).progress(at: t0) == nil)
}
@Test("Länge null ergibt keinen Fortschritt statt einer Division durch null")
func progressIsNilForZeroDuration() {
#expect(playing(duration: 0).progress(at: t0) == nil)
}
}
@Suite("Adapter-Payload")
struct AdapterPayloadTests {
private func line(_ json: String) -> String { json }
@Test("Vollmeldung setzt alle Felder")
func fullPayloadPopulates() {
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","album":"LP",\
"playing":true,"duration":210.5,"elapsedTime":42.0,"playbackRate":1,\
"bundleIdentifier":"com.google.Chrome"}}
""", to: nil)
#expect(state?.title == "Song")
#expect(state?.artist == "Band")
#expect(state?.isPlaying == true)
#expect(state?.duration == 210.5)
#expect(state?.elapsed == 42)
#expect(state?.bundleIdentifier == "com.google.Chrome")
#expect(state?.origin == .adapter)
}
@Test("Teilmeldung ergänzt den bisherigen Stand, statt ihn zu ersetzen")
func diffPayloadMerges() {
// Der eigentliche Grund für diese Unterscheidung: eine Teilmeldung mit
// nur der Position würde sonst Titel, Interpret und Cover löschen.
let full = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","playing":true}}
""", to: nil)
let updated = AdapterPayload.apply(line: """
{"type":"data","diff":true,"payload":{"elapsedTime":99.0}}
""", to: full)
#expect(updated?.title == "Song")
#expect(updated?.artist == "Band")
#expect(updated?.elapsed == 99)
}
@Test("Leere Vollmeldung heißt: es läuft nichts")
func emptyFullPayloadMeansNothingPlaying() {
#expect(AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{}}
""", to: nil) == nil)
}
@Test("Leere Teilmeldung lässt den Stand unangetastet")
func emptyDiffKeepsState() {
let previous = NowPlaying(title: "Song")
let result = AdapterPayload.apply(line: """
{"type":"data","diff":true,"payload":{}}
""", to: previous)
#expect(result?.title == "Song")
}
@Test("Der Zeitstempel der Quelle wird übernommen, nicht der Empfangszeitpunkt")
func timestampComesFromPayload() {
// Sonst springt der Scrubber bei jedem Ereignis, weil die Position auf
// einen falschen Bezugszeitpunkt gerechnet wird.
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","elapsedTime":10.0,\
"timestamp":"2026-08-10T14:22:16Z"}}
""", to: nil)
let expected = ISO8601DateFormatter().date(from: "2026-08-10T14:22:16Z")
#expect(state?.asOf == expected)
}
@Test("Cover kommt als base64 an")
func artworkIsDecoded() {
let raw = Data([0xFF, 0xD8, 0xFF]).base64EncodedString()
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","artworkData":"\(raw)"}}
""", to: nil)
#expect(state?.artwork == Data([0xFF, 0xD8, 0xFF]))
}
@Test("Länge null wird zu 'unbekannt' statt zu einer Null-Länge")
func zeroDurationBecomesNil() {
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Stream","duration":0}}
""", to: nil)
#expect(state?.duration == nil)
}
@Test("Kaputte Zeilen werden verworfen, nicht interpretiert")
func garbageIsIgnored() {
for junk in ["", "kein json", "{}", "{\"type\":\"log\"}", "null"] {
#expect(AdapterPayload.apply(line: junk, to: nil) == nil)
}
}
@Test("`playing: true` wird nicht als Zahl gelesen")
func boolIsNotMistakenForNumber() {
// Reihenfolge in AnyDecodable: Bool vor Double. Andernfalls wird `true`
// zu 1 und `playing` bliebe für immer falsch.
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","playing":true}}
""", to: nil)
#expect(state?.isPlaying == true)
}
}