Files
BizGaze_Remote/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift
T
Sravan c3b0ef560d 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>
2026-07-21 23:43:07 +05:30

86 lines
4.1 KiB
Swift

import Foundation
import Capacitor
import AVFoundation
// Call-audio routing for the iOS app. Registered by cap sync as a real Capacitor plugin package, so it shows
// up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds).
//
// The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the
// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only
// what iOS actually allows for call audio:
// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker))
// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default
// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio
// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct
// speaker/bluetooth/headset icon.
@objc(AudioRoutePlugin)
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AudioRoutePlugin"
public let jsName = "AudioRoute"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
]
private var wantSpeaker = false
private var pending: DispatchWorkItem?
override public func load() {
let s = AVAudioSession.sharedInstance()
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
try? s.setActive(true)
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
name: AVAudioSession.routeChangeNotification, object: nil)
}
deinit { NotificationCenter.default.removeObserver(self) }
// The ACTIVE output, mapped to a simple label the web UI turns into an icon.
private func currentOutput() -> String {
for o in AVAudioSession.sharedInstance().currentRoute.outputs {
switch o.portType {
case .builtInSpeaker: return "speaker"
case .builtInReceiver: return "receiver"
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .carAudio: return "bluetooth"
case .headphones, .headsetMic, .usbAudio: return "wired"
case .airPlay: return "airplay"
default: continue
}
}
return "unknown"
}
@objc private func routeChanged() {
// Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user
// chose it, and tell the web UI where the audio actually is so the icon stays correct.
pending?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self = self else { return }
let s = AVAudioSession.sharedInstance()
if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) {
try? s.overrideOutputAudioPort(.speaker)
}
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
}
pending = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
}
@objc func setSpeaker(_ call: CAPPluginCall) {
wantSpeaker = call.getBool("on") ?? true
let s = AVAudioSession.sharedInstance()
do {
// "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on.
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) }
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
} catch {
call.reject(error.localizedDescription)
}
}
@objc func getRoute(_ call: CAPPluginCall) {
call.resolve(["output": currentOutput()])
}
}