feat(ios audio): final UX — auto-route + live indicator (no impossible toggle)
Telemetry confirmed the ceiling: a single speaker override holds ~1s over active BT then WebKit reverts. So stop trying to control the route on iOS — always use the default port (iOS auto-routes: BT/wired if connected, else loudspeaker), and make the button a live INDICATOR of the real output; tapping shows a toast (connect/disconnect a headset to change). Removes the temp nroute/sptap probes. Pure web change; plugin v1.1.2 already supports it. build batch151. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-22
@@ -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-22-batch150';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
<script>window.__BUILD='2026-07-22-batch151';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
|
||||||
@@ -4370,12 +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 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){
|
function bzApplyRoute(route){
|
||||||
var ar=nativeAudioRoute();
|
var ar=nativeAudioRoute();
|
||||||
// iOS native only: 'speaker' forces the loudspeaker; anything else (bt / wired headset) uses the default
|
// iOS: the WKWebView owns call audio and auto-routes it — a connected Bluetooth/wired headset, else the
|
||||||
// output port, which reverts to a connected accessory. The built-in EARPIECE is not reachable inside a
|
// loudspeaker. PROVEN on device: the app can force NEITHER the earpiece NOR the loudspeaker-over-active-BT
|
||||||
// WKWebView — WebKit owns the WebRTC audio unit and forces the speaker (proven: overrideOutputAudioPort(.none)
|
// (WebKit re-asserts within ~1s). So we don't fight it: always use the default port (setSpeaker(false)) and
|
||||||
// is a no-op there), so the iOS route cycle never offers 'earpiece'. Native-grade earpiece would require a
|
// let iOS pick; the button is a live INDICATOR of the actual output. Native-grade routing (earpiece, hold
|
||||||
// native WebRTC stack (e.g. the LiveKit iOS SDK) instead of the webview.
|
// speaker over BT, CallKit) would require a native WebRTC stack (e.g. the LiveKit iOS SDK), not the webview.
|
||||||
if(ar){ bzInitNativeRoute(); try{ ar.setSpeaker({ on: route==='speaker' }); }catch(_){} }
|
if(ar){ bzInitNativeRoute(); try{ ar.setSpeaker({ on:false }); }catch(_){} }
|
||||||
}
|
}
|
||||||
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
|
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
|
||||||
// plugin is present.
|
// plugin is present.
|
||||||
@@ -4455,14 +4455,13 @@ let _bzNativeRouteReady=false;
|
|||||||
function bzInitNativeRoute(){
|
function bzInitNativeRoute(){
|
||||||
const ar=nativeAudioRoute(); if(!ar) return;
|
const ar=nativeAudioRoute(); if(!ar) return;
|
||||||
if(!_bzNativeRouteReady && ar.addListener){ _bzNativeRouteReady=true;
|
if(!_bzNativeRouteReady && ar.addListener){ _bzNativeRouteReady=true;
|
||||||
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output, e && e.native); }); }catch(_){}
|
try{ ar.addListener('routeChange', function(e){ bzOnNativeRoute(e && e.output); }); }catch(_){}
|
||||||
}
|
}
|
||||||
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output, r && r.native); }).catch(function(){}); }catch(_){} }
|
if(ar.getRoute){ try{ ar.getRoute().then(function(r){ bzOnNativeRoute(r && r.output); }).catch(function(){}); }catch(_){} }
|
||||||
}
|
}
|
||||||
function bzOnNativeRoute(output, nat){
|
function bzOnNativeRoute(output){
|
||||||
if(!output) return;
|
if(!output) return;
|
||||||
_nativeOutput=output;
|
_nativeOutput=output;
|
||||||
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);
|
meetRoute = (output==='bluetooth'||output==='wired') ? 'bt' : (output==='speaker' ? 'speaker' : meetRoute);
|
||||||
refreshSpkBtn();
|
refreshSpkBtn();
|
||||||
}
|
}
|
||||||
@@ -4564,19 +4563,24 @@ async function openAudioMenu(anchor){
|
|||||||
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
|
// Tap = advance to the next available route. Bluetooth is only in the cycle while a headset is connected.
|
||||||
async function toggleSpeakerphone(){
|
async function toggleSpeakerphone(){
|
||||||
const ios = !!nativeAudioRoute();
|
const ios = !!nativeAudioRoute();
|
||||||
|
// iOS: the OS owns call-audio routing (BT/wired if connected, else loudspeaker) and the app can't override it
|
||||||
|
// (earpiece and loudspeaker-over-active-BT both proven impossible in a WKWebView). So the button is a live
|
||||||
|
// INDICATOR, not a switch — tapping just explains the current output / how to change it.
|
||||||
|
if(ios){
|
||||||
|
const o=_nativeOutput;
|
||||||
|
toast(o==='bluetooth' ? 'On Bluetooth — disconnect the headset to use the speaker'
|
||||||
|
: o==='wired' ? 'On headset — unplug to use the speaker'
|
||||||
|
: 'On speaker — connect a Bluetooth/wired headset to use it');
|
||||||
|
return;
|
||||||
|
}
|
||||||
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
let outs=[]; try{ outs=(await navigator.mediaDevices.enumerateDevices()).filter(x=>x.kind==='audiooutput'); }catch(_){}
|
||||||
const bt=outs.find(d=>isBtLabel(d.label)); if(!ios) _btConnected=!!bt;
|
const bt=outs.find(d=>isBtLabel(d.label)); _btConnected=!!bt;
|
||||||
// iOS: earpiece is unreachable and JS can't see audio outputs, so it's a 2-state toggle — Speaker (force the
|
const routes = ['speaker','earpiece'].concat(bt?['bt']:[]);
|
||||||
// loudspeaker) <-> Device ('bt' = default port, i.e. a connected BT/wired headset). The button icon reflects
|
const i=routes.indexOf(meetRoute);
|
||||||
// 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)];
|
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);
|
||||||
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);
|
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
||||||
else if(meetRoute==='speaker'){
|
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);
|
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
|
||||||
@@ -4584,7 +4588,7 @@ async function toggleSpeakerphone(){
|
|||||||
} else { await setSpeakerDevice(''); } // earpiece → system default route
|
} else { await setSpeakerDevice(''); } // earpiece → system default route
|
||||||
}
|
}
|
||||||
refreshSpkBtn();
|
refreshSpkBtn();
|
||||||
toast(ios ? (meetRoute==='speaker'?'Speaker':'Headset / default') : routeLabel());
|
toast(routeLabel());
|
||||||
}
|
}
|
||||||
function addTile(id, stream, label, muted){
|
function addTile(id, stream, label, muted){
|
||||||
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
const grid=document.getElementById('meetGrid'); if(!grid) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user