From a21b95e382552c9bf2e28bdc9bd8adb5f0467b3c Mon Sep 17 00:00:00 2001 From: Joe Blau Date: Sun, 5 Jul 2026 19:18:45 -0400 Subject: [PATCH] fix: detect Bluetooth/DJI mics connected mid-session (no app restart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input enumeration was pull-only — it ran on the Settings view's onAppear and the manual "Refresh Inputs" button, and nowhere else. Nothing observed AVAudioSession.routeChangeNotification, so a Bluetooth headset connected while the app was already running never re-enumerated; the only way to pick it up was relaunching (which re-runs onAppear). AudioInputProvider now registers a single routeChangeNotification observer (idempotently, from refresh()) and re-enumerates on .newDeviceAvailable / .oldDeviceUnavailable. It deliberately ignores .categoryChange and .override, which we provoke ourselves by setting the category and preferred input — reacting to those would loop. The observer is torn down in a nonisolated deinit. SettingsView wires the new onInputsChanged callback to re-apply the persisted preferred input to the session and restart the mic-level meter on the new route, so a headset plugged in now goes live within a second. Skipped while broadcasting, where the ScreenCaptureKit extension owns the route. Co-Authored-By: Claude Opus 4.8 (1M context) --- Stream/AudioInputProvider.swift | 53 +++++++++++++++++++++++++++++++++ Stream/SettingsView.swift | 11 +++++++ 2 files changed, 64 insertions(+) diff --git a/Stream/AudioInputProvider.swift b/Stream/AudioInputProvider.swift index b529b65..9b71b69 100644 --- a/Stream/AudioInputProvider.swift +++ b/Stream/AudioInputProvider.swift @@ -33,9 +33,29 @@ final class AudioInputProvider { /// Current microphone permission state. private(set) var permission: Permission = .undetermined + /// Fired after a live route change re-enumerates `inputs`, so the owning view + /// can re-apply the persisted preferred input and restart the level meter on + /// the new route. Not fired for a manual `refresh()` (the caller already knows). + var onInputsChanged: (() -> Void)? + + /// Observer token for `AVAudioSession.routeChangeNotification`. Marked + /// `nonisolated(unsafe)` so `deinit` (which is nonisolated on a `@MainActor` + /// type) can remove it; an `NSObjectProtocol` token is safe to touch there. + private nonisolated(unsafe) var routeChangeObserver: NSObjectProtocol? + + deinit { + if let routeChangeObserver { + NotificationCenter.default.removeObserver(routeChangeObserver) + } + } + /// Requests mic permission if needed, configures a record-capable session /// with Bluetooth options so BT inputs appear, then enumerates inputs. func refresh(requestPermission: Bool = true) { + // Start listening for hardware route changes so a Bluetooth/DJI mic + // connected while the app is already running is picked up immediately + // rather than only on the next launch or a manual Refresh. Idempotent. + startMonitoringRouteChanges() switch AVAudioApplication.shared.recordPermission { case .granted: permission = .granted @@ -71,6 +91,39 @@ final class AudioInputProvider { } } + // MARK: - Live route changes + + /// Registers a single `AVAudioSession.routeChangeNotification` observer. + /// Idempotent — safe to call from every `refresh()`. + func startMonitoringRouteChanges() { + guard routeChangeObserver == nil else { return } + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main + ) { [weak self] notification in + // Extract the reason off-actor (Notification isn't Sendable); the + // reason enum is a plain Sendable value we can hop to the main actor. + let raw = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt + let reason = raw.flatMap(AVAudioSession.RouteChangeReason.init(rawValue:)) + Task { @MainActor in self?.handleRouteChange(reason) } + } + } + + private func handleRouteChange(_ reason: AVAudioSession.RouteChangeReason?) { + // React only to hardware appearing/disappearing. We deliberately ignore + // `.categoryChange` / `.override`, which we provoke ourselves by setting + // the category and the preferred input — reacting to those would loop. + switch reason { + case .newDeviceAvailable, .oldDeviceUnavailable: + guard permission == .granted else { return } + configureAndEnumerate() + onInputsChanged?() + default: + break + } + } + // MARK: - Private private func configureAndEnumerate() { diff --git a/Stream/SettingsView.swift b/Stream/SettingsView.swift index 398b52c..00d7149 100644 --- a/Stream/SettingsView.swift +++ b/Stream/SettingsView.swift @@ -189,6 +189,17 @@ struct SettingsView: View { // Enumerate inputs/capabilities so the launcher summaries are accurate; // the live mic meter only runs while the Audio detail is open. audio.refresh(requestPermission: false) + // When a mic is (dis)connected mid-session, the provider re-enumerates + // and calls this back: re-apply the persisted input to the session and + // re-route the meter so a headset plugged in now is immediately live — + // no app restart. Skipped while broadcasting (extension owns the route). + audio.onInputsChanged = { + guard !BroadcastStateStore.isLive() else { return } + let uid = settings.preferredAudioInputUID + audio.select(uid: uid, into: &settings) + micLevel.setPreferredInput(uid) + micLevel.restartLocalCapture() + } camera.refresh() photos.refresh() }