Files
onyx/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift
Scarriffle 28b1b4270e Audiomixer zeigt Programme statt Prozesse
Core Audio meldet Prozesse, nicht Programme. Angezeigt wurden deshalb
Kennungen wie „com.apple.WebKit.GPU" und Dutzende Systemdienste, die
theoretisch Ton ausgeben könnten, es aber nie tun.

ProcessNaming fragt erst das laufende Programm, kürzt sonst bekannte
Hilfsprozess-Endungen weg und schlägt das übergeordnete Programm nach —
Chromes Ton kommt aus einem Helfer, angezeigt gehört Chrome. Symbole
gehen denselben Weg, sonst steht neben dem Namen ein Platzhalter.

ProcessVisibility zeigt, was klingt, kürzlich klang oder geregelt wird.
Die Nachlaufzeit von zwei Minuten ist der eigentliche Punkt: ohne sie
verschwindet Spotify beim Pausieren aus der Liste, und man kann die
Lautstärke genau dann nicht mehr einstellen, wenn man es will. Geregelte
Programme bleiben immer — wer etwas leiser gedreht hat, muss es auch
zurückdrehen können.

Die Prozentzahl wandert vom Namen zum Regler. Oben war sie genau die
Breite, die den Namen abgeschnitten hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:54:20 +02:00

197 lines
7.6 KiB
Swift

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 ? 6 : 10) {
if mixer.isEnabled {
if mixer.processes.isEmpty {
Text("mixer.noProcesses", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
} else {
ForEach(mixer.processes.prefix(compact ? 4 : 10)) { process in
ProcessRow(mixer: mixer, process: process, compact: compact)
}
}
if let error = mixer.lastError {
Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical)
.fixedSize(horizontal: false, vertical: true)
}
Divider().overlay(Onyx.Color.hairline)
// 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: {
Label("mixer.off", systemImage: "power")
.font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
} else {
OffState(mixer: mixer, compact: compact)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt.
private struct OffState: View {
let mixer: AudioMixer
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
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: {
Label("mixer.on", systemImage: "slider.horizontal.3").font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)
}
}
}
private struct ProcessRow: View {
let mixer: AudioMixer
let process: AudioProcess
let compact: Bool
var body: some View {
let managed = mixer.isManaged(process.bundleID)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
icon
Text(process.name)
.font(Onyx.Font.caption)
.foregroundStyle(process.isPlaying ? 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)
if process.isPlaying {
Circle().fill(Onyx.Color.positive).frame(width: 5, height: 5)
}
Spacer(minLength: 0)
if managed {
Button {
mixer.setMuted(!mixer.isMuted(process.bundleID), for: process.bundleID)
} label: {
Image(systemName: mixer.isMuted(process.bundleID)
? "speaker.slash.fill" : "speaker.wave.2.fill")
.font(.system(size: 10))
}
.buttonStyle(.plain)
.foregroundStyle(mixer.isMuted(process.bundleID)
? Onyx.Color.critical : Onyx.Color.textTertiary)
}
// Erst wenn ein Programm ausgewählt ist, wird sein Ton
// abgegriffen. Alles andere läuft unverändert am Mixer vorbei.
Toggle("", isOn: Binding(
get: { managed },
set: { mixer.setManaged($0, for: process.bundleID) }))
.toggleStyle(.switch)
.controlSize(.mini)
.labelsHidden()
}
if managed {
// Die Prozentzahl steht beim Regler, nicht beim Namen: oben ist
// sie die Breite, die den Namen abschneidet, hier gehört sie
// ohnehin hin.
HStack(spacing: 6) {
Slider(value: Binding(
get: { mixer.volume(for: process.bundleID) },
set: { mixer.setVolume($0, for: process.bundleID) }),
in: 0...Gain.maximumVolume)
.controlSize(.mini)
.disabled(mixer.isMuted(process.bundleID))
.overlay(alignment: .leading) {
// Markierung bei 100 %: darüber wird verstärkt, und
// man soll sehen, wann man diese Grenze überschreitet.
GeometryReader { geometry in
Rectangle()
.fill(Onyx.Color.hairline)
.frame(width: 1, height: 8)
.offset(x: geometry.size.width / Gain.maximumVolume, y: 4)
}
}
Text("\(Int(mixer.volume(for: process.bundleID) * 100)) %")
.font(Onyx.Font.metricSmall)
.foregroundStyle(mixer.volume(for: process.bundleID) > 1
? Onyx.Color.warning : Onyx.Color.textTertiary)
// Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 34, alignment: .trailing)
}
}
}
}
@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: 14, height: 14)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
// 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)
)
}
}