44526e1ee9
v1.1.1's route observer re-forced speaker on every change, and WebKit re-added Bluetooth each time -> the audio flapped speaker<->BT many times/sec (telemetry: dozens of route flips from 3 taps), which read as 'sound doesn't switch'. WKWebView won't let the app hold the built-in speaker over an active BT device. So: one override per tap, observer only REPORTS the output (no fighting). Also stop dimming the iOS button (it's a live output indicator, not on/off; the dim read as 'disabled' on BT). Probe now carries the native marker so we can confirm the binary. plugin v1.1.2-stable, web batch150. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
4.1 KiB
Swift
90 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).
|
|
//
|
|
// KNOWN CEILING (WKWebView): the app does NOT own the AVAudioSession — WebKit's media process does, and it
|
|
// re-asserts its own category (Bluetooth allowed) on every change. So the earpiece can't be forced, and the
|
|
// built-in speaker can't be held over an actively-connected Bluetooth device (a re-force just oscillates with
|
|
// WebKit — proven on device). This plugin therefore does a SINGLE override per user tap and does NOT fight
|
|
// route changes; it only reports the active output so the web UI can show the correct icon.
|
|
// * setSpeaker(true) -> try to force the loudspeaker (drops Bluetooth options + overrideOutputAudioPort(.speaker))
|
|
// * setSpeaker(false) -> "Device": allow BT/wired and use the default port (routes to a connected headset)
|
|
// * getRoute() + 'routeChange' event -> the active output ('speaker'|'bluetooth'|'wired'|'receiver'|'airplay')
|
|
@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 static let nativeTag = "1.1.2-stable"
|
|
private var pending: DispatchWorkItem?
|
|
|
|
override public func load() {
|
|
try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call
|
|
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged),
|
|
name: AVAudioSession.routeChangeNotification, object: nil)
|
|
}
|
|
|
|
deinit { NotificationCenter.default.removeObserver(self) }
|
|
|
|
private func configureSpeaker() throws {
|
|
let s = AVAudioSession.sharedInstance()
|
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
|
|
try s.setActive(true)
|
|
try s.overrideOutputAudioPort(.speaker)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
// Report the active output to the web UI. Deliberately does NOT re-force any route — fighting WebKit's
|
|
// re-assertion just oscillates the audio (see ceiling note above).
|
|
@objc private func routeChanged() {
|
|
pending?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self = self else { return }
|
|
self.notifyListeners("routeChange", data: ["output": self.currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
|
}
|
|
pending = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
|
}
|
|
|
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
|
let on = call.getBool("on") ?? true
|
|
do {
|
|
if on { try configureSpeaker() } else { try configureDevice() }
|
|
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
|
} catch {
|
|
call.reject(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
@objc func getRoute(_ call: CAPPluginCall) {
|
|
call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag])
|
|
}
|
|
}
|