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
|
2026-07-22 11:01:06 +05:30
|
|
|
// loudspeaker. So this plugin exposes only what iOS actually allows for call audio:
|
|
|
|
|
// * setSpeaker(true) -> force the loudspeaker. IMPORTANT: overrideOutputAudioPort(.speaker) ALONE cannot
|
|
|
|
|
// beat a connected Bluetooth headset (BT is higher priority), so we also DROP the
|
|
|
|
|
// Bluetooth options from the category (options [.defaultToSpeaker]) so BT isn't a
|
|
|
|
|
// candidate output — that's what actually forces the speaker over BT.
|
|
|
|
|
// * setSpeaker(false) -> "Device": allow BT/wired again and use the default port, which routes to a connected
|
|
|
|
|
// headset (else the system default).
|
2026-07-21 23:43:07 +05:30
|
|
|
// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio
|
2026-07-22 11:01:06 +05:30
|
|
|
// outputs from JavaScript, 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-22 11:01:06 +05:30
|
|
|
try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call
|
2026-07-21 23:43:07 +05:30
|
|
|
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-22 11:01:06 +05:30
|
|
|
// Force the loudspeaker even over a connected BT headset: with no .allowBluetooth option, BT is not an
|
|
|
|
|
// eligible output, so the port override lands on the built-in speaker.
|
|
|
|
|
private func configureSpeaker() throws {
|
|
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
|
|
|
|
|
try s.setActive(true)
|
|
|
|
|
try s.overrideOutputAudioPort(.speaker)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Allow BT/wired and use the default port (routes to a connected headset, else the system default).
|
|
|
|
|
private func configureDevice() throws {
|
|
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
|
|
|
try s.setActive(true)
|
|
|
|
|
try s.overrideOutputAudioPort(.none)
|
|
|
|
|
}
|
|
|
|
|
|
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() {
|
2026-07-22 11:01:06 +05:30
|
|
|
// Coalesce the burst iOS fires on device (dis)connect, then re-hold the loudspeaker if the user chose
|
|
|
|
|
// it (a BT connect can steal the route), and tell the web UI where the audio actually is.
|
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 }
|
2026-07-22 11:01:06 +05:30
|
|
|
let onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
|
|
|
|
if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() }
|
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-20 17:06:16 +05:30
|
|
|
do {
|
2026-07-22 11:01:06 +05:30
|
|
|
if wantSpeaker { try configureSpeaker() } else { try configureDevice() }
|
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
|
|
|
}
|