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 } 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) }() }