feat(ios calls): drop unreachable earpiece from route toggle; Speaker <-> BT/headset

Proven via device telemetry: inside a WKWebView, WebKit owns the WebRTC audio unit and
forces the loudspeaker; overrideOutputAudioPort(.none) is a no-op (route settles on
Speaker 1.2s later), so the built-in earpiece cannot be selected. Present only what
actually works on iOS: Speaker, and Bluetooth/wired headset when connected. bzApplyRoute
now maps only 'speaker' to the loudspeaker override; bt/headset use the default port.
Coerce any stale 'earpiece' pref to speaker on iOS. Also strips the route debug telemetry.
Other platforms keep the earpiece option. build batch147.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:26:13 +05:30
parent 0282181ca6
commit e9b9d3c121
+11 -6
View File
@@ -1158,7 +1158,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-20-batch146';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-21-batch147';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
@@ -4370,10 +4370,12 @@ function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.
function nativeAudioRoute(){ try{ var C=window.Capacitor; return (C && C.getPlatform && C.getPlatform()==='ios' && C.Plugins && C.Plugins.AudioRoute) ? C.Plugins.AudioRoute : null; }catch(_){ return null; } }
function bzApplyRoute(route){
var ar=nativeAudioRoute();
// TEMP diagnostic: report whether the native plugin registered and what setSpeaker does, so we can see on
// device WHY the toggle isn't switching (plugin missing? setSpeaker rejecting? route not holding?).
if(window.bzDbg){ try{ var C=window.Capacitor; window.bzDbg('route',{ stage:'apply', route:route, has:ar?1:0, plat:(C&&C.getPlatform&&C.getPlatform())||'', plugins:(C&&C.Plugins)?Object.keys(C.Plugins).slice(0,30).join(','):'none' }); }catch(_){} }
if(ar){ try{ var pr=ar.setSpeaker({ on: route!=='earpiece' }); if(pr&&pr.then){ pr.then(function(r){ if(window.bzDbg) window.bzDbg('route',{stage:'ok', route:route, r:JSON.stringify(r||{})}); }).catch(function(e){ if(window.bzDbg) window.bzDbg('route',{stage:'reject', route:route, err:String((e&&e.message)||e)}); }); } }catch(e){ if(window.bzDbg) window.bzDbg('route',{stage:'threw', err:String((e&&e.message)||e)}); } } // speaker/bt -> loudspeaker override; earpiece -> default route (BT auto-selected if connected)
// iOS native only: 'speaker' forces the loudspeaker; anything else (bt / wired headset) uses the default
// output port, which reverts to a connected accessory. The built-in EARPIECE is not reachable inside a
// WKWebView — WebKit owns the WebRTC audio unit and forces the speaker (proven: overrideOutputAudioPort(.none)
// is a no-op there), so the iOS route cycle never offers 'earpiece'. Native-grade earpiece would require a
// native WebRTC stack (e.g. the LiveKit iOS SDK) instead of the webview.
if(ar){ try{ ar.setSpeaker({ on: route==='speaker' }); }catch(_){} }
}
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
// plugin is present.
@@ -4425,6 +4427,7 @@ function spkIcon(){ return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speake
function routeLabel(){ return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece'); }
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
meetSpeakerOn=(meetRoute==='speaker');
b.classList.toggle('off', meetRoute!=='speaker');
b.innerHTML=ic(spkIcon(),20);
@@ -4529,7 +4532,9 @@ async function openAudioMenu(anchor){
async function toggleSpeakerphone(){
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
const routes=['speaker','earpiece'].concat(bt?['bt']:[]);
// iOS WKWebView cannot route call audio to the built-in earpiece (WebKit forces the speaker), so drop it from
// the cycle there: Speaker <-> Bluetooth/headset only. Other platforms keep the earpiece option.
const routes=['speaker'].concat(nativeAudioRoute()?[]:['earpiece']).concat(bt?['bt']:[]);
const i=routes.indexOf(meetRoute);
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}