import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. public struct MixerView: View { let mixer: AudioMixer /// Im Panel ist es eng, im Popover nicht. let compact: Bool public init(mixer: AudioMixer, compact: Bool = false) { self.mixer = mixer self.compact = compact } public var body: some View { VStack(alignment: .leading, spacing: compact ? 10 : 14) { if mixer.isEnabled { if mixer.processes.isEmpty { Text("mixer.noProcesses", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) } else { ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in ProcessRow(mixer: mixer, process: process) } } if let error = mixer.lastError { Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical) .fixedSize(horizontal: false, vertical: true) } // Der Notausschalter gehört sichtbar dazu, nicht in ein Menü: // wenn der Ton weg ist, ist das der Knopf, den man sucht. Button { mixer.disable() } label: { Text("mixer.off", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } .buttonStyle(.plain) .padding(.top, 2) } else { OffState(mixer: mixer, compact: compact) } } .frame(maxWidth: .infinity, alignment: .leading) .animation(Onyx.Motion.value, value: mixer.processes.count) } } /// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt — in zwei /// Sätzen, nicht in einem Absatz. Wer vier Zeilen lesen muss, bevor er einen /// Regler sieht, liest sie nicht. private struct OffState: View { let mixer: AudioMixer let compact: Bool var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "slider.horizontal.3") .font(.system(size: 15, weight: .light)) .foregroundStyle(Onyx.Color.textTertiary) Text("mixer.off.explain", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .fixedSize(horizontal: false, vertical: true) } if !compact { Text("mixer.off.warning", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) .fixedSize(horizontal: false, vertical: true) } Button { mixer.enable() } label: { Text("mixer.on", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.surface) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Onyx.Color.accent, in: .capsule) } .buttonStyle(.plain) } } } private struct ProcessRow: View { let mixer: AudioMixer let process: AudioProcess @State private var isHovered = false private var managed: Bool { mixer.isManaged(process.bundleID) } private var muted: Bool { mixer.isMuted(process.bundleID) } private var volume: Double { mixer.volume(for: process.bundleID) } var body: some View { VStack(alignment: .leading, spacing: 5) { HStack(spacing: 7) { icon Text(process.name) .font(Onyx.Font.caption) .foregroundStyle(managed ? Onyx.Color.textPrimary : Onyx.Color.textSecondary) .lineLimit(1) // In der Mitte kürzen statt am Ende: bei zwei ähnlichen // Namen ist das Ende oft das Unterscheidende. .truncationMode(.middle) .layoutPriority(1) Spacer(minLength: 4) // Die Prozentzahl erscheint erst, wenn sie etwas aussagt. // Bei jedem Programm „100 %" zu zeigen ist Zahlenrauschen. if managed { Text("\(Int(volume * 100)) %") .font(Onyx.Font.metricSmall) .foregroundStyle(volume > 1 ? Onyx.Color.warning : Onyx.Color.textTertiary) .monospacedDigit() .transition(.opacity) } } HStack(spacing: 7) { VolumeBar( volume: volume, level: mixer.level(for: process.bundleID), isActive: managed && !muted, onChange: { newValue in // Am Regler zu ziehen **ist** die Entscheidung, dieses // Programm zu regeln. Ein zusätzlicher Schalter davor // wäre eine Hürde, die nichts schützt — der große // Ausschalter unten stellt ohnehin alles zurück. if !managed { mixer.setManaged(true, for: process.bundleID) } mixer.setVolume(newValue, for: process.bundleID) }) Button { if !managed { mixer.setManaged(true, for: process.bundleID) } mixer.setMuted(!muted, for: process.bundleID) } label: { Image(systemName: muted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: 9)) .frame(width: 12) } .buttonStyle(.plain) .foregroundStyle(muted ? Onyx.Color.critical : (managed ? Onyx.Color.textTertiary : Onyx.Color.textTertiary.opacity(0.5))) } } .contentShape(.rect) .onHover { isHovered = $0 } .contextMenu { if managed { Button { mixer.setManaged(false, for: process.bundleID) } label: { Text("mixer.release", bundle: .module) } } } .animation(Onyx.Motion.value, value: managed) } @ViewBuilder private var icon: some View { // Auch Hilfsprozesse bekommen das Symbol ihres Programms — sonst steht // neben „Google Chrome" ein leerer Platzhalter. if let image = ProcessNaming.icon(bundleID: process.bundleID, pid: process.pid) { Image(nsImage: image) .resizable().frame(width: 15, height: 15) .opacity(managed ? 1 : 0.65) } else { Image(systemName: "app.dashed") .font(.system(size: 11)) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 15, height: 15) } } } /// Der Lautstärkeregler. /// /// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes /// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt. /// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und /// die Grenze bei 100 %. private struct VolumeBar: View { let volume: Double let level: Double let isActive: Bool let onChange: (Double) -> Void @State private var isDragging = false private let trackHeight: CGFloat = 5 var body: some View { GeometryReader { geometry in let width = geometry.size.width let fraction = min(max(volume / Gain.maximumVolume, 0), 1) let fillWidth = width * fraction ZStack(alignment: .leading) { Capsule() .fill(Onyx.Color.elevated) .frame(height: trackHeight) // Markierung bei 100 %: darüber wird verstärkt, und man soll // sehen, wann man diese Grenze überschreitet. Rectangle() .fill(Onyx.Color.hairline) .frame(width: 1, height: trackHeight + 3) .offset(x: width / Gain.maximumVolume) Capsule() .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) .frame(width: fillWidth, height: trackHeight) .opacity(isActive ? 1 : 0.28) // Der Pegel läuft **im** Regler mit, nicht daneben: eine // zweite Leiste pro Zeile wäre doppelt so viel Grafik für // dieselbe Auskunft. if isActive, level > 0.001 { Capsule() .fill(.white.opacity(0.35)) .frame(width: fillWidth * meterFraction, height: trackHeight) .blendMode(.plusLighter) .allowsHitTesting(false) } Circle() .fill(Onyx.Color.textPrimary) .frame(width: isDragging ? 10 : 8, height: isDragging ? 10 : 8) .shadow(color: .black.opacity(0.4), radius: 1, y: 0.5) .offset(x: fillWidth - (isDragging ? 5 : 4)) .opacity(isActive ? 1 : 0.5) } .frame(height: 14) .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true let position = min(max(value.location.x / width, 0), 1) onChange(position * Gain.maximumVolume) } .onEnded { _ in isDragging = false }) } .frame(height: 14) .animation(Onyx.Motion.value, value: isDragging) } /// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: /// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren /// Bereich so weit, dass man überhaupt etwas sieht. private var meterFraction: Double { min(1, sqrt(min(max(level, 0), 1))) } } // MARK: - Widget public struct AudioMixerWidget: OnyxWidget { public let id = "audio" public var displayName: String { String(localized: "mixer.name", bundle: .module) } public let symbolName = "slider.horizontal.3" public let supportedSizes: [WidgetSize] = [.medium, .large] private let mixer: AudioMixer public init(mixer: AudioMixer) { self.mixer = mixer } public func makeView(size: WidgetSize) -> AnyView { AnyView( ScrollView { MixerView(mixer: mixer, compact: size == .medium) } .scrollIndicators(.never) ) } }