feat(ios audio): report real output route so the toggle icon is correct

BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 23:43:07 +05:30
parent e9b9d3c121
commit c3b0ef560d
3 changed files with 101 additions and 131 deletions
+49 -18
View File
@@ -1158,7 +1158,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-21-batch147';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-21-batch148';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
@@ -4375,7 +4375,7 @@ function bzApplyRoute(route){
// 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(_){} }
if(ar){ bzInitNativeRoute(); 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.
@@ -4423,16 +4423,42 @@ function isBtLabel(l){ return /bluetooth|airpod|buds|headset|headphone|wh-|wf-/i
// needed on mobile. meetRoute is the source of truth; meetSpeakerOn stays in sync for the lite/off styling.
let _btConnected=false;
let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }catch(_){ return 'speaker'; } })(); // 'speaker'|'earpiece'|'bt'
function spkIcon(){ return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff'); }
function routeLabel(){ return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece'); }
// iOS only: the real active output as reported by the native plugin ('speaker'|'bluetooth'|'wired'|'receiver'|
// 'airplay'). The web layer can't see audio outputs on iOS, so the icon is driven by this instead of meetRoute.
let _nativeOutput=null;
function iosRoute(){ return _nativeOutput; } // truthy only on iOS native once the plugin has reported
function spkIcon(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'bluetooth' : (_nativeOutput==='wired' ? 'headphones' : 'speaker'); }
return meetRoute==='bt' ? 'bluetooth' : (meetRoute==='speaker' ? 'speaker' : 'speakerOff');
}
function routeLabel(){
if(iosRoute()){ return _nativeOutput==='bluetooth' ? 'Bluetooth' : (_nativeOutput==='wired' ? 'Headset' : 'Speaker'); }
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');
const onSpk = iosRoute() ? (_nativeOutput==='speaker'||_nativeOutput==='receiver'||_nativeOutput==='unknown') : (meetRoute==='speaker');
meetSpeakerOn=onSpk;
b.classList.toggle('off', !onSpk);
b.innerHTML=ic(spkIcon(),20);
b.title=routeLabel()+' — tap to switch';
}
// iOS: subscribe to the native plugin's real output route so the button icon reflects reality (BT/headset/speaker).
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(_){}
}
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output); }).catch(function(){}); }catch(_){} }
}
function bzOnNativeRoute(output){
if(!output) return;
_nativeOutput=output;
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
refreshSpkBtn();
}
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
async function detectBtOutput(){
try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; }
@@ -4530,22 +4556,27 @@ async function openAudioMenu(anchor){
// available output device. In the native mobile build this maps to the OS audio route.
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
async function toggleSpeakerphone(){
const ios = !!nativeAudioRoute();
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!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);
const bt=outs.find(d=>isBtLabel(d.label)); if(!ios) _btConnected=!!bt;
// iOS: earpiece is unreachable and JS can't see audio outputs, so it's a 2-state toggle — Speaker (force the
// loudspeaker) <-> Device ('bt' = default port, i.e. a connected BT/wired headset). The button icon reflects
// the REAL output via the native routeChange event. Other platforms keep Speaker/Earpiece/BT via setSinkId.
const routes = ios ? ['speaker','bt'] : ['speaker','earpiece'].concat(bt?['bt']:[]);
const cur = ios ? (_nativeOutput==='speaker'||_nativeOutput==='receiver'||_nativeOutput==='unknown' ? 'speaker' : 'bt') : meetRoute;
const i=routes.indexOf(cur);
meetRoute=routes[(i<0?0:(i+1)%routes.length)];
try{ localStorage.setItem('bzc_route', meetRoute); }catch(_){}
bzApplyRoute(meetRoute); // iOS native: actually switch earpiece<->speaker via AVAudioSession (setSinkId below is a no-op there)
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
else if(meetRoute==='speaker'){
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
} else { await setSpeakerDevice(''); } // earpiece → system default route
bzApplyRoute(meetRoute); // iOS native: switch loudspeaker<->default port via AVAudioSession (setSinkId below is a no-op there)
if(!ios){
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
else if(meetRoute==='speaker'){
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
} else { await setSpeakerDevice(''); } // earpiece → system default route
}
refreshSpkBtn();
toast(routeLabel());
toast(ios ? (meetRoute==='speaker'?'Speaker':'Headset / default') : routeLabel());
}
function addTile(id, stream, label, muted){
const grid=document.getElementById('meetGrid'); if(!grid) return;