c3b0ef560d
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>
86 lines
4.1 KiB
Swift
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()])
|
|
}
|
|
}
|