Files
onyx/Onyx/MixerMenuBarModule.swift
Scarriffle ee848b86cc Das Aufnahmesymbol kam vom Mixer, nicht vom Pegel
Gemessen über kAudioProcessPropertyIsRunningInput: mit laufendem Mixer nimmt
Onyx auf, ohne ihn nicht. Ein Process Tap ist für macOS eine Tonaufnahme —
auch wenn damit nur die Lautstärke geregelt wird. Solange der Mixer Chrome und
Firefox abgreift, steht das orange Symbol in der Leiste.

Der Mixer stand seit der Fehlersuche am Wochenende auf „automatisch starten",
deshalb war es dauerhaft. Das Häkchen steht jetzt wieder aus.

Ein Hinweis unter dem Häkchen sagt das jetzt, wo die Entscheidung fällt: wer
das Symbol ohne Erklärung sieht, sucht den Fehler bei sich oder hält die App
für unsauber.

Dazu ein echter Fund an derselben Stelle: der Mikrofonpegel hing an
`onDisappear` der View — und das bleibt bei einem NSPopover aus. Dessen
Controller lebt nach dem Schließen weiter, die View verschwindet nie aus der
Hierarchie. Die Messung wäre also beim ersten Öffnen angegangen und nie wieder
aus. Sie hängt jetzt am Popover selbst: `MenuBarModule` bekommt
`popoverDidOpen`/`popoverDidClose`, gemeldet auch dann, wenn das Popover von
selbst zugeht — beim Wechsel in eine andere App etwa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:20:07 +02:00

140 lines
4.8 KiB
Swift

import SwiftUI
import AppKit
import AudioProvider
import OnyxMenuBar
import OnyxDesign
/// Der Lautstärkemixer als eigenes Element in der Menüleiste.
///
/// Bewusst getrennt vom Onyx-Element: den Ton einer einzelnen App leiser zu
/// drehen ist etwas, das man mitten in etwas anderem tut. Ein Umweg über ein
/// Menü und dann in ein Panel ist dafür zwei Schritte zu weit.
@MainActor
final class MixerMenuBarModule: MenuBarModule {
let id = "audio"
var displayName: String { String(localized: "mixer.menubar.name") }
var shortLabel: String { String(localized: "mixer.menubar.short") }
private let mixer: AudioMixer
private let system: SystemAudio
private var view: MixerStatusView?
private var isActive = false
init(mixer: AudioMixer, system: SystemAudio) {
self.mixer = mixer
self.system = system
}
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MixerStatusView()
view.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
self.view = view
return view
}
/// Pegel nur, solange das Popover offen ist.
///
/// Der Mikrofonpegel schaltet den orangen Punkt an das darf keine
/// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht
/// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb
/// dauerhaft stehen.
func popoverDidOpen() { system.levels.addConsumer() }
func popoverDidClose() { system.levels.removeConsumer() }
func makePopoverView() -> AnyView {
AnyView(
ScrollView {
MixerView(mixer: mixer, system: system)
.padding(16)
}
.scrollIndicators(.automatic)
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
// Eine Zeile aus Symbol, Stummschalter, Regler und Wert braucht
// Platz, und ein Regler von hundert Punkten Länge lässt sich nicht
// fein einstellen.
.frame(width: 420)
.frame(maxHeight: 620)
)
}
func activate() {
isActive = true
startObserving()
}
func deactivate() {
isActive = false
view = nil
}
/// Der Mixer misst ohnehin; hier wird nur zugehört. `withObservationTracking`
/// meldet sich einmal und muss danach neu eingerichtet werden.
private func startObserving() {
withObservationTracking {
_ = mixer.isEnabled
_ = mixer.managed
} onChange: {
Task { @MainActor [weak self] in
guard let self, isActive else { return }
view?.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
startObserving()
}
}
}
}
/// Ein Glyph, mehr nicht.
///
/// Eine Zahl wäre hier sinnlos: welche Lautstärke" hat bei einem Mischpult
/// keine einzelne Antwort. Der Zustand, der zählt, ist ob überhaupt etwas
/// geregelt wird und den zeigt die Deckkraft.
private final class MixerStatusView: NSView {
private var isEnabled = false
private var managed = 0
init() {
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: MenuBarText.minimumWidth, height: 22)
}
func update(isEnabled: Bool, managed: Int) {
self.isEnabled = isEnabled
self.managed = managed
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
// Volle Deckkraft, auch ausgeschaltet.
//
// Die Abdunklung war meine Idee und ein Fehlgriff: in der Menüleiste
// liegt oft ein heller Schreibtischhintergrund, und ein zu 55 % blasses
// Glyph ist dort praktisch weg. Ob der Mixer läuft, erfährt man beim
// Klick dafür muss man das Symbol aber erst finden.
let rect = NSRect(x: bounds.midX - 8, y: bounds.midY - 8, width: 16, height: 16)
if let glyph = Self.glyph {
// `labelColor` und nicht Weiß: unter einer hellen Menüleiste wäre
// ein weißes Symbol unsichtbar. Auf dunklem Grund ist labelColor
// weiß, also genau das Gewünschte.
MenuBarText.drawTemplate(glyph, color: .labelColor, in: rect)
return
}
MenuBarText.drawSymbol("slider.horizontal.3", color: .labelColor, in: rect)
}
/// Die eigene Vorlage aus dem Bundle. Einmal geladen und behalten das
/// Zeichnen läuft bei jeder Änderung, das Einlesen einer SVG nicht.
private static let glyph: NSImage? = {
guard let url = Bundle.main.url(forResource: "sound-waves", withExtension: "svg")
else { return nil }
return NSImage(contentsOf: url)
}()
}