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:
@@ -2,145 +2,84 @@ import Foundation
|
|||||||
import Capacitor
|
import Capacitor
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a real Capacitor plugin package, so it
|
// Call-audio routing for the iOS app. Registered by cap sync as a real Capacitor plugin package, so it shows
|
||||||
// shows up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
// up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
|
||||||
//
|
//
|
||||||
// STATE OF THE INVESTIGATION (from device telemetry):
|
// The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the
|
||||||
// * In a WKWebView the app does NOT own the AVAudioSession; WebKit's media process re-pins it while WebRTC
|
// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only
|
||||||
// capture is active. overrideOutputAudioPort is a *transient* override (Apple QA1754) that any route/category
|
// what iOS actually allows for call audio:
|
||||||
// change resets, so we must re-assert on AVAudioSession.routeChangeNotification (debounced, mismatch-only, capped).
|
// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker))
|
||||||
// * For earpiece, override(.none) reverts to the mode default — the RECEIVER only if the mode is .voiceChat.
|
// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default
|
||||||
// .videoChat implies .defaultToSpeaker => the default is the loudspeaker. So we pin .voiceChat before .none.
|
// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio
|
||||||
// * OPEN QUESTION this build answers: telemetry showed mode already == .voiceChat when earpiece still landed on
|
// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct
|
||||||
// Speaker, AND setSpeaker read the route SYNCHRONOUSLY right after the override (which can be stale). So this
|
// speaker/bluetooth/headset icon.
|
||||||
// build adds a reliable SETTLED probe: it re-reads the actual output port + mode at +0.4s and +1.2s after each
|
|
||||||
// toggle, and stamps the live mode into every route-change log line. That tells us definitively whether WebKit
|
|
||||||
// flips to .videoChat and where the route truly settles — i.e. whether the mode re-pin works or we've hit the
|
|
||||||
// documented WKWebView ceiling (earpiece not reliably forceable on video calls).
|
|
||||||
@objc(AudioRoutePlugin)
|
@objc(AudioRoutePlugin)
|
||||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AudioRoutePlugin"
|
public let identifier = "AudioRoutePlugin"
|
||||||
public let jsName = "AudioRoute"
|
public let jsName = "AudioRoute"
|
||||||
public let pluginMethods: [CAPPluginMethod] = [
|
public let pluginMethods: [CAPPluginMethod] = [
|
||||||
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise),
|
||||||
|
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
|
||||||
]
|
]
|
||||||
|
|
||||||
// Bump on every native change so telemetry identifies the running binary unambiguously.
|
private var wantSpeaker = false
|
||||||
private static let nativeTag = "1.0.5-settle"
|
private var pending: DispatchWorkItem?
|
||||||
|
|
||||||
private var wantSpeaker = false // desired output; source of truth, re-applied on route changes
|
|
||||||
private var pending: DispatchWorkItem? // debounced re-assert
|
|
||||||
private var asserts = 0 // re-assert count this toggle (capped, anti-thrash)
|
|
||||||
private var routeLog: [String] = [] // reason->port/mode trail, returned to JS for diagnosis
|
|
||||||
|
|
||||||
private let accessoryPorts: [AVAudioSession.Port] = [
|
|
||||||
.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay
|
|
||||||
]
|
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||||
try? session.setActive(true)
|
try? s.setActive(true)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
||||||
name: AVAudioSession.routeChangeNotification, object: nil)
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit { NotificationCenter.default.removeObserver(self) }
|
deinit { NotificationCenter.default.removeObserver(self) }
|
||||||
|
|
||||||
private func reasonName(_ r: UInt) -> String {
|
// The ACTIVE output, mapped to a simple label the web UI turns into an icon.
|
||||||
switch r {
|
private func currentOutput() -> String {
|
||||||
case 1: return "newDev"; case 2: return "oldDev"; case 3: return "catChg"
|
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
|
||||||
case 4: return "override"; case 6: return "wake"; case 7: return "noRoute"
|
switch o.portType {
|
||||||
case 8: return "cfgChg"; default: return "unknown"
|
case .builtInSpeaker: return "speaker"
|
||||||
}
|
case .builtInReceiver: return "receiver"
|
||||||
}
|
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .carAudio: return "bluetooth"
|
||||||
|
case .headphones, .headsetMic, .usbAudio: return "wired"
|
||||||
private func modeShort(_ m: AVAudioSession.Mode) -> String {
|
case .airPlay: return "airplay"
|
||||||
if m == .voiceChat { return "vc" }
|
default: continue
|
||||||
if m == .videoChat { return "vid" }
|
|
||||||
if m == .default { return "def" }
|
|
||||||
return "oth"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actual output port(s) + live mode — the ground-truth snapshot.
|
|
||||||
private func snap() -> String {
|
|
||||||
let s = AVAudioSession.sharedInstance()
|
|
||||||
let outs = s.currentRoute.outputs.map { $0.portType.rawValue }
|
|
||||||
return (outs.isEmpty ? "(none)" : outs.joined(separator: "+")) + "/" + modeShort(s.mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func log(_ s: String) {
|
|
||||||
routeLog.append(s)
|
|
||||||
if routeLog.count > 18 { routeLog.removeFirst(routeLog.count - 18) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func routeChanged(_ note: Notification) {
|
|
||||||
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
|
|
||||||
log("\(reasonName(raw))>\(snap())")
|
|
||||||
pending?.cancel()
|
|
||||||
let work = DispatchWorkItem { [weak self] in self?.applyRoute() }
|
|
||||||
pending = work
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyRoute() {
|
|
||||||
guard asserts < 6 else { log("cap"); return }
|
|
||||||
let s = AVAudioSession.sharedInstance()
|
|
||||||
let outs = s.currentRoute.outputs
|
|
||||||
let onSpeaker = outs.contains { $0.portType == .builtInSpeaker }
|
|
||||||
let onAccessory = outs.contains { accessoryPorts.contains($0.portType) }
|
|
||||||
if onAccessory && !wantSpeaker { log("skipAcc"); return }
|
|
||||||
do {
|
|
||||||
if wantSpeaker {
|
|
||||||
if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") }
|
|
||||||
} else if onSpeaker {
|
|
||||||
asserts += 1
|
|
||||||
// .none reverts to the mode default; make that the RECEIVER by pinning .voiceChat first.
|
|
||||||
if s.category != .playAndRecord {
|
|
||||||
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
||||||
log("repinCat")
|
|
||||||
} else if s.mode != .voiceChat {
|
|
||||||
try s.setMode(.voiceChat); log("repinMode")
|
|
||||||
}
|
|
||||||
try s.overrideOutputAudioPort(.none)
|
|
||||||
log("assert>rcv:\(snap())")
|
|
||||||
}
|
}
|
||||||
} catch { log("assertErr") }
|
}
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-read the SETTLED route a beat after the override, since the immediate read can be stale.
|
@objc private func routeChanged() {
|
||||||
private func scheduleSettleProbe() {
|
// Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.log("s4>\(self?.snap() ?? "")") }
|
// chose it, and tell the web UI where the audio actually is so the icon stays correct.
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.log("s12>\(self?.snap() ?? "")") }
|
pending?.cancel()
|
||||||
}
|
let work = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
private func diag(_ applied: String) -> [String: Any] {
|
let s = AVAudioSession.sharedInstance()
|
||||||
let s = AVAudioSession.sharedInstance()
|
if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) {
|
||||||
return [
|
try? s.overrideOutputAudioPort(.speaker)
|
||||||
"route": applied,
|
}
|
||||||
"native": AudioRoutePlugin.nativeTag,
|
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
||||||
"outputs": snap(),
|
}
|
||||||
"category": s.category.rawValue,
|
pending = work
|
||||||
"opts": Int(s.categoryOptions.rawValue),
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
||||||
"log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
wantSpeaker = call.getBool("on") ?? true
|
wantSpeaker = call.getBool("on") ?? true
|
||||||
asserts = 0
|
|
||||||
let s = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat {
|
// "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on.
|
||||||
try s.setMode(.voiceChat)
|
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) }
|
||||||
}
|
|
||||||
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
||||||
scheduleSettleProbe() // settled reads land in the NEXT toggle's trail
|
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
||||||
let out = diag(wantSpeaker ? "speaker" : "earpiece")
|
|
||||||
routeLog.removeAll()
|
|
||||||
call.resolve(out)
|
|
||||||
} catch {
|
} catch {
|
||||||
call.reject(error.localizedDescription)
|
call.reject(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func getRoute(_ call: CAPPluginCall) {
|
||||||
|
call.resolve(["output": currentOutput()])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-route",
|
"name": "audio-route",
|
||||||
"version": "1.0.5",
|
"version": "1.1.0",
|
||||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||||
"main": "dist/plugin.cjs.js",
|
"main": "dist/plugin.cjs.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|||||||
+49
-18
@@ -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-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.
|
// 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
|
||||||
@@ -4375,7 +4375,7 @@ function bzApplyRoute(route){
|
|||||||
// WKWebView — WebKit owns the WebRTC audio unit and forces the speaker (proven: overrideOutputAudioPort(.none)
|
// 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
|
// 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.
|
// 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
|
// The speaker toggle is usable if the browser supports output switching (setSinkId) OR the native iOS route
|
||||||
// plugin is present.
|
// 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.
|
// needed on mobile. meetRoute is the source of truth; meetSpeakerOn stays in sync for the lite/off styling.
|
||||||
let _btConnected=false;
|
let _btConnected=false;
|
||||||
let meetRoute=(()=>{ try{ return localStorage.getItem('bzc_route')||'speaker'; }catch(_){ return 'speaker'; } })(); // 'speaker'|'earpiece'|'bt'
|
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'); }
|
// iOS only: the real active output as reported by the native plugin ('speaker'|'bluetooth'|'wired'|'receiver'|
|
||||||
function routeLabel(){ return meetRoute==='bt' ? 'Bluetooth / headset' : (meetRoute==='speaker' ? 'Speaker' : 'Earpiece'); }
|
// '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(){
|
function refreshSpkBtn(){
|
||||||
const b=document.getElementById('meetSpkBtn'); if(!b) return;
|
const b=document.getElementById('meetSpkBtn'); if(!b) return;
|
||||||
if(meetRoute==='earpiece' && nativeAudioRoute()) meetRoute='speaker'; // iOS: earpiece is unreachable in the WebView; coerce stale prefs
|
if(meetRoute==='earpiece' && nativeAudioRoute()) meetRoute='speaker'; // iOS: earpiece is unreachable in the WebView; coerce stale prefs
|
||||||
meetSpeakerOn=(meetRoute==='speaker');
|
const onSpk = iosRoute() ? (_nativeOutput==='speaker'||_nativeOutput==='receiver'||_nativeOutput==='unknown') : (meetRoute==='speaker');
|
||||||
b.classList.toggle('off', meetRoute!=='speaker');
|
meetSpeakerOn=onSpk;
|
||||||
|
b.classList.toggle('off', !onSpk);
|
||||||
b.innerHTML=ic(spkIcon(),20);
|
b.innerHTML=ic(spkIcon(),20);
|
||||||
b.title=routeLabel()+' — tap to switch';
|
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).
|
// Detect a connected BT/headset output so the cycle can include it (and the icon can show it).
|
||||||
async function detectBtOutput(){
|
async function detectBtOutput(){
|
||||||
try{ const d=await navigator.mediaDevices.enumerateDevices(); _btConnected=d.some(x=>x.kind==='audiooutput' && isBtLabel(x.label)); }catch(_){ _btConnected=false; }
|
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.
|
// 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.
|
// 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();
|
||||||
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)); _btConnected=!!bt;
|
const bt=outs.find(d=>isBtLabel(d.label)); if(!ios) _btConnected=!!bt;
|
||||||
// iOS WKWebView cannot route call audio to the built-in earpiece (WebKit forces the speaker), so drop it from
|
// iOS: earpiece is unreachable and JS can't see audio outputs, so it's a 2-state toggle — Speaker (force the
|
||||||
// the cycle there: Speaker <-> Bluetooth/headset only. Other platforms keep the earpiece option.
|
// loudspeaker) <-> Device ('bt' = default port, i.e. a connected BT/wired headset). The button icon reflects
|
||||||
const routes=['speaker'].concat(nativeAudioRoute()?[]:['earpiece']).concat(bt?['bt']:[]);
|
// the REAL output via the native routeChange event. Other platforms keep Speaker/Earpiece/BT via setSinkId.
|
||||||
const i=routes.indexOf(meetRoute);
|
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(_){}
|
||||||
bzApplyRoute(meetRoute); // iOS native: actually switch earpiece<->speaker via AVAudioSession (setSinkId below is a no-op there)
|
bzApplyRoute(meetRoute); // iOS native: switch loudspeaker<->default port via AVAudioSession (setSinkId below is a no-op there)
|
||||||
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
if(!ios){
|
||||||
else if(meetRoute==='speaker'){
|
if(meetRoute==='bt' && bt) await setSpeakerDevice(bt.deviceId);
|
||||||
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
|
else if(meetRoute==='speaker'){
|
||||||
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
|
const spk=outs.find(d=>/speaker/i.test(d.label)) || outs.find(d=>d.deviceId==='default') || (outs[0]&&outs[0].deviceId);
|
||||||
} else { await setSpeakerDevice(''); } // earpiece → system default route
|
await setSpeakerDevice(typeof spk==='string'?spk:(spk&&spk.deviceId)||'');
|
||||||
|
} else { await setSpeakerDevice(''); } // earpiece → system default route
|
||||||
|
}
|
||||||
refreshSpkBtn();
|
refreshSpkBtn();
|
||||||
toast(routeLabel());
|
toast(ios ? (meetRoute==='speaker'?'Speaker':'Headset / default') : 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