diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index 9f1d1fc..dbdb984 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -6,13 +6,15 @@ import AVFoundation // up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds). // // The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the -// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only -// what iOS actually allows for call audio: -// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker)) -// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default +// loudspeaker. So this plugin exposes only what iOS actually allows for call audio: +// * setSpeaker(true) -> force the loudspeaker. IMPORTANT: overrideOutputAudioPort(.speaker) ALONE cannot +// beat a connected Bluetooth headset (BT is higher priority), so we also DROP the +// Bluetooth options from the category (options [.defaultToSpeaker]) so BT isn't a +// candidate output — that's what actually forces the speaker over BT. +// * setSpeaker(false) -> "Device": allow BT/wired again and use the default port, which routes to a connected +// headset (else the system default). // It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio -// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct -// speaker/bluetooth/headset icon. +// outputs from JavaScript, so the web layer can't otherwise show the correct speaker/bluetooth/headset icon. @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -26,15 +28,30 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { private var pending: DispatchWorkItem? override public func load() { - let s = AVAudioSession.sharedInstance() - try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) - try? s.setActive(true) + try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call NotificationCenter.default.addObserver(self, selector: #selector(routeChanged), name: AVAudioSession.routeChangeNotification, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } + // Force the loudspeaker even over a connected BT headset: with no .allowBluetooth option, BT is not an + // eligible output, so the port override lands on the built-in speaker. + private func configureSpeaker() throws { + let s = AVAudioSession.sharedInstance() + try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) + try s.setActive(true) + try s.overrideOutputAudioPort(.speaker) + } + + // Allow BT/wired and use the default port (routes to a connected headset, else the system default). + private func configureDevice() throws { + let s = AVAudioSession.sharedInstance() + try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) + try s.setActive(true) + try s.overrideOutputAudioPort(.none) + } + // The ACTIVE output, mapped to a simple label the web UI turns into an icon. private func currentOutput() -> String { for o in AVAudioSession.sharedInstance().currentRoute.outputs { @@ -51,15 +68,13 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { } @objc private func routeChanged() { - // Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user - // chose it, and tell the web UI where the audio actually is so the icon stays correct. + // Coalesce the burst iOS fires on device (dis)connect, then re-hold the loudspeaker if the user chose + // it (a BT connect can steal the route), and tell the web UI where the audio actually is. pending?.cancel() let work = DispatchWorkItem { [weak self] in guard let self = self else { return } - let s = AVAudioSession.sharedInstance() - if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) { - try? s.overrideOutputAudioPort(.speaker) - } + let onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker } + if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() } self.notifyListeners("routeChange", data: ["output": self.currentOutput()]) } pending = work @@ -68,11 +83,8 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { @objc func setSpeaker(_ call: CAPPluginCall) { wantSpeaker = call.getBool("on") ?? true - let s = AVAudioSession.sharedInstance() do { - // "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on. - if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) } - try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) + if wantSpeaker { try configureSpeaker() } else { try configureDevice() } call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it } catch { call.reject(error.localizedDescription) diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index ba45f13..41a1f76 100644 --- a/mobile/plugins/audio-route/package.json +++ b/mobile/plugins/audio-route/package.json @@ -1,6 +1,6 @@ { "name": "audio-route", - "version": "1.1.0", + "version": "1.1.1", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", diff --git a/server/public/home.html b/server/public/home.html index e5a7e76..8566675 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1158,7 +1158,7 @@ -