Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl. Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz auf schwarz endet. Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient, bis jede Kachel einmal zu sehen war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.2 KiB
Swift
206 lines
7.2 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"
|
|
|
|
private let model: MediaModel
|
|
|
|
public init(model: MediaModel) { self.model = model }
|
|
|
|
public func makeView() -> AnyView {
|
|
AnyView(MediaWidgetView(model: model))
|
|
}
|
|
}
|
|
|
|
private struct MediaWidgetView: View {
|
|
let model: MediaModel
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let state = model.nowPlaying, state.hasMetadata {
|
|
Playing(model: model, state: state)
|
|
} 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: Onyx.Space.md) {
|
|
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
|
|
|
|
var body: some View {
|
|
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
|
|
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
|
|
// steht dort „Bernhard H…" statt eines Titels.
|
|
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
|
Artwork(data: state.artwork)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 88)
|
|
|
|
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
|
Text(state.title)
|
|
.font(Onyx.Font.caption.weight(.medium))
|
|
.foregroundStyle(Onyx.Color.textPrimary)
|
|
.lineLimit(2)
|
|
Text(state.artist)
|
|
.font(Onyx.Font.caption)
|
|
.foregroundStyle(Onyx.Color.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Scrubber(model: model, state: state)
|
|
|
|
HStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
TransportControls(model: model, size: 15)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.overlay(alignment: .trailing) {
|
|
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: Onyx.Symbol.caption))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
.help(Text("widget.media.viaAppleScript", bundle: .module))
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(count: 2) { model.activateSource() }
|
|
}
|
|
}
|
|
|
|
private struct Artwork: View {
|
|
let data: Data?
|
|
|
|
var body: some View {
|
|
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous)
|
|
|
|
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
|
|
// beschneidet man das Bild auf seine eigene, viel größere Größe — und es
|
|
// ragt anschließend über die Kachel hinaus, mitten in den Nachbarn.
|
|
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: Onyx.Symbol.xlarge))
|
|
.foregroundStyle(Onyx.Color.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.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: Onyx.Space.xxs) {
|
|
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(Onyx.Font.micro)
|
|
.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: Onyx.Space.lg) {
|
|
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)
|
|
}
|
|
}
|