import SwiftUI import AppKit import OnyxDesign import OnyxWidgetKit /// 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 /// 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 { // Eine Karte um die Zeilen: sie fasst zusammen, was // zusammengehört, und trennt es vom Ausschalter darunter. VStack(spacing: 0) { ForEach(Array(mixer.processes.prefix(compact ? 4 : 12).enumerated()), id: \.element.id) { index, process in if index > 0 { Divider().overlay(Onyx.Color.hairline) } ProcessRow(mixer: mixer, process: process, compact: compact) .padding(.horizontal, 10) .padding(.vertical, compact ? 7 : 9) } } .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) } // 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 let compact: Bool 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: compact ? 5 : 6) { HStack(spacing: 8) { 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) Spacer(minLength: 4) if process.isPlaying { Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5) } } HStack(spacing: 9) { // Der Stummschalter links vom Regler, wie in SoundSource: er // gehört zur Lautstärke und nicht zu den Zusatzfunktionen. Button { if !managed { mixer.setManaged(true, for: process.bundleID) } mixer.setMuted(!muted, for: process.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: 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) }) 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) } } .contentShape(.rect) .contextMenu { if managed { Button { mixer.setManaged(false, for: process.bundleID) } label: { Text("mixer.release", bundle: .module) } } } .animation(Onyx.Motion.value, value: managed) } /// 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" } } @ViewBuilder private var icon: some View { // Als Kachel wie in SoundSource: ein freigestelltes Programmsymbol // wirkt neben Text verloren, mit Hintergrund steht es für sich. Group { // 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().padding(2) } else { Image(systemName: "app.dashed") .font(.system(size: 10)) .foregroundStyle(Onyx.Color.textTertiary) } } .frame(width: 20, height: 20) .background(Onyx.Color.surface.opacity(managed ? 0.9 : 0.5), in: .rect(cornerRadius: 5, style: .continuous)) .opacity(managed ? 1 : 0.7) } } /// 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 public init(mixer: AudioMixer) { self.mixer = mixer } public func makeView() -> AnyView { AnyView( ScrollView { MixerView(mixer: mixer, compact: true) } .scrollIndicators(.never) ) } }