fix(ios audio): stop the speaker<->BT oscillation; single override, no re-force
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app hold the built-in speaker over an active BT device. So: one override per tap, observer only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,14 @@ import AVFoundation
|
||||
// Call-audio routing for the iOS app. Registered by cap sync as a real Capacitor plugin package, so it shows
|
||||
// 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. 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, so the web layer can't otherwise show the correct speaker/bluetooth/headset icon.
|
||||
// KNOWN CEILING (WKWebView): the app does NOT own the AVAudioSession — WebKit's media process does, and it
|
||||
// re-asserts its own category (Bluetooth allowed) on every change. So the earpiece can't be forced, and the
|
||||
// built-in speaker can't be held over an actively-connected Bluetooth device (a re-force just oscillates with
|
||||
// WebKit — proven on device). This plugin therefore does a SINGLE override per user tap and does NOT fight
|
||||
// route changes; it only reports the active output so the web UI can show the correct icon.
|
||||
// * setSpeaker(true) -> try to force the loudspeaker (drops Bluetooth options + overrideOutputAudioPort(.speaker))
|
||||
// * setSpeaker(false) -> "Device": allow BT/wired and use the default port (routes to a connected headset)
|
||||
// * getRoute() + 'routeChange' event -> the active output ('speaker'|'bluetooth'|'wired'|'receiver'|'airplay')
|
||||
@objc(AudioRoutePlugin)
|
||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "AudioRoutePlugin"
|
||||
@@ -24,7 +22,7 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
private var wantSpeaker = false
|
||||
private static let nativeTag = "1.1.2-stable"
|
||||
private var pending: DispatchWorkItem?
|
||||
|
||||
override public func load() {
|
||||
@@ -35,8 +33,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
|
||||
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])
|
||||
@@ -44,7 +40,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
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])
|
||||
@@ -52,7 +47,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
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 {
|
||||
switch o.portType {
|
||||
@@ -67,31 +61,29 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Report the active output to the web UI. Deliberately does NOT re-force any route — fighting WebKit's
|
||||
// re-assertion just oscillates the audio (see ceiling note above).
|
||||
@objc private func routeChanged() {
|
||||
// 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 onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
||||
if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() }
|
||||
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
||||
self.notifyListeners("routeChange", data: ["output": self.currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||
}
|
||||
pending = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
||||
}
|
||||
|
||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||
wantSpeaker = call.getBool("on") ?? true
|
||||
let on = call.getBool("on") ?? true
|
||||
do {
|
||||
if wantSpeaker { try configureSpeaker() } else { try configureDevice() }
|
||||
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
||||
if on { try configureSpeaker() } else { try configureDevice() }
|
||||
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||
} catch {
|
||||
call.reject(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getRoute(_ call: CAPPluginCall) {
|
||||
call.resolve(["output": currentOutput()])
|
||||
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audio-route",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||
"main": "dist/plugin.cjs.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
||||
+14
-8
@@ -1158,7 +1158,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-22-batch149';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-22-batch150';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.
|
||||
//
|
||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||
@@ -4438,9 +4438,15 @@ function routeLabel(){
|
||||
function refreshSpkBtn(){
|
||||
const b=document.getElementById('meetSpkBtn'); if(!b) return;
|
||||
if(meetRoute==='earpiece' && nativeAudioRoute()) meetRoute='speaker'; // iOS: earpiece is unreachable in the WebView; coerce stale prefs
|
||||
const onSpk = iosRoute() ? (_nativeOutput==='speaker'||_nativeOutput==='receiver'||_nativeOutput==='unknown') : (meetRoute==='speaker');
|
||||
meetSpeakerOn=onSpk;
|
||||
b.classList.toggle('off', !onSpk);
|
||||
if(iosRoute()){
|
||||
// On iOS the button is a live OUTPUT indicator (speaker/bluetooth/headphones), not a speaker on/off — so
|
||||
// never dim it (a dimmed icon read as "disabled" when audio was simply on Bluetooth).
|
||||
meetSpeakerOn=(_nativeOutput==='speaker');
|
||||
b.classList.remove('off');
|
||||
} else {
|
||||
meetSpeakerOn=(meetRoute==='speaker');
|
||||
b.classList.toggle('off', meetRoute!=='speaker');
|
||||
}
|
||||
b.innerHTML=ic(spkIcon(),20);
|
||||
b.title=routeLabel()+' — tap to switch';
|
||||
}
|
||||
@@ -4449,14 +4455,14 @@ let _bzNativeRouteReady=false;
|
||||
function bzInitNativeRoute(){
|
||||
const ar=nativeAudioRoute(); if(!ar) return;
|
||||
if(!_bzNativeRouteReady && ar.addListener){ _bzNativeRouteReady=true;
|
||||
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output); }); }catch(_){}
|
||||
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output, e && e.native); }); }catch(_){}
|
||||
}
|
||||
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output); }).catch(function(){}); }catch(_){} }
|
||||
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output, r && r.native); }).catch(function(){}); }catch(_){} }
|
||||
}
|
||||
function bzOnNativeRoute(output){
|
||||
function bzOnNativeRoute(output, nat){
|
||||
if(!output) return;
|
||||
_nativeOutput=output;
|
||||
if(window.bzDbg) window.bzDbg('nroute',{out:output, want:meetRoute}); // TEMP verify speaker-over-BT (remove after confirm)
|
||||
if(window.bzDbg) window.bzDbg('nroute',{out:output, want:meetRoute, nat:nat||null}); // TEMP verify speaker-over-BT (remove after confirm)
|
||||
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
||||
refreshSpkBtn();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user