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

199 lines
6.7 KiB
Swift

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