fix(ios audio): force speaker over connected Bluetooth by dropping BT category options
overrideOutputAudioPort(.speaker) alone can't beat a connected BT headset (BT is higher priority), so 'Speaker' snapped back to BT. Now setSpeaker(true) sets category options [.defaultToSpeaker] (no allowBluetooth) so BT isn't an eligible output and the speaker wins; setSpeaker(false) restores [.allowBluetooth,.allowBluetoothA2DP] and uses the default port (routes to the headset). Observer re-holds speaker if a BT connect steals it. Adds a TEMP web probe (nroute/sptap) to verify from telemetry. plugin v1.1.1, web batch149. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user