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>
140 lines
4.8 KiB
Swift
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)
|
|
}()
|
|
}
|