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:
@@ -1,19 +1,31 @@
|
||||
// Inject an AVAudioSession "default to the loudspeaker" setup into the freshly-generated Capacitor iOS
|
||||
// AppDelegate, so calls don't route to the quiet earpiece. Run on Codemagic (macOS) from ios-patch.sh:
|
||||
// node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
|
||||
// TOLERANT by design: if the file is missing or the template doesn't match, it prints a note and exits 0
|
||||
// so it can NEVER fail the build. First-pass fix — if iOS WebRTC re-grabs the session mid-call on device,
|
||||
// we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
|
||||
// Patch the freshly-generated Capacitor iOS AppDelegate for call audio. Run on Codemagic (macOS) from
|
||||
// ios-patch.sh: 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
|
||||
// fail the build):
|
||||
// 1. At launch, set the AVAudioSession so calls DEFAULT to the loudspeaker (iOS uses the quiet earpiece).
|
||||
// 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 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 {
|
||||
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;
|
||||
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');
|
||||
const inject = [
|
||||
|
||||
// ---- imports ----
|
||||
if (!s.includes('import 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');
|
||||
}
|
||||
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).',
|
||||
' do {',
|
||||
' let audioSession = AVAudioSession.sharedInstance()',
|
||||
@@ -23,14 +35,40 @@ try {
|
||||
'',
|
||||
].join('\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 (s !== orig && s.includes('AVAudioSession')) {
|
||||
fs.writeFileSync(p, s);
|
||||
console.log(' AVAudioSession default-to-speaker injected into AppDelegate');
|
||||
} else {
|
||||
console.log(' (AppDelegate pattern not matched — AVAudioSession patch skipped)');
|
||||
}
|
||||
if (m && !s.includes('.defaultToSpeaker')) { const idx = m.index + m[0].length; s = s.slice(0, idx) + launch + s.slice(idx); }
|
||||
|
||||
// ---- 2) append the AudioRoute plugin (earpiece/speaker toggle) ----
|
||||
const plugin = [
|
||||
'',
|
||||
'// 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) {
|
||||
console.log(' (AVAudioSession patch error, skipped: ' + (e && e.message) + ')');
|
||||
console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
+10
-2
@@ -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});
|
||||
|
||||
Reference in New Issue
Block a user