Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl. Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz auf schwarz endet. Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient, bis jede Kachel einmal zu sehen war. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
20 KiB
Swift
507 lines
20 KiB
Swift
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.
|
||
OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
|
||
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||
SystemSection(system: system, compact: compact)
|
||
|
||
OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
|
||
.padding(.top, compact ? 0 : Onyx.Space.xxs)
|
||
|
||
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, Onyx.Space.md)
|
||
} else {
|
||
// Eine Karte um die Zeilen: sie fasst zusammen, was
|
||
// zusammengehört, und trennt es vom Ausschalter darunter.
|
||
OnyxCard {
|
||
VStack(spacing: 0) {
|
||
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
|
||
id: \.element.id) { index, group in
|
||
if index > 0 { OnyxHairline() }
|
||
ProcessRow(mixer: mixer, group: group, compact: compact)
|
||
.padding(.horizontal, Onyx.Space.row)
|
||
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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, Onyx.Space.xxs)
|
||
} else {
|
||
OffState(mixer: mixer, compact: compact)
|
||
autostartToggle
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.animation(Onyx.Motion.value, value: mixer.groups.count)
|
||
// Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht
|
||
// an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und
|
||
// ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist
|
||
// schlimmer als eines, das gar nicht misst.
|
||
}
|
||
}
|
||
|
||
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 Hinweis gehört hierher, weil hier die Entscheidung fällt.
|
||
//
|
||
// Ein Tap ist für macOS eine Tonaufnahme — auch wenn nur die
|
||
// Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das
|
||
// orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht,
|
||
// sucht den Fehler bei sich oder hält die App für unsauber.
|
||
Text("mixer.recordingHint", bundle: .module)
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
/// 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: Onyx.Space.row) {
|
||
HStack(spacing: Onyx.Space.md) {
|
||
Image(systemName: "slider.horizontal.3")
|
||
.font(.system(size: Onyx.Symbol.large, 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)
|
||
}
|
||
|
||
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
|
||
}
|
||
}
|
||
}
|
||
|
||
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) }
|
||
private var overdriven: Bool { mixer.isOverdriven(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: Onyx.Space.row) {
|
||
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: Onyx.Symbol.small))
|
||
.frame(width: 15, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(muted ? Onyx.Color.critical
|
||
: (managed ? Onyx.Color.textSecondary
|
||
: Onyx.Color.textTertiary))
|
||
|
||
OnyxSlider(
|
||
value: volume,
|
||
maximum: Gain.maximum(overdrive: overdriven),
|
||
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)
|
||
|
||
overdriveButton
|
||
resetButton
|
||
}
|
||
.contentShape(.rect)
|
||
.contextMenu {
|
||
if managed {
|
||
Button { mixer.setManaged(false, for: group.bundleID) } label: {
|
||
Text("mixer.release", bundle: .module)
|
||
}
|
||
}
|
||
}
|
||
.animation(Onyx.Motion.value, value: managed)
|
||
}
|
||
|
||
/// Der Overdrive-Schalter.
|
||
///
|
||
/// Er steht bei jedem Programm, auch bei einem ungeregelten — sonst müsste
|
||
/// man erst den Regler anfassen, um an den Schalter zu kommen, und hätte
|
||
/// damit schon geregelt. Eingeschaltet reicht der Regler bis 400 %.
|
||
@ViewBuilder
|
||
private var overdriveButton: some View {
|
||
Button {
|
||
if !managed { mixer.setManaged(true, for: group.bundleID) }
|
||
mixer.setOverdrive(!overdriven, for: group.bundleID)
|
||
} label: {
|
||
Image(systemName: "bolt.fill")
|
||
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
|
||
.help(Text("mixer.overdrive", bundle: .module))
|
||
.frame(width: 14, height: 14)
|
||
}
|
||
|
||
/// 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 || overdriven {
|
||
Button {
|
||
// Der Overdrive geht mit zurück: er ist keine eigene
|
||
// Einstellung, sondern die Freigabe für einen Wert, den es
|
||
// danach nicht mehr gibt. Bliebe er stehen, zeigte die Zeile
|
||
// 100 % und der Regler stünde trotzdem im Boost-Bereich.
|
||
mixer.setOverdrive(false, for: group.bundleID)
|
||
mixer.setVolume(1, for: group.bundleID)
|
||
} label: {
|
||
Image(systemName: "arrow.counterclockwise")
|
||
.font(.system(size: Onyx.Symbol.caption, 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.
|
||
Group {
|
||
if let image = ProcessNaming.icon(bundleID: group.bundleID,
|
||
pid: group.processes.first?.pid ?? 0) {
|
||
Image(nsImage: image)
|
||
.resizable()
|
||
.opacity(managed ? 1 : 0.65)
|
||
} else {
|
||
Image(systemName: "app.dashed")
|
||
.font(.system(size: Onyx.Symbol.large))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
}
|
||
.frame(width: 26, height: 26)
|
||
// Der Name hängt am Symbol, nicht an der Zeile: die Zeile besteht zum
|
||
// größten Teil aus Regler und Knöpfen, und die verschlucken den
|
||
// Hinweis. Am Symbol kommt er an — und dort sucht man ihn auch, wenn
|
||
// man das Programm nicht erkennt.
|
||
.help(tooltip)
|
||
}
|
||
|
||
/// Name des Programms, bei mehreren Tonquellen mit ihrer Anzahl.
|
||
///
|
||
/// Chrome erscheint als eine Zeile, gibt seinen Ton aber über mehrere
|
||
/// Hilfsprozesse aus. Die Zahl erklärt, warum ein Regler mehr als einen
|
||
/// Prozess bewegt.
|
||
private var tooltip: String {
|
||
guard group.processes.count > 1 else { return group.name }
|
||
return "\(group.name) (\(group.processes.count))"
|
||
}
|
||
}
|
||
// 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
|
||
|
||
/// 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 {
|
||
OnyxCard {
|
||
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,
|
||
level: system.levels.output,
|
||
muted: system.outputMuted,
|
||
adjustable: system.outputIsAdjustable,
|
||
compact: compact,
|
||
onVolume: { system.setOutputVolume($0) },
|
||
onMute: { system.setOutputMuted(!system.outputMuted) },
|
||
onSelect: { system.selectOutput($0) })
|
||
|
||
OnyxHairline()
|
||
|
||
SystemRow(
|
||
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
|
||
title: inputName,
|
||
devices: system.inputDevices,
|
||
selected: system.inputDeviceID,
|
||
volume: system.inputVolume,
|
||
level: system.levels.input,
|
||
// Der Mikrofonpegel nur auf Knopfdruck — siehe SystemLevels.
|
||
metering: MeteringButton(isOn: system.levels.isListeningToInput,
|
||
toggle: { system.levels.toggleInputMetering() }),
|
||
muted: system.inputMuted,
|
||
adjustable: system.inputIsAdjustable,
|
||
compact: compact,
|
||
onVolume: { system.setInputVolume($0) },
|
||
onMute: { system.setInputMuted(!system.inputMuted) },
|
||
onSelect: { system.selectInput($0) })
|
||
}
|
||
}
|
||
}
|
||
|
||
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 level: Double
|
||
/// Nur die Eingabezeile hat einen — die Ausgabe misst ohne Nebenwirkung.
|
||
var metering: MeteringButton?
|
||
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: Onyx.Space.xs) {
|
||
Text(title)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: Onyx.Symbol.caption))
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
}
|
||
.menuStyle(.borderlessButton)
|
||
.menuIndicator(.hidden)
|
||
.fixedSize()
|
||
|
||
HStack(spacing: Onyx.Space.row) {
|
||
Button(action: onMute) {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: Onyx.Symbol.small))
|
||
.frame(width: 15, alignment: .leading)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
|
||
|
||
if adjustable {
|
||
OnyxSlider(value: volume, level: level,
|
||
isActive: !muted, onChange: onVolume)
|
||
if let metering { metering }
|
||
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, Onyx.Space.row)
|
||
.padding(.vertical, compact ? 7 : 9)
|
||
}
|
||
}
|
||
|
||
/// Der Knopf, der den Mikrofonpegel anschaltet.
|
||
///
|
||
/// **Ein Knopf und kein Automatismus**, weil ein offener Eingangsstrom bei
|
||
/// Bluetooth-Hörern nicht folgenlos ist: macOS schaltet sie ins Telefonprofil,
|
||
/// die Abtastrate halbiert sich und die Wiedergabe wird hörbar lauter. Wer nur
|
||
/// die Lautstärke regeln will, hat das nicht bestellt.
|
||
struct MeteringButton: View {
|
||
let isOn: Bool
|
||
let toggle: () -> Void
|
||
|
||
var body: some View {
|
||
Button(action: toggle) {
|
||
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
|
||
.font(.system(size: Onyx.Symbol.medium))
|
||
.frame(width: 14, height: 14)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(isOn ? Onyx.Color.accent : Onyx.Color.textTertiary)
|
||
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
|
||
}
|
||
}
|