Die Mediensteuerung stand nebeneinander: Cover links, alles andere in den verbleibenden gut hundert Punkten. Daher „Bernhard H…" statt eines Titels. Jetzt übereinander, wie es zu einer hochkanten Kachel passt. Das Cover ragte zusätzlich über die Kachel hinaus, weil `clipShape` vor dem Rahmen stand — beschnitten wurde damit auf die Eigengröße des Bildes, und die ist bei einem Podcast-Cover ein Vielfaches. Erst Rahmen, dann beschneiden. Menüleistensymbole waren kaum zu sehen. `color.set()` vor `image.draw(in:)` färbt ein Template-Bild nicht: es wird schwarz gezeichnet, auf einer dunklen Menüleiste also unsichtbar. AppKit färbt Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind — in einer selbst gezeichneten Ansicht muss die Farbe in die Symbolkonfiguration. Betraf alle fünf Zeichenstellen, nicht nur die Lüfter. „fans.auto" stand als roher Schlüssel da: Xcode hatte ihn extrahiert, gefüllt wurde er nie. Beim Nachsehen waren es 35 solche Einträge über alle Kataloge hinweg — die meisten reine Formatschlüssel, die als Durchreicher trotzdem einen Wert brauchen. Der Automatik-Knopf ist jetzt eine gefüllte Pille, wenn sie greift. Blauer Text sieht aus wie ein Link und nicht wie ein Zustand. Und ein Absturz, der noch niemandem passiert ist: meldet der Helfer für einen stehenden Lüfter keine Grenzen, hätte `Slider(in: 0...0)` die App beendet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
7.1 KiB
Swift
206 lines
7.1 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: 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
|
|
|
|
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: 7) {
|
|
Artwork(data: state.artwork)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 88)
|
|
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
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: 9))
|
|
.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: 8, 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: 18))
|
|
.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: 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)
|
|
}
|
|
}
|