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:
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user