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).
|
// 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
|
// 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
|
// loudspeaker. So this plugin exposes only what iOS actually allows for call audio:
|
||||||
// what iOS actually allows for call audio:
|
// * setSpeaker(true) -> force the loudspeaker. IMPORTANT: overrideOutputAudioPort(.speaker) ALONE cannot
|
||||||
// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker))
|
// beat a connected Bluetooth headset (BT is higher priority), so we also DROP the
|
||||||
// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default
|
// 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
|
// 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
|
// outputs from JavaScript, so the web layer can't otherwise show the correct speaker/bluetooth/headset icon.
|
||||||
// speaker/bluetooth/headset icon.
|
|
||||||
@objc(AudioRoutePlugin)
|
@objc(AudioRoutePlugin)
|
||||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AudioRoutePlugin"
|
public let identifier = "AudioRoutePlugin"
|
||||||
@@ -26,15 +28,30 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
private var pending: DispatchWorkItem?
|
private var pending: DispatchWorkItem?
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
let s = AVAudioSession.sharedInstance()
|
try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call
|
||||||
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
||||||
try? s.setActive(true)
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
||||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit { NotificationCenter.default.removeObserver(self) }
|
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.
|
// The ACTIVE output, mapped to a simple label the web UI turns into an icon.
|
||||||
private func currentOutput() -> String {
|
private func currentOutput() -> String {
|
||||||
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
|
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
|
||||||
@@ -51,15 +68,13 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func routeChanged() {
|
@objc private func routeChanged() {
|
||||||
// Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user
|
// Coalesce the burst iOS fires on device (dis)connect, then re-hold the loudspeaker if the user chose
|
||||||
// chose it, and tell the web UI where the audio actually is so the icon stays correct.
|
// it (a BT connect can steal the route), and tell the web UI where the audio actually is.
|
||||||
pending?.cancel()
|
pending?.cancel()
|
||||||
let work = DispatchWorkItem { [weak self] in
|
let work = DispatchWorkItem { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let s = AVAudioSession.sharedInstance()
|
let onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||||
if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) {
|
if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() }
|
||||||
try? s.overrideOutputAudioPort(.speaker)
|
|
||||||
}
|
|
||||||
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
||||||
}
|
}
|
||||||
pending = work
|
pending = work
|
||||||
@@ -68,11 +83,8 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
|
|
||||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
wantSpeaker = call.getBool("on") ?? true
|
wantSpeaker = call.getBool("on") ?? true
|
||||||
let s = AVAudioSession.sharedInstance()
|
|
||||||
do {
|
do {
|
||||||
// "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on.
|
if wantSpeaker { try configureSpeaker() } else { try configureDevice() }
|
||||||
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) }
|
|
||||||
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
|
||||||
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
||||||
} catch {
|
} catch {
|
||||||
call.reject(error.localizedDescription)
|
call.reject(error.localizedDescription)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-route",
|
"name": "audio-route",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||||
"main": "dist/plugin.cjs.js",
|
"main": "dist/plugin.cjs.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|||||||
@@ -1158,7 +1158,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="/icons.js?v=6"></script>
|
<script src="/icons.js?v=6"></script>
|
||||||
<script>window.__BUILD='2026-07-21-batch148';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-22-batch149';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||||
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
||||||
//
|
//
|
||||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||||
@@ -4456,6 +4456,7 @@ function bzInitNativeRoute(){
|
|||||||
function bzOnNativeRoute(output){
|
function bzOnNativeRoute(output){
|
||||||
if(!output) return;
|
if(!output) return;
|
||||||
_nativeOutput=output;
|
_nativeOutput=output;
|
||||||
|
if(window.bzDbg) window.bzDbg('nroute',{out:output, want:meetRoute}); // TEMP verify speaker-over-BT (remove after confirm)
|
||||||
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
||||||
refreshSpkBtn();
|
refreshSpkBtn();
|
||||||
}
|
}
|
||||||
@@ -4567,6 +4568,7 @@ async function toggleSpeakerphone(){
|
|||||||
const i=routes.indexOf(cur);
|
const i=routes.indexOf(cur);
|
||||||
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
|
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
|
||||||
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
|
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
|
||||||
|
if(window.bzDbg) window.bzDbg('sptap',{route:meetRoute, ios:ios, nout:_nativeOutput}); // TEMP verify toggle (remove after confirm)
|
||||||
bzApplyRoute(meetRoute); // iOS native: switch loudspeaker<->default port via AVAudioSession (setSinkId below is a no-op there)
|
bzApplyRoute(meetRoute); // iOS native: switch loudspeaker<->default port via AVAudioSession (setSinkId below is a no-op there)
|
||||||
if(!ios){
|
if(!ios){
|
||||||
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
||||||
|
|||||||
Reference in New Issue
Block a user