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