diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index cc8e54b..bd0f3d6 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -2,9 +2,19 @@ import Foundation import Capacitor import AVFoundation -// Earpiece <-> speaker toggle for calls. Registered by cap sync as a normal Capacitor plugin package, so it -// appears as window.Capacitor.Plugins.AudioRoute (unlike an app-embedded class, which gets stripped/not -// discovered in release builds). setSpeaker({on}) overrides the AVAudioSession output port. +// Earpiece <-> speaker toggle for calls. 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). +// +// WHY THIS SHAPE (earlier version left the earpiece silent): +// * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means +// the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker and the earpiece +// never receives audio. +// * setSpeaker flips ONLY overrideOutputAudioPort. It must NOT re-setCategory/setActive mid-call: doing so +// tears down the audio unit WebKit's WebRTC engine is actively using and leaves the earpiece route silent +// (forcing .speaker is a strong enough override to survive it, which is why only speaker "worked"). +// * WebKit reconfigures the shared AVAudioSession when call audio starts (and on headset plug/unplug), which +// would clobber our choice — so we observe routeChangeNotification and re-assert the desired port. That +// also removes the "call starts on speaker then drops to silence" flicker. @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -13,21 +23,56 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise) ] + // Desired output — the source of truth, re-applied on every route change. Defaults to earpiece; the JS + // side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker'). + private var wantSpeaker = false + override public func load() { - // Default calls to the loudspeaker when the plugin initialises (iOS uses the quiet earpiece otherwise). let session = AVAudioSession.sharedInstance() - try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) + // NO .defaultToSpeaker — the output port is driven explicitly (see note above). + try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) try? session.setActive(true) + NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)), + name: AVAudioSession.routeChangeNotification, object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // WebKit/WebRTC reset the route (call audio just started, or a headset was (un)plugged). Re-assert ours, + // but ignore the change we caused ourselves (.override) so we don't fight in a feedback loop. + @objc private func routeChanged(_ note: Notification) { + if let info = note.userInfo, + let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: raw), + reason == .override { + return + } + applyRoute() + } + + // Nudge the port toward the desired output, but only when it actually differs — this leaves an attached + // Bluetooth/wired headset alone in the earpiece case (its route is neither builtInSpeaker nor forced). + private func applyRoute() { + let session = AVAudioSession.sharedInstance() + let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } + do { + if wantSpeaker && !onSpeaker { + try session.overrideOutputAudioPort(.speaker) + } else if !wantSpeaker && onSpeaker { + try session.overrideOutputAudioPort(.none) + } + } catch { } } @objc func setSpeaker(_ call: CAPPluginCall) { - let on = call.getBool("on") ?? true + wantSpeaker = call.getBool("on") ?? true let session = AVAudioSession.sharedInstance() do { - try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) - try session.setActive(true) - try session.overrideOutputAudioPort(on ? .speaker : .none) - call.resolve(["route": on ? "speaker" : "earpiece"]) + // Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio. + try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) + call.resolve(["route": wantSpeaker ? "speaker" : "earpiece"]) } catch { call.reject(error.localizedDescription) } diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index 2699743..f9e50ff 100644 --- a/mobile/plugins/audio-route/package.json +++ b/mobile/plugins/audio-route/package.json @@ -1,6 +1,6 @@ { "name": "audio-route", - "version": "1.0.0", + "version": "1.0.1", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js",