Files
BizGaze_Remote/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift
T

86 lines
4.1 KiB
Swift
Raw Normal View History

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()])
}
}