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
+55 -17
View File
@@ -1,19 +1,31 @@
// Inject an AVAudioSession "default to the loudspeaker" setup into the freshly-generated Capacitor iOS // Patch the freshly-generated Capacitor iOS AppDelegate for call audio. Run on Codemagic (macOS) from
// AppDelegate, so calls don't route to the quiet earpiece. Run on Codemagic (macOS) from ios-patch.sh: // ios-patch.sh: node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
// node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift // It does two things, both TOLERANT (exits 0 and no-ops if the template doesn't match, so it can NEVER
// TOLERANT by design: if the file is missing or the template doesn't match, it prints a note and exits 0 // fail the build):
// so it can NEVER fail the build. First-pass fix — if iOS WebRTC re-grabs the session mid-call on device, // 1. At launch, set the AVAudioSession so calls DEFAULT to the loudspeaker (iOS uses the quiet earpiece).
// we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker). // 2. Append an AudioRoute Capacitor plugin (Capacitor 7 auto-registers any CAPBridgedPlugin in the app
// target) so the web can switch earpiece<->speaker at runtime via
// window.Capacitor.Plugins.AudioRoute.setSpeaker({ on: true|false }).
// If the plugin fails to register for any reason, the web call simply no-ops — it cannot crash the app.
const fs = require('fs'); const fs = require('fs');
const p = process.argv[2]; const p = process.argv[2];
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — AVAudioSession patch skipped)'); process.exit(0); } if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — audio patch skipped)'); process.exit(0); }
try { try {
let s = fs.readFileSync(p, 'utf8'); let s = fs.readFileSync(p, 'utf8');
if (s.includes('AVAudioSession')) { console.log(' AVAudioSession already patched'); process.exit(0); } if (s.includes('AudioRoutePlugin')) { console.log(' audio already patched'); process.exit(0); }
const orig = s; const orig = s;
// ---- imports ----
if (!s.includes('import AVFoundation')) {
if (s.includes('import Capacitor')) s = s.replace('import Capacitor', 'import Capacitor\nimport AVFoundation'); if (s.includes('import Capacitor')) s = s.replace('import Capacitor', 'import Capacitor\nimport AVFoundation');
else if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport AVFoundation'); else if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport AVFoundation');
const inject = [ }
if (!s.includes('import Capacitor')) {
if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport Capacitor');
}
// ---- 1) launch: default call audio to the loudspeaker ----
const launch = [
' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).', ' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).',
' do {', ' do {',
' let audioSession = AVAudioSession.sharedInstance()', ' let audioSession = AVAudioSession.sharedInstance()',
@@ -23,14 +35,40 @@ try {
'', '',
].join('\n'); ].join('\n');
const m = s.match(/func\s+application\([^)]*didFinishLaunchingWithOptions[^)]*\)\s*->\s*Bool\s*\{[^\n]*\n/); const m = s.match(/func\s+application\([^)]*didFinishLaunchingWithOptions[^)]*\)\s*->\s*Bool\s*\{[^\n]*\n/);
if (m) { const idx = m.index + m[0].length; s = s.slice(0, idx) + inject + s.slice(idx); } if (m && !s.includes('.defaultToSpeaker')) { const idx = m.index + m[0].length; s = s.slice(0, idx) + launch + s.slice(idx); }
if (s !== orig && s.includes('AVAudioSession')) {
fs.writeFileSync(p, s); // ---- 2) append the AudioRoute plugin (earpiece/speaker toggle) ----
console.log(' AVAudioSession default-to-speaker injected into AppDelegate'); const plugin = [
} else { '',
console.log(' (AppDelegate pattern not matched — AVAudioSession patch skipped)'); '// Earpiece <-> speaker toggle for calls. Capacitor 7 auto-registers any CAPBridgedPlugin in the app',
} '// target, so the web calls window.Capacitor.Plugins.AudioRoute.setSpeaker({ on: true|false }).',
'@objc(AudioRoutePlugin)',
'public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {',
' public let identifier = "AudioRoutePlugin"',
' public let jsName = "AudioRoute"',
' public let pluginMethods: [CAPPluginMethod] = [',
' CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)',
' ]',
' @objc func setSpeaker(_ call: CAPPluginCall) {',
' let on = call.getBool("on") ?? true',
' let session = AVAudioSession.sharedInstance()',
' do {',
' try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])',
' try session.setActive(true)',
' try session.overrideOutputAudioPort(on ? .speaker : .none)',
' call.resolve(["route": on ? "speaker" : "earpiece"])',
' } catch {',
' call.reject(error.localizedDescription)',
' }',
' }',
'}',
'',
].join('\n');
s = s + plugin;
if (s !== orig) { fs.writeFileSync(p, s); console.log(' AVAudioSession default-to-speaker + AudioRoute plugin injected'); }
else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); }
} catch (e) { } catch (e) {
console.log(' (AVAudioSession patch error, skipped: ' + (e && e.message) + ')'); console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
} }
process.exit(0); process.exit(0);
+10 -2
View File
@@ -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-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. // 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
@@ -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 // 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. // 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. // 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 // 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. // screen. Drag from anywhere on the bar except a control. Position is remembered per bar.
function makeDraggable(el, key){ function makeDraggable(el, key){
@@ -4521,6 +4527,7 @@ async function toggleSpeakerphone(){
const i=routes.indexOf(meetRoute); const i=routes.indexOf(meetRoute);
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(_){}
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); 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);
@@ -4741,6 +4748,7 @@ function bzUnlockAudio(){
try{ if(_audioCtx && _audioCtx.state==='suspended') _audioCtx.resume(); }catch(_){} 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{ 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{ 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('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}); document.addEventListener('click', function(){ if(meetState==='call') bzUnlockAudio(); }, {passive:true});