Files
onyx/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
T
ScarriffleandClaude Opus 5 edb327cabe Kacheln und Fenster sprechen dieselbe Sprache
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>
2026-09-05 21:05:51 +02:00

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