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 { 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 let artworkSize: CGFloat = 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) } }