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>
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(Onyx.Space.xl)
|
|
}
|
|
.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)
|
|
}()
|
|
}
|