2026-07-20 17:06:16 +05:30
|
|
|
import Foundation
|
|
|
|
|
import Capacitor
|
|
|
|
|
import AVFoundation
|
|
|
|
|
|
2026-07-21 23:43:07 +05:30
|
|
|
// 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).
|
2026-07-21 09:10:09 +05:30
|
|
|
//
|
2026-07-21 23:43:07 +05:30
|
|
|
// 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.
|
2026-07-20 17:06:16 +05:30
|
|
|
@objc(AudioRoutePlugin)
|
|
|
|
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
|
|
public let identifier = "AudioRoutePlugin"
|
|
|
|
|
public let jsName = "AudioRoute"
|
|
|
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
2026-07-21 23:43:07 +05:30
|
|
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise),
|
|
|
|
|
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
|
2026-07-20 17:06:16 +05:30
|
|
|
]
|
|
|
|
|
|
2026-07-21 23:43:07 +05:30
|
|
|
private var wantSpeaker = false
|
|
|
|
|
private var pending: DispatchWorkItem?
|
2026-07-21 22:31:14 +05:30
|
|
|
|
2026-07-20 17:06:16 +05:30
|
|
|
override public func load() {
|
2026-07-21 23:43:07 +05:30
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
|
|
|
try? s.setActive(true)
|
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
2026-07-21 09:10:09 +05:30
|
|
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:20:29 +05:30
|
|
|
deinit { NotificationCenter.default.removeObserver(self) }
|
|
|
|
|
|
2026-07-21 23:43:07 +05:30
|
|
|
// 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
|
|
|
|
|
}
|
2026-07-21 22:20:29 +05:30
|
|
|
}
|
2026-07-21 23:43:07 +05:30
|
|
|
return "unknown"
|
2026-07-21 22:20:29 +05:30
|
|
|
}
|
|
|
|
|
|
2026-07-21 23:43:07 +05:30
|
|
|
@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.
|
2026-07-21 22:20:29 +05:30
|
|
|
pending?.cancel()
|
2026-07-21 23:43:07 +05:30
|
|
|
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)
|
2026-07-21 09:10:09 +05:30
|
|
|
}
|
2026-07-21 23:43:07 +05:30
|
|
|
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
|
|
|
|
}
|
|
|
|
|
pending = work
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
2026-07-21 19:22:01 +05:30
|
|
|
}
|
|
|
|
|
|
2026-07-20 17:06:16 +05:30
|
|
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
2026-07-21 09:10:09 +05:30
|
|
|
wantSpeaker = call.getBool("on") ?? true
|
2026-07-21 22:31:14 +05:30
|
|
|
let s = AVAudioSession.sharedInstance()
|
2026-07-20 17:06:16 +05:30
|
|
|
do {
|
2026-07-21 23:43:07 +05:30
|
|
|
// "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) }
|
2026-07-21 22:31:14 +05:30
|
|
|
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
2026-07-21 23:43:07 +05:30
|
|
|
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
2026-07-20 17:06:16 +05:30
|
|
|
} catch {
|
|
|
|
|
call.reject(error.localizedDescription)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-21 23:43:07 +05:30
|
|
|
|
|
|
|
|
@objc func getRoute(_ call: CAPPluginCall) {
|
|
|
|
|
call.resolve(["output": currentOutput()])
|
|
|
|
|
}
|
2026-07-20 17:06:16 +05:30
|
|
|
}
|