Files
onyx/Packages/OnyxKit/Sources/MediaProvider/MediaModel.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

130 lines
4.7 KiB
Swift

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