diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index a9d0d6c1d9..99851f4be8 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -179,7 +179,7 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { if playback { if AVAudioSession.sharedInstance().category != .playback { logger.log("AudioSession: playback") - try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: .duckOthers) + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, options: [.duckOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) } } else { if AVAudioSession.sharedInstance().category != .soloAmbient { diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index d557d51182..97415018bf 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -110,8 +110,8 @@ struct ActiveCallView: View { call.callState = .invitationSent call.localCapabilities = capabilities } - if call.supportsVideo { - try? AVAudioSession.sharedInstance().setCategory(.playback, options: .defaultToSpeaker) + if call.supportsVideo && !AVAudioSession.sharedInstance().hasExternalAudioDevice() { + try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) } CallSoundsPlayer.shared.startConnectingCallSound() activeCallWaitDeliveryReceipt() @@ -235,6 +235,7 @@ struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call var client: WebRTCClient + @ObservedObject private var deviceManager = CallAudioDeviceManager.shared var body: some View { VStack { @@ -250,7 +251,16 @@ struct ActiveCallOverlay: View { HStack { toggleAudioButton() Spacer() - Color.clear.frame(width: 40, height: 40) + if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) { + toggleSpeakerButton() + .frame(width: 40, height: 40) + } else if call.hasMedia { + AudioDevicePicker() + .scaleEffect(2) + .frame(maxWidth: 40, maxHeight: 40) + } else { + Color.clear.frame(width: 40, height: 40) + } Spacer() endCallButton() Spacer() @@ -291,14 +301,33 @@ struct ActiveCallOverlay: View { toggleAudioButton() .frame(maxWidth: .infinity, alignment: .leading) endCallButton() - toggleSpeakerButton() - .frame(maxWidth: .infinity, alignment: .trailing) + // Check if the only input is microphone. And in this case show toggle button, + // If there are more inputs, it probably means something like bluetooth headphones are available + // and in this case show iOS button for choosing different output. + // There is no way to get available outputs, only inputs + if deviceManager.availableInputs.allSatisfy({ $0.portType == .builtInMic }) { + toggleSpeakerButton() + .frame(maxWidth: .infinity, alignment: .trailing) + } else if call.hasMedia { + AudioDevicePicker() + .scaleEffect(2) + .frame(maxWidth: 50, maxHeight: 40) + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + Color.clear.frame(width: 50, height: 40) + } } .padding(.bottom, 60) .padding(.horizontal, 48) } } .frame(maxWidth: .infinity) + .onAppear { + deviceManager.start() + } + .onDisappear { + deviceManager.stop() + } } private func audioCallInfoView(_ call: Call) -> some View { @@ -376,12 +405,17 @@ struct ActiveCallOverlay: View { private func toggleSpeakerButton() -> some View { controlButton(call, call.speakerEnabled ? "speaker.wave.2.fill" : "speaker.wave.1.fill") { Task { - client.setSpeakerEnabledAndConfigureSession(!call.speakerEnabled) + let speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker + client.setSpeakerEnabledAndConfigureSession(!speakerEnabled) DispatchQueue.main.async { - call.speakerEnabled = !call.speakerEnabled + call.speakerEnabled = !speakerEnabled } } } + .onAppear { + deviceManager.call = call + //call.speakerEnabled = AVAudioSession.sharedInstance().currentRoute.outputs.first?.portType == .builtInSpeaker + } } private func toggleVideoButton() -> some View { diff --git a/apps/ios/Shared/Views/Call/AudioDevicePicker.swift b/apps/ios/Shared/Views/Call/AudioDevicePicker.swift new file mode 100644 index 0000000000..3d846c7b68 --- /dev/null +++ b/apps/ios/Shared/Views/Call/AudioDevicePicker.swift @@ -0,0 +1,25 @@ +// +// MPVolumeView.swift +// SimpleX (iOS) +// +// Created by Stanislav on 24.04.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI +import UIKit +import AVKit + +struct AudioDevicePicker: UIViewRepresentable { + func makeUIView(context: Context) -> some UIView { + let v = AVRoutePickerView(frame: .zero) + v.activeTintColor = .white + v.tintColor = .white + return v + } + + func updateUIView(_ uiView: UIViewType, context: Context) { + + } +} diff --git a/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift b/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift new file mode 100644 index 0000000000..d56849d16a --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift @@ -0,0 +1,67 @@ +// +// CallAudioDeviceManager.swift +// SimpleX (iOS) +// +// Created by Avently on 23.04.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI +import SimpleXChat +import AVKit +import WebRTC + +class CallAudioDeviceManager: ObservableObject { + static let shared = CallAudioDeviceManager() + let audioSession: AVAudioSession + let nc = NotificationCenter.default + + var call: Call? + var timer: Timer? = nil + + // Actually, only one output + @Published var outputs: [AVAudioSessionPortDescription] + @Published var currentDevice: AVAudioSessionPortDescription? = nil + // All devices that can record audio (the ones that can play audio are not included) + @Published var availableInputs: [AVAudioSessionPortDescription] = [] + + + init(_ audioSession: AVAudioSession? = nil) { + self.audioSession = audioSession ?? RTCAudioSession.sharedInstance().session + self.outputs = self.audioSession.currentRoute.outputs + self.availableInputs = self.audioSession.availableInputs ?? [] + } + + func reloadDevices() { + outputs = audioSession.currentRoute.outputs + currentDevice = audioSession.currentRoute.outputs.first + availableInputs = audioSession.availableInputs ?? [] + call?.speakerEnabled = currentDevice?.portType == .builtInSpeaker + + + // Workaround situation: + // have bluetooth device connected, choosing speaker, disconnecting bluetooth device. In this case iOS will not post notification, so do it manually + timer?.invalidate() + if availableInputs.contains(where: { $0.portType != .builtInReceiver && $0.portType != .builtInSpeaker }) { + timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { t in + self.reloadDevices() + } + } + } + + @objc func audioCallback(notification: Notification) { + reloadDevices() + + logger.debug("Changes in devices, current audio devices: \(String(describing: self.availableInputs.map({ $0.portType.rawValue }))), output: \(String(describing: self.currentDevice?.portType.rawValue))") + } + + func start() { + nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil) + } + + func stop() { + nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + timer?.invalidate() + } +} diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 6da8294ef8..64b565e8e6 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -103,7 +103,23 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true do { - try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) + let supportsVideo = ChatModel.shared.activeCall?.supportsVideo == true + if supportsVideo { + try audioSession.setCategory(.playAndRecord, mode: .videoChat, options: [.defaultToSpeaker, .mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) + } else { + try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) + } + // Without any delay sound is not playing from speaker or external device in incoming call + Task { + for i in 0 ... 3 { + try? await Task.sleep(nanoseconds: UInt64(i) * 300_000000) + if let preferred = audioSession.preferredInputDevice() { + await MainActor.run { try? audioSession.setPreferredInput(preferred) } + } else if supportsVideo { + await MainActor.run { try? audioSession.overrideOutputAudioPort(.speaker) } + } + } + } logger.debug("audioSession category set") try audioSession.setActive(true) logger.debug("audioSession activated") diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index ff36241daf..0b4917c103 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -605,9 +605,23 @@ extension WebRTCClient { self.rtcAudioSession.unlockForConfiguration() } do { - try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue) - try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) - try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none) + let hasExternalAudioDevice = self.rtcAudioSession.session.hasExternalAudioDevice() + if enabled { + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.defaultToSpeaker, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) + try self.rtcAudioSession.setMode(AVAudioSession.Mode.videoChat.rawValue) + if hasExternalAudioDevice, let preferred = self.rtcAudioSession.session.preferredInputDevice() { + try self.rtcAudioSession.setPreferredInput(preferred) + } else { + try self.rtcAudioSession.overrideOutputAudioPort(.speaker) + } + } else { + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) + try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) + try self.rtcAudioSession.overrideOutputAudioPort(.none) + } + if hasExternalAudioDevice { + logger.debug("WebRTCClient: configuring session with external device available, skip configuring speaker") + } try self.rtcAudioSession.setActive(true) logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") } catch let error { @@ -658,6 +672,17 @@ extension WebRTCClient { } } +extension AVAudioSession { + func hasExternalAudioDevice() -> Bool { + availableInputs?.allSatisfy({ $0.portType == .builtInMic }) != true + } + + func preferredInputDevice() -> AVAudioSessionPortDescription? { +// logger.debug("Preferred input device: \(String(describing: self.availableInputs?.filter({ $0.portType != .builtInMic })))") + return availableInputs?.filter({ $0.portType != .builtInMic }).last + } +} + struct CustomRTCSessionDescription: Codable { public var type: RTCSdpType? public var sdp: String? diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index feeea69376..6511b67ae9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -188,6 +188,8 @@ 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */; }; 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */; }; + 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */; }; + 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; @@ -483,6 +485,8 @@ 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToDevice.swift; sourceTree = ""; }; 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromDevice.swift; sourceTree = ""; }; + 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDevicePicker.swift; sourceTree = ""; }; + 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -549,6 +553,8 @@ 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */, 18415323A4082FC92887F906 /* WebRTCClient.swift */, 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */, + 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */, + 8C81482B2BD91CD4002CBEC3 /* AudioDevicePicker.swift */, ); path = Call; sourceTree = ""; @@ -1237,6 +1243,7 @@ 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */, + 8C81482C2BD91CD4002CBEC3 /* AudioDevicePicker.swift in Sources */, 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */, 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */, 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, @@ -1266,6 +1273,7 @@ 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, + 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */,