diff --git a/Stream/AudioInputProvider.swift b/Stream/AudioInputProvider.swift index 9b71b69..8aea91f 100644 --- a/Stream/AudioInputProvider.swift +++ b/Stream/AudioInputProvider.swift @@ -38,6 +38,18 @@ final class AudioInputProvider { /// the new route. Not fired for a manual `refresh()` (the caller already knows). var onInputsChanged: (() -> Void)? + /// Fired when a Bluetooth audio input appears that wasn't present on the + /// previous enumeration — i.e. a device paired/connected mid-session. Carries + /// the new device's display name so the UI can surface a " connected" + /// banner. Deliberately NOT fired for devices already connected when monitoring + /// starts (the first `refresh()` only seeds the baseline), only for arrivals. + var onBluetoothConnected: ((String) -> Void)? + + /// UIDs of the Bluetooth inputs seen on the most recent enumeration. A later + /// route change diffs against this to tell which device is *newly* connected + /// (fire the banner) versus one that was already present (stay quiet). + private var knownBluetoothUIDs: Set = [] + /// 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. @@ -117,7 +129,7 @@ final class AudioInputProvider { switch reason { case .newDeviceAvailable, .oldDeviceUnavailable: guard permission == .granted else { return } - configureAndEnumerate() + configureAndEnumerate(announceNewBluetooth: true) onInputsChanged?() default: break @@ -126,7 +138,10 @@ final class AudioInputProvider { // MARK: - Private - private func configureAndEnumerate() { + /// - Parameter announceNewBluetooth: when true, fire `onBluetoothConnected` + /// for each Bluetooth input not seen on the previous enumeration. False for + /// the initial `refresh()`, which only seeds the baseline set. + private func configureAndEnumerate(announceNewBluetooth: Bool = false) { let session = AVAudioSession.sharedInstance() // Setting the category is sufficient to enumerate Bluetooth inputs. Do // not activate merely for enumeration; activation is asynchronous on @@ -139,10 +154,10 @@ final class AudioInputProvider { continue } } - enumerate(from: session) + enumerate(from: session, announceNewBluetooth: announceNewBluetooth) } - private func enumerate(from session: AVAudioSession) { + private func enumerate(from session: AVAudioSession, announceNewBluetooth: Bool) { let available = session.availableInputs ?? [] inputs = available.map { port in let isBT = port.portType == .bluetoothHFP || port.portType == .bluetoothLE @@ -152,6 +167,15 @@ final class AudioInputProvider { isBluetooth: isBT ) } + // Diff the Bluetooth set against the previous enumeration so a device that + // paired mid-session surfaces once; already-connected devices stay quiet. + let bluetooth = inputs.filter(\.isBluetooth) + if announceNewBluetooth { + for input in bluetooth where !knownBluetoothUIDs.contains(input.uid) { + onBluetoothConnected?(input.displayName) + } + } + knownBluetoothUIDs = Set(bluetooth.map(\.uid)) } /// Activates a record-capable session that surfaces Bluetooth inputs. diff --git a/Stream/ContentView.swift b/Stream/ContentView.swift index 287e4a9..eb62a3f 100644 --- a/Stream/ContentView.swift +++ b/Stream/ContentView.swift @@ -1,6 +1,14 @@ import SwiftUI import StreamCore +/// A Bluetooth audio device that paired mid-session, backing the transient +/// "connected" banner. The `id` is fresh per arrival so replacing the banner +/// restarts its dismiss timer even when the same device reconnects. +private struct BluetoothConnection: Equatable, Identifiable { + let id = UUID() + let name: String +} + /// Root view. The main page shows the live chat feed; the toolbar carries a "Live" /// recording button (top right) that starts/stops capture and a gear button that /// presents every setting — connection, video, backup, audio, and chat — in a @@ -18,6 +26,17 @@ struct ContentView: View { /// sheet's chat section so both observe one connection. @State private var chat = RestreamChat() + /// Audio input helper, owned here so its `AVAudioSession` route-change monitor + /// runs for the whole app lifetime. Drives the Bluetooth-connect banner below + /// and is threaded into the Settings sheet so both share one instance/observer. + @State private var audio = AudioInputProvider() + + /// The Bluetooth device that most recently paired mid-session, shown as a + /// transient banner. Set from `audio.onBluetoothConnected`; auto-cleared after + /// a few seconds by a `.task` keyed on its `id` (a fresh `id` restarts the + /// timer, so a second device replaces the banner cleanly). + @State private var bluetoothBanner: BluetoothConnection? + @State private var showingSettings = false /// Gates the "Stop Broadcast" confirmation. A live stream is easy to kill by a @@ -59,9 +78,16 @@ struct ContentView: View { if capture.isLive { micFAB } } .overlay(alignment: .top) { - if capture.isLive { livePill } + VStack(spacing: 8) { + if capture.isLive { livePill } + if let banner = bluetoothBanner { + bluetoothBannerView(name: banner.name) + } + } + .padding(.top, 8) } .animation(.spring(duration: 0.3, bounce: 0.2), value: capture.isLive) + .animation(.spring(duration: 0.35, bounce: 0.25), value: bluetoothBanner) .navigationTitle("Stream") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -83,7 +109,27 @@ struct ContentView: View { } .sheet(isPresented: $showingSettings) { settingsSheet } } - .task { chat.autoConnect() } + .task { + chat.autoConnect() + // Surface a banner when a Bluetooth audio device pairs mid-session. + audio.onBluetoothConnected = { name in + bluetoothBanner = BluetoothConnection(name: name) + Haptics.tap() + } + // Seed the baseline set + start the route-change observer without + // prompting for mic access here (Settings owns the permission ask). + // Already-connected devices only populate the baseline; they don't + // trigger a banner — only devices that arrive afterward do. + audio.refresh(requestPermission: false) + } + // Auto-dismiss the Bluetooth banner. Keyed on the connection id so a new + // device restarts the timer; cancellation (id change) skips the stale clear. + .task(id: bluetoothBanner?.id) { + guard bluetoothBanner != nil else { return } + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + bluetoothBanner = nil + } .confirmationDialog( "Stop the broadcast?", isPresented: $showingStopConfirmation, @@ -170,7 +216,6 @@ struct ContentView: View { .padding(.horizontal, 12) .padding(.vertical, 7) .glassEffect(.regular, in: .capsule) - .padding(.top, 8) .transition(.scale.combined(with: .opacity)) // Purely informational: never intercept taps/scroll on the chat beneath it. .allowsHitTesting(false) @@ -179,6 +224,29 @@ struct ContentView: View { .accessibilityElement(children: .combine) } + // MARK: - Bluetooth-connected banner + + /// Transient glass capsule announcing a Bluetooth audio device that just + /// paired. Sits below the LIVE pill (same top overlay stack) and auto-dismisses + /// via the `.task(id:)` timer; purely informational, so it never eats taps. + private func bluetoothBannerView(name: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "wave.3.right.circle.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.blue) + Text("\(name) connected") + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .glassEffect(.regular, in: .capsule) + .transition(.move(edge: .top).combined(with: .opacity)) + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(name) connected") + } + // MARK: - Setup banner /// Slim call-to-action shown when the connection isn't ready to publish. Tapping @@ -224,7 +292,7 @@ struct ContentView: View { /// the sheet to the exact height of whatever content is on screen (measured /// from the scroll view's real content size), resizing as sections are pushed. private var settingsSheet: some View { - SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist) + SettingsView(settings: $settings, chat: chat, capture: capture, onChange: persist, audio: audio) } } diff --git a/Stream/SettingsView.swift b/Stream/SettingsView.swift index ee1a2e2..b86d96a 100644 --- a/Stream/SettingsView.swift +++ b/Stream/SettingsView.swift @@ -66,7 +66,9 @@ struct SettingsView: View { var onChange: () -> Void /// Audio input enumeration helper (AVAudioSession-backed, Simulator-safe). - @State private var audio = AudioInputProvider() + /// Owned by the root view so route-change monitoring (and the Bluetooth-connect + /// banner it drives) runs continuously, not only while this sheet is open. + var audio: AudioInputProvider /// Camera permission + device-capability helper for the facecam. @State private var camera = CameraSupport() @@ -889,6 +891,13 @@ private final class MicrophoneLevelMonitor { @ObservationIgnored private var lastBroadcastCheckAt: UInt64 = 0 @ObservationIgnored private var broadcastIsLive = false + /// Observer for `AVAudioEngineConfigurationChange`. The engine posts it when its + /// I/O format changes underneath the running graph — most importantly when a + /// Bluetooth HFP link finishes its asynchronous handoff after `setPreferredInput`. + /// The tap was installed at the pre-switch format, so without rebuilding it on + /// this notification the meter goes silent once the route lands on the BT mic. + @ObservationIgnored private nonisolated(unsafe) var configChangeObserver: NSObjectProtocol? + func start() { guard task == nil else { return } task = Task { [weak self] in @@ -994,9 +1003,45 @@ private final class MicrophoneLevelMonitor { // (the DJI once it's the preferred input) and gives real PCM buffers, unlike // the /dev/null AVAudioRecorder metering trick which can read nothing. let engine = AVAudioEngine() + let input = engine.inputNode + let inputFormat = input.inputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { return } + // A tap alone does NOT reliably pull a Bluetooth HFP input — the engine only + // renders its input when the graph drives an output, so an unconnected + // input node hands the tap silent buffers (the "DJI selected, meter flat" + // bug). Route input → main mixer to force a full I/O cycle, and mute the + // mixer output so nothing is monitored back to the speaker/HFP earpiece. + engine.connect(input, to: engine.mainMixerNode, format: inputFormat) + engine.mainMixerNode.outputVolume = 0 + guard installMeterTap(on: engine) else { return } + engine.prepare() + do { + try engine.start() + } catch { + engine.inputNode.removeTap(onBus: 0) + return + } + self.engine = engine + + // A Bluetooth HFP link settles asynchronously AFTER setPreferredInput, so the + // format above may still be the built-in mic's. Rebuild the tap on the new + // format when the engine reconfigures, else the BT meter reads flat forever. + observeConfigChanges(of: engine) + + let route = session.currentRoute.inputs + .map { "\($0.portName)/\($0.portType.rawValue)" } + .joined(separator: ",") + let format = engine.inputNode.inputFormat(forBus: 0) + Self.log.info("Mic meter started: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch pref=\(self.preferredInputUID ?? "nil", privacy: .public)") + } + + /// Reads the input node's CURRENT format and installs the metering tap. Split out + /// so it can be re-run on a configuration change (when the live route/format has + /// changed). Returns false when the route has no usable input yet. + private func installMeterTap(on engine: AVAudioEngine) -> Bool { let input = engine.inputNode let format = input.inputFormat(forBus: 0) - guard format.sampleRate > 0, format.channelCount > 0 else { return } + guard format.sampleRate > 0, format.channelCount > 0 else { return false } let level = meterLevel // @Sendable so the tap runs on the audio render thread — WITHOUT it the // closure inherits this @MainActor class's isolation and iOS crashes with @@ -1016,21 +1061,42 @@ private final class MicrophoneLevelMonitor { level.withLock { $0 = rms } } } catch { - return + return false } - engine.prepare() - do { - try engine.start() - } catch { - input.removeTap(onBus: 0) - return + return true + } + + /// Registers the configuration-change observer for `engine`, replacing any prior + /// one. Fires on the main queue; hops back onto the actor to rebuild the tap. + private func observeConfigChanges(of engine: AVAudioEngine) { + if let configChangeObserver { + NotificationCenter.default.removeObserver(configChangeObserver) } - self.engine = engine + configChangeObserver = NotificationCenter.default.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine, + queue: .main + ) { [weak self] _ in + Task { @MainActor in self?.handleEngineConfigChange() } + } + } - let route = session.currentRoute.inputs + /// The engine's I/O reconfigured (typically the BT route finishing its handoff). + /// Reinstall the tap at the new input format and make sure the engine is running; + /// a config change stops the engine, and the old tap's format no longer matches. + private func handleEngineConfigChange() { + guard let engine else { return } + engine.inputNode.removeTap(onBus: 0) + guard installMeterTap(on: engine) else { return } + if !engine.isRunning { + engine.prepare() + try? engine.start() + } + let route = AVAudioSession.sharedInstance().currentRoute.inputs .map { "\($0.portName)/\($0.portType.rawValue)" } .joined(separator: ",") - Self.log.info("Mic meter started: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch pref=\(self.preferredInputUID ?? "nil", privacy: .public)") + let format = engine.inputNode.inputFormat(forBus: 0) + Self.log.info("Mic meter reconfigured: route=[\(route, privacy: .public)] format=\(format.sampleRate, privacy: .public)Hz/\(format.channelCount, privacy: .public)ch") } private func stopLocalCapture() { @@ -1039,6 +1105,10 @@ private final class MicrophoneLevelMonitor { // audio server and interrupting ScreenCaptureKit's live mic session. // Deactivate exactly once, on the real handover. guard let engine else { return } + if let configChangeObserver { + NotificationCenter.default.removeObserver(configChangeObserver) + self.configChangeObserver = nil + } engine.inputNode.removeTap(onBus: 0) engine.stop() self.engine = nil @@ -1159,6 +1229,6 @@ private struct LiveStatsCard: View { @Previewable @State var settings = StreamSettings.default return NavigationStack { SettingsView(settings: $settings, chat: RestreamChat(), - capture: ScreenCaptureController(), onChange: {}) + capture: ScreenCaptureController(), onChange: {}, audio: AudioInputProvider()) } }