diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 6d88f64..6961d8a 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -93,6 +93,16 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega Task { await r?.disconnect() } } + // Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected + // wired/Bluetooth headset (any non-receiver route) still wins. Re-asserted on mute toggles because enabling + // the mic can flip the route back to the earpiece. + private func preferSpeaker() { + let s = AVAudioSession.sharedInstance() + if s.currentRoute.outputs.contains(where: { $0.portType == .builtInReceiver }) { + try? s.overrideOutputAudioPort(.speaker) + } + } + // MARK: - JS-callable methods @objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) } @@ -285,7 +295,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { let r = room - Task { try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted) } + Task { [weak self] in + try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted) + self?.preferSpeaker() // toggling the mic can flip the route back to the earpiece — re-assert speaker + } notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted]) action.fulfill() } @@ -295,9 +308,14 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega // dead mic / no-audio and late speaker routing. Don't call setActive(true): CallKit already activated it. public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { do { - try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers]) + // Route to the LOUDSPEAKER by default (earpiece was the .voiceChat default). .videoChat + + // .defaultToSpeaker prefers the speaker while still letting wired/Bluetooth headsets win. + try audioSession.setCategory(.playAndRecord, mode: .videoChat, + options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) try AudioManager.shared.setEngineAvailability(.default) - notifyListeners("audioActivated", data: ["ok": true]) + preferSpeaker() // belt-and-braces: if we still landed on the earpiece, force the speaker + let route = audioSession.currentRoute.outputs.first?.portType.rawValue ?? "none" + notifyListeners("audioActivated", data: ["ok": true, "route": route]) } catch { notifyListeners("audioActivated", data: ["ok": false, "error": String(describing: error)]) } diff --git a/server/public/home.html b/server/public/home.html index b432275..2afd7a9 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -3815,7 +3815,7 @@ async function setupNativeCall(){ // Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces // for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button. NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} } }); - NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'' }); }); // CallKit audio-session activation (debug intermittent audio) + NC.addListener('audioActivated', (e)=>{ pdbg('nc-audio', { ok:!!(e&&e.ok), err:(e&&e.error)||'', route:(e&&e.route)||'' }); }); // CallKit audio-session activation + output route (debug audio) NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); }); // Muted from the CallKit system UI → reflect it in the meeting window's mic button. NC.addListener('setMuted', (e)=>{ if(!e||meetState!=='call'||!meetNative) return; meetMic=!e.muted; try{ updateMicBtn(); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); }catch(_){} });