import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit import CoreAudio /// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover. /// /// Die Anordnung folgt SoundSource, weil sie dort gut gelöst ist: eine Zeile je /// Programm, links das Symbol als Kachel, dann ein Stummschalter, dann der /// Regler, rechts der Wert. Nichts steht übereinander, nichts muss man suchen. /// /// Die Farben folgen ihr **nicht**. SoundSource färbt jeden Regler grün; in /// Onyx ist Farbe ein Signal — die Verstärkung über 100 % ist eines, eine /// gewöhnliche Lautstärke nicht. public struct MixerView: View { let mixer: AudioMixer let system: SystemAudio /// Im Panel ist es eng, im Popover nicht. let compact: Bool public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) { self.mixer = mixer self.system = system self.compact = compact } public var body: some View { VStack(alignment: .leading, spacing: compact ? 10 : 12) { // Die Systemregler stehen **vor** den Programmen und außerhalb des // Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und // keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür // nichts einschalten müssen. SectionTitle("mixer.section.system", compact: compact) SystemSection(system: system, compact: compact) SectionTitle("mixer.section.apps", compact: compact) if mixer.isEnabled { if mixer.groups.isEmpty { Text("mixer.noProcesses", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 8) } else { // Eine Karte um die Zeilen: sie fasst zusammen, was // zusammengehört, und trennt es vom Ausschalter darunter. VStack(spacing: 0) { ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()), id: \.element.id) { index, group in if index > 0 { Divider().overlay(Onyx.Color.hairline) } ProcessRow(mixer: mixer, group: group, compact: compact) .padding(.horizontal, 10) .padding(.vertical, compact ? 6 : 8) } } .background(Onyx.Color.elevated.opacity(0.55), in: .rect(cornerRadius: 10, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(Onyx.Color.hairline, lineWidth: 1) } } if let error = mixer.lastError { Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical) .fixedSize(horizontal: false, vertical: true) } autostartToggle // 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) autostartToggle } } .frame(maxWidth: .infinity, alignment: .leading) .animation(Onyx.Motion.value, value: mixer.groups.count) } } extension MixerView { /// Das Häkchen steht in **beiden** Zuständen: eingeschaltet, um es wieder /// abzuwählen, ausgeschaltet, um es gleich mit zu setzen. @ViewBuilder var autostartToggle: some View { Toggle(isOn: Binding(get: { mixer.startsAutomatically }, set: { mixer.startsAutomatically = $0 })) { Text("mixer.autostart", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } .toggleStyle(.checkbox) .controlSize(.small) } } /// 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 group: AudioProcessGroup let compact: Bool private var managed: Bool { mixer.isManaged(group.bundleID) } private var muted: Bool { mixer.isMuted(group.bundleID) } private var volume: Double { mixer.volume(for: group.bundleID) } var body: some View { // Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name // ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und // die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht, // hält den Zeiger auf das Symbol. HStack(spacing: 10) { icon Button { if !managed { mixer.setManaged(true, for: group.bundleID) } mixer.setMuted(!muted, for: group.bundleID) } label: { Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol) .font(.system(size: 11)) .frame(width: 15, alignment: .leading) } .buttonStyle(.plain) .foregroundStyle(muted ? Onyx.Color.critical : (managed ? Onyx.Color.textSecondary : Onyx.Color.textTertiary)) VolumeBar( volume: volume, level: mixer.level(for: group.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: group.bundleID) } mixer.setVolume(newValue, for: group.bundleID) }) Text(managed ? "\(Int(volume * 100)) %" : "–") .font(Onyx.Font.metricSmall) .monospacedDigit() .foregroundStyle(volume > 1 && managed ? Onyx.Color.warning : Onyx.Color.textTertiary) // Feste Breite, sonst wandert der Regler beim Ziehen. .frame(width: 36, alignment: .trailing) resetButton } .contentShape(.rect) .help(group.name) .contextMenu { if managed { Button { mixer.setManaged(false, for: group.bundleID) } label: { Text("mixer.release", bundle: .module) } } } .animation(Onyx.Motion.value, value: managed) } /// Zurück auf 100 %. /// /// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu /// sehen ist. Ein Knopf, der auftaucht und dabei den Regler schmaler macht, /// verschiebt genau das, was man gerade bedient — und beim Loslassen springt /// es zurück. @ViewBuilder private var resetButton: some View { Group { if managed, abs(volume - 1) > 0.001 { Button { mixer.setVolume(1, for: group.bundleID) } label: { Image(systemName: "arrow.counterclockwise") .font(.system(size: 9, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) .help(Text("mixer.reset", bundle: .module)) } else { Color.clear } } .frame(width: Self.resetSlotWidth, height: 14) } /// Die reservierte Breite des Rücksetzknopfes. private static let resetSlotWidth: CGFloat = 14 /// Wie viele Wellen am Lautsprecher — folgt der Lautstärke, wie überall /// sonst in macOS auch. private var speakerSymbol: String { switch volume { case ..<0.01: "speaker.fill" case ..<0.5: "speaker.wave.1.fill" case ..<1.2: "speaker.wave.2.fill" default: "speaker.wave.3.fill" } } /// Ohne Namen daneben trägt das Symbol die Erkennung allein — also größer /// und ohne Kachel, damit es tatsächlich zu erkennen ist. @ViewBuilder private var icon: some View { // Auch Hilfsprozesse bekommen das Symbol ihres Programms. if let image = ProcessNaming.icon(bundleID: group.bundleID, pid: group.processes.first?.pid ?? 0) { Image(nsImage: image) .resizable() .frame(width: 26, height: 26) .opacity(managed ? 1 : 0.65) } else { Image(systemName: "app.dashed") .font(.system(size: 15)) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 26, height: 26) } } } /// 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 = 6 var body: some View { GeometryReader { geometry in let width = geometry.size.width let fraction = min(max(volume / Gain.maximumVolume, 0), 1) let knob: CGFloat = isDragging ? 13 : 11 // Der Knopf soll an den Enden nicht überstehen. let travel = max(width - knob, 1) let fillWidth = travel * fraction + knob / 2 ZStack(alignment: .leading) { Capsule() .fill(Onyx.Color.surface) .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 + 4) .offset(x: knob / 2 + travel / Gain.maximumVolume) Capsule() .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) .frame(width: fillWidth, height: trackHeight) .opacity(isActive ? 1 : 0.3) // 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.4)) .frame(width: fillWidth * meterFraction, height: trackHeight) .blendMode(.plusLighter) .allowsHitTesting(false) } Circle() .fill(.white) .frame(width: knob, height: knob) .shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5) .offset(x: travel * fraction) .opacity(isActive ? 1 : 0.55) } .frame(height: 16) .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true let position = min(max((value.location.x - knob / 2) / travel, 0), 1) onChange(position * Gain.maximumVolume) } .onEnded { _ in isDragging = false }) } .frame(height: 16) .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" private let mixer: AudioMixer private let system: SystemAudio public init(mixer: AudioMixer, system: SystemAudio) { self.mixer = mixer self.system = system } public func makeView() -> AnyView { AnyView( ScrollView { MixerView(mixer: mixer, system: system, compact: true) } .scrollIndicators(.never) ) } } // MARK: - Systemzeilen private struct SectionTitle: View { let key: LocalizedStringKey let compact: Bool init(_ key: LocalizedStringKey, compact: Bool) { self.key = key self.compact = compact } var body: some View { Text(key, bundle: .module) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) .textCase(.uppercase) .padding(.top, compact ? 0 : 2) } } /// Ausgabe, Eingabe und Gerätewahl. /// /// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter, /// Regler, Wert. Zwei verschiedene Regler in einem Fenster wären eine /// Behauptung, dass es zwei verschiedene Dinge sind — es ist aber beides /// Lautstärke. private struct SystemSection: View { let system: SystemAudio let compact: Bool var body: some View { VStack(spacing: 0) { SystemRow( symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", title: outputName, devices: system.outputDevices, selected: system.outputDeviceID, volume: system.outputVolume, muted: system.outputMuted, adjustable: system.outputIsAdjustable, compact: compact, onVolume: { system.setOutputVolume($0) }, onMute: { system.setOutputMuted(!system.outputMuted) }, onSelect: { system.selectOutput($0) }) Divider().overlay(Onyx.Color.hairline) SystemRow( symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill", title: inputName, devices: system.inputDevices, selected: system.inputDeviceID, volume: system.inputVolume, muted: system.inputMuted, adjustable: system.inputIsAdjustable, compact: compact, onVolume: { system.setInputVolume($0) }, onMute: { system.setInputMuted(!system.inputMuted) }, onSelect: { system.selectInput($0) }) } .background(Onyx.Color.elevated.opacity(0.55), in: .rect(cornerRadius: 10, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder(Onyx.Color.hairline, lineWidth: 1) } } private var outputName: String { system.outputDevices.first { $0.id == system.outputDeviceID }?.name ?? String(localized: "mixer.output", bundle: .module) } private var inputName: String { system.inputDevices.first { $0.id == system.inputDeviceID }?.name ?? String(localized: "mixer.input", bundle: .module) } } private struct SystemRow: View { let symbol: String let title: String let devices: [AudioDevice] let selected: AudioObjectID let volume: Double let muted: Bool let adjustable: Bool let compact: Bool let onVolume: (Double) -> Void let onMute: () -> Void let onSelect: (AudioDevice) -> Void var body: some View { VStack(alignment: .leading, spacing: compact ? 5 : 6) { // Der Gerätename **ist** die Auswahl: ein eigenes Auswahlfeld // daneben wäre eine zweite Stelle für dieselbe Sache. Menu { ForEach(devices) { device in Button { onSelect(device) } label: { if device.id == selected { Label(device.name, systemImage: "checkmark") } else { Text(device.name) } } } } label: { HStack(spacing: 4) { Text(title) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textPrimary) .lineLimit(1) .truncationMode(.middle) Image(systemName: "chevron.up.chevron.down") .font(.system(size: 7)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() HStack(spacing: 9) { Button(action: onMute) { Image(systemName: symbol) .font(.system(size: 11)) .frame(width: 15, alignment: .leading) } .buttonStyle(.plain) .foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary) if adjustable { SystemVolumeBar(volume: volume, isActive: !muted, onChange: onVolume) Text("\(Int(volume * 100)) %") .font(Onyx.Font.metricSmall).monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 36, alignment: .trailing) } else { // Manche Geräte bringen keinen eigenen Regler mit — HDMI, // viele USB-Interfaces. Ein Regler, der nichts tut, wäre // eine Pseudobedienung. Text("mixer.notAdjustable", bundle: .module) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } } } .padding(.horizontal, 10) .padding(.vertical, compact ? 7 : 9) } } /// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die /// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her. private struct SystemVolumeBar: View { let volume: Double let isActive: Bool let onChange: (Double) -> Void @State private var isDragging = false var body: some View { GeometryReader { geometry in let knob: CGFloat = isDragging ? 13 : 11 let travel = max(geometry.size.width - knob, 1) let fraction = min(max(volume, 0), 1) ZStack(alignment: .leading) { Capsule().fill(Onyx.Color.surface).frame(height: 6) Capsule() .fill(Onyx.Color.accent) .frame(width: travel * fraction + knob / 2, height: 6) .opacity(isActive ? 1 : 0.3) Circle() .fill(.white) .frame(width: knob, height: knob) .shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5) .offset(x: travel * fraction) .opacity(isActive ? 1 : 0.55) } .frame(height: 16) .contentShape(.rect) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true onChange(min(max((value.location.x - knob / 2) / travel, 0), 1)) } .onEnded { _ in isDragging = false }) } .frame(height: 16) .animation(Onyx.Motion.value, value: isDragging) } }