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.
199 lines
6.7 KiB
Swift
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)
|
|
}
|
|
}
|