speaker: native AudioRoute plugin for real earpiece<->speaker toggle on iOS (batch144)

- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
  into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
  build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
  unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
  when the native plugin is present. Dormant until the next Codemagic build ships the plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 00:31:25 +05:30
parent 9c2ba847b0
commit 90d49d29d0
2 changed files with 67 additions and 21 deletions
+10 -2
View File
@@ -1158,7 +1158,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-20-batch143';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-20-batch144';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
@@ -4365,7 +4365,13 @@ function isMobileUA(){ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.
// implement — the OS owns the route there (and iOS forces loudspeaker while a mic track is live). Rather
// than ship a button that silently does nothing, we only show the speaker control where it really works.
// Real speaker/earpiece/Bluetooth switching on phones needs the native (Capacitor) build's audio plugin.
function canRouteAudio(){ try{ return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
// Native iOS AudioRoute plugin (injected into the app via ios-patch.sh) — switches earpiece<->speaker at the
// AVAudioSession level, which the web setSinkId API can't do on iOS. Null in the browser/PWA/Android.
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(); if(ar){ try{ ar.setSpeaker({ on: route!=='earpiece' }); }catch(_){} } } // speaker/bt -> loudspeaker override; earpiece -> default route (BT auto-selected if connected)
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
// plugin is present.
function canRouteAudio(){ try{ if(nativeAudioRoute()) return true; return typeof HTMLMediaElement!=='undefined' && typeof HTMLMediaElement.prototype.setSinkId==='function'; }catch(_){ return false; } }
// new #4: let the user drag a floating bar out of the way — it otherwise covers part of the shared
// screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){
@@ -4521,6 +4527,7 @@ async function toggleSpeakerphone(){
const i=routes.indexOf(meetRoute);
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);
@@ -4741,6 +4748,7 @@ function bzUnlockAudio(){
try{ if(_audioCtx && _audioCtx.state==='suspended') _audioCtx.resume(); }catch(_){}
try{ if(!_bzSilent){ _bzSilent=new Audio('data:audio/wav;base64,UklGRjIAAABXQVZFZm10IBAAAAABAAEAgLsAAAB3AQACABAAZGF0YQ4AAAAAAAAAAAAAAAAAAAAAAAA='); _bzSilent.setAttribute('playsinline',''); } _bzSilent.play().catch(function(){}); }catch(_){}
try{ document.querySelectorAll('#meetGrid video').forEach(function(v){ if(v && v.paused) v.play().catch(function(){}); }); }catch(_){}
try{ bzApplyRoute(meetRoute); }catch(_){} // re-assert the chosen earpiece/speaker route (iOS WebRTC re-grabs the session when audio starts)
}
document.addEventListener('touchend', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true}); // fallback: any tap during a call restarts silent remote audio
document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});