diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index 4a8214f..bb206b7 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -33,6 +33,15 @@ final class MixerMenuBarModule: MenuBarModule { 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 { diff --git a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings index f0589d6..95266e3 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/AudioProvider/Localizable.xcstrings @@ -196,6 +196,22 @@ } } }, + "mixer.recordingHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted." + } + } + } + }, "mixer.release": { "localizations": { "de": { diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index 4eabde4..ee0ee48 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -86,11 +86,10 @@ public struct MixerView: View { } .frame(maxWidth: .infinity, alignment: .leading) .animation(Onyx.Motion.value, value: mixer.groups.count) - // Die Pegel laufen nur, solange die Ansicht sichtbar ist. Der - // Mikrofonpegel schaltet dabei den orangen Punkt an — ein Messgerät, - // das im Hintergrund weiterläuft, wäre hier das Falsche. - .onAppear { system.levels.addConsumer() } - .onDisappear { system.levels.removeConsumer() } + // Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht + // an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und + // ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist + // schlimmer als eines, das gar nicht misst. } } @@ -107,6 +106,17 @@ extension MixerView { } .toggleStyle(.checkbox) .controlSize(.small) + + // Der Hinweis gehört hierher, weil hier die Entscheidung fällt. + // + // Ein Tap ist für macOS eine Tonaufnahme — auch wenn nur die + // Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das + // orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht, + // sucht den Fehler bei sich oder hält die App für unsauber. + Text("mixer.recordingHint", bundle: .module) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift index 5a267d5..c8e926d 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift @@ -10,6 +10,9 @@ import SwiftUI @MainActor public final class MenuBarController { + /// Hält den Delegaten am Leben — `NSPopover` merkt ihn sich nur schwach. + private var popoverWatcher: PopoverWatcher? + /// Ein Modul, dessen Element keine Breite bekommen hat. /// /// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte @@ -148,7 +151,11 @@ public final class MenuBarController { rootView: entry.module.makePopoverView() .tint(Onyx.Color.accent) .labelStyle(.onyx)) + let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() } + popoverWatcher = watcher + entry.popover.delegate = watcher entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + entry.module.popoverDidOpen() // Ohne das bleibt das Popover hinter dem aktiven Fenster. entry.popover.contentViewController?.view.window?.makeKey() openPopoverID = id @@ -190,11 +197,21 @@ public final class MenuBarController { } } + /// Zugegangen, ohne dass wir es veranlasst haben. + private func popoverClosedByItself() { + guard let id = openPopoverID, let entry = entries[id] else { return } + entry.module.popoverDidClose() + openPopoverID = nil + } + private func closeOpenPopover() { if let id = openPopoverID, let entry = entries[id] { entry.popover.performClose(nil) } - openPopoverID = nil + // Über denselben Weg wie das Zugehen von selbst: der räumt + // `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der + // Delegat vorher, nachher oder gar nicht meldet. + popoverClosedByItself() globalMonitor.map(NSEvent.removeMonitor) localMonitor.map(NSEvent.removeMonitor) keyMonitor.map(NSEvent.removeMonitor) @@ -215,3 +232,21 @@ public final class MenuBarController { onVisibilityChanged?(hidden) } } + +/// Horcht, ob das Popover zugegangen ist. +/// +/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und +/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch +/// ohne Zutun zu — beim Wechsel in eine andere App etwa —, und ohne diese +/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht. +final class PopoverWatcher: NSObject, NSPopoverDelegate { + private let onClose: @MainActor () -> Void + + init(onClose: @escaping @MainActor () -> Void) { + self.onClose = onClose + } + + func popoverDidClose(_ notification: Notification) { + MainActor.assumeIsolated { onClose() } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift index 1835a96..ec18453 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift @@ -42,6 +42,17 @@ public protocol MenuBarModule: AnyObject { /// Der Inhalt des Popovers beim Klick. func makePopoverView() -> AnyView + /// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht. + /// + /// **Die einzige verlässliche Stelle für alles, was nur laufen darf, + /// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem + /// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die + /// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt, + /// hängt sie an etwas, das nie eintritt — im Fall des Mikrofons mit dem + /// orangen Punkt als Beweis. + func popoverDidOpen() + func popoverDidClose() + /// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier /// meldet sich das Modul beim Provider an und wieder ab. func activate() @@ -72,3 +83,9 @@ extension TimeInterval { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } + +public extension MenuBarModule { + // Die meisten Module haben nichts abzuräumen. + func popoverDidOpen() {} + func popoverDidClose() {} +}