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 751eba80ae..00eb930d6b 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -111,7 +111,7 @@ struct ActiveCallView: View { call.localCapabilities = capabilities } if call.supportsVideo { - try? AVAudioSession.sharedInstance().setCategory(.playback, options: .defaultToSpeaker) + try? AVAudioSession.sharedInstance().setCategory(.playback, options: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) } CallSoundsPlayer.shared.startConnectingCallSound() activeCallWaitDeliveryReceipt() @@ -236,6 +236,7 @@ struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call var client: WebRTCClient + let callAudioDeviceManager = CallAudioDeviceManager() var body: some View { VStack { @@ -251,7 +252,13 @@ struct ActiveCallOverlay: View { HStack { toggleAudioButton() Spacer() - Color.clear.frame(width: 40, height: 40) + if AVAudioSession.sharedInstance().availableInputs?.allSatisfy({ $0.portType == .builtInMic }) == true { + Color.clear.frame(width: 40, height: 40) + } else { + AudioDevicePicker() + .scaleEffect(1.8) + .frame(maxWidth: 40, maxHeight: 40) + } Spacer() endCallButton() Spacer() @@ -292,14 +299,28 @@ 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 AVAudioSession.sharedInstance().availableInputs?.allSatisfy({ $0.portType == .builtInMic }) == true { + toggleSpeakerButton() + .frame(maxWidth: .infinity, alignment: .trailing) + } else { + AudioDevicePicker() + .scaleEffect(1.8) + .frame(maxWidth: 50, maxHeight: 40) + .frame(maxWidth: .infinity, alignment: .trailing) + } } .padding(.bottom, 60) .padding(.horizontal, 48) } } .frame(maxWidth: .infinity) + .onAppear { + callAudioDeviceManager.start() + } + .onDisappear { + callAudioDeviceManager.stop() + } } private func audioCallInfoView(_ call: Call) -> 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..f81defd1d1 --- /dev/null +++ b/apps/ios/Shared/Views/Call/AudioDevicePicker.swift @@ -0,0 +1,24 @@ +// +// 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 MediaPlayer + +struct AudioDevicePicker: UIViewRepresentable { + func makeUIView(context: Context) -> some UIView { + let v = MPVolumeView(frame: .zero) + v.showsVolumeSlider = false + 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..52ded5d2c8 --- /dev/null +++ b/apps/ios/Shared/Views/Call/CallAudioDeviceManager.swift @@ -0,0 +1,59 @@ +// +// 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 + +class CallAudioDeviceManager { + let audioSession: AVAudioSession + let nc = NotificationCenter.default + + @State var devices: [AVAudioSessionPortDescription] + @State var currentDevice: AVAudioSessionPortDescription? = nil + @State var availableInputs: [AVAudioSessionPortDescription] = [] + + + init(_ audioSession: AVAudioSession? = nil) { + self.audioSession = audioSession ?? AVAudioSession.sharedInstance() + self.devices = self.audioSession.currentRoute.outputs + self.availableInputs = self.audioSession.availableInputs ?? [] + } + + @objc func audioCallback(notification: Notification) { + logger.debug("LALAL CALLED") + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + switch reason { + case .newDeviceAvailable: + devices = audioSession.currentRoute.outputs + case .oldDeviceUnavailable: + devices = audioSession.currentRoute.outputs + case .override: + currentDevice = audioSession.currentRoute.outputs.first + default: () + } + self.availableInputs = audioSession.availableInputs ?? [] + logger.debug("LALAL inputs \(String(describing: self.availableInputs))") + } + + func start() { + logger.debug("LALAL START") + nc.addObserver(self, selector: #selector(audioCallback), name: AVAudioSession.routeChangeNotification, object: nil) + } + + func stop() { + logger.debug("LALAL STOP") + nc.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + } +} diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 6da8294ef8..3c3899d600 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -103,7 +103,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse RTCAudioSession.sharedInstance().audioSessionDidActivate(audioSession) RTCAudioSession.sharedInstance().isAudioEnabled = true do { - try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: .mixWithOthers) + try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.mixWithOthers, .allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) 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..f4dc8803fd 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -605,10 +605,12 @@ extension WebRTCClient { self.rtcAudioSession.unlockForConfiguration() } do { - try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue) + try self.rtcAudioSession.setCategory(AVAudioSession.Category.playAndRecord.rawValue, with: [.allowBluetooth, .allowAirPlay, .allowBluetoothA2DP]) try self.rtcAudioSession.setMode(AVAudioSession.Mode.voiceChat.rawValue) try self.rtcAudioSession.overrideOutputAudioPort(enabled ? .speaker : .none) try self.rtcAudioSession.setActive(true) + logger.debug("LALAL AUDIO in \(self.rtcAudioSession.currentRoute.inputs.description)") + logger.debug("LALAL AUDIO out \(self.rtcAudioSession.currentRoute.outputs.description)") logger.debug("WebRTCClient: configuring session with speaker enabled \(enabled) success") } catch let error { logger.debug("Error configuring AVAudioSession: \(error)") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2830c57057..aa4936680f 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 */,