diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java index e1af0a779..45ebdfe8f 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java @@ -256,6 +256,11 @@ class AudioModeModule extends ReactContextBaseJavaModule private static final String DEVICE_HEADPHONES = "HEADPHONES"; private static final String DEVICE_SPEAKER = "SPEAKER"; + /** + * Device change event. + */ + private static final String DEVICE_CHANGE_EVENT = "org.jitsi.meet:features/audio-mode#devices-update"; + /** * List of currently available audio devices. */ @@ -303,7 +308,7 @@ class AudioModeModule extends ReactContextBaseJavaModule if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // Do an initial detection on Android >= M. - runInAudioThread(onAudioDeviceChangeRunner); + onAudioDeviceChange(); } else { // On Android < M, detect if we have an earpiece. PackageManager pm = reactContext.getPackageManager(); @@ -327,6 +332,7 @@ class AudioModeModule extends ReactContextBaseJavaModule public Map getConstants() { Map constants = new HashMap<>(); + constants.put("DEVICE_CHANGE_EVENT", DEVICE_CHANGE_EVENT); constants.put("AUDIO_CALL", AUDIO_CALL); constants.put("DEFAULT", DEFAULT); constants.put("VIDEO_CALL", VIDEO_CALL); @@ -335,31 +341,26 @@ class AudioModeModule extends ReactContextBaseJavaModule } /** - * Gets the list of available audio device categories, i.e. 'bluetooth', - * 'earpiece ', 'speaker', 'headphones'. - * - * @param promise a {@link Promise} which will be resolved with an object - * containing a 'devices' key with a list of devices, plus a - * 'selected' key with the selected one. + * Notifies JS land that the devices list has changed. */ - @ReactMethod - public void getAudioDevices(final Promise promise) { + private void notifyDevicesChanged() { runInAudioThread(new Runnable() { @Override public void run() { - WritableMap map = Arguments.createMap(); - map.putString("selected", selectedDevice); - WritableArray devices = Arguments.createArray(); + WritableArray data = Arguments.createArray(); + final boolean hasHeadphones = availableDevices.contains(DEVICE_HEADPHONES); for (String device : availableDevices) { - if (mode == VIDEO_CALL && device.equals(DEVICE_EARPIECE)) { - // Skip earpiece when in video call mode. + if (hasHeadphones && device.equals(DEVICE_EARPIECE)) { + // Skip earpiece when headphones are plugged in. continue; } - devices.pushString(device); + WritableMap deviceInfo = Arguments.createMap(); + deviceInfo.putString("type", device); + deviceInfo.putBoolean("selected", device.equals(selectedDevice)); + data.pushMap(deviceInfo); } - map.putArray("devices", devices); - - promise.resolve(map); + ReactInstanceManagerHolder.emitEvent(DEVICE_CHANGE_EVENT, data); + Log.i(TAG, "Updating audio device list"); } }); } @@ -584,7 +585,7 @@ class AudioModeModule extends ReactContextBaseJavaModule return; } - Runnable r = new Runnable() { + runInAudioThread(new Runnable() { @Override public void run() { boolean success; @@ -607,8 +608,7 @@ class AudioModeModule extends ReactContextBaseJavaModule "Failed to set audio mode to " + mode); } } - }; - runInAudioThread(r); + }); } /** @@ -690,6 +690,7 @@ class AudioModeModule extends ReactContextBaseJavaModule selectedDevice = null; userSelectedDevice = null; + notifyDevicesChanged(); return true; } @@ -708,7 +709,6 @@ class AudioModeModule extends ReactContextBaseJavaModule } boolean bluetoothAvailable = availableDevices.contains(DEVICE_BLUETOOTH); - boolean earpieceAvailable = availableDevices.contains(DEVICE_EARPIECE); boolean headsetAvailable = availableDevices.contains(DEVICE_HEADPHONES); // Pick the desired device based on what's available and the mode. @@ -717,8 +717,6 @@ class AudioModeModule extends ReactContextBaseJavaModule audioDevice = DEVICE_BLUETOOTH; } else if (headsetAvailable) { audioDevice = DEVICE_HEADPHONES; - } else if (mode == AUDIO_CALL && earpieceAvailable) { - audioDevice = DEVICE_EARPIECE; } else { audioDevice = DEVICE_SPEAKER; } @@ -744,6 +742,7 @@ class AudioModeModule extends ReactContextBaseJavaModule setAudioRoutePreO(audioDevice); } + notifyDevicesChanged(); return true; } } diff --git a/ios/sdk/sdk.xcodeproj/project.pbxproj b/ios/sdk/sdk.xcodeproj/project.pbxproj index f0fedf72b..f54acb222 100644 --- a/ios/sdk/sdk.xcodeproj/project.pbxproj +++ b/ios/sdk/sdk.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 0B412F181EDEC65D00B1A0A6 /* JitsiMeetView.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B412F161EDEC65D00B1A0A6 /* JitsiMeetView.h */; settings = {ATTRIBUTES = (Public, ); }; }; 0B412F191EDEC65D00B1A0A6 /* JitsiMeetView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B412F171EDEC65D00B1A0A6 /* JitsiMeetView.m */; }; 0B412F221EDEF6EA00B1A0A6 /* JitsiMeetViewDelegate.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B412F1B1EDEC80100B1A0A6 /* JitsiMeetViewDelegate.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 0B44A0191F902126009D1D64 /* MPVolumeViewManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 0B44A0181F902126009D1D64 /* MPVolumeViewManager.m */; }; 0B49424520AD8DBD00BD2DE0 /* outgoingStart.wav in Resources */ = {isa = PBXBuildFile; fileRef = 0B49424320AD8DBD00BD2DE0 /* outgoingStart.wav */; }; 0B49424620AD8DBD00BD2DE0 /* outgoingRinging.wav in Resources */ = {isa = PBXBuildFile; fileRef = 0B49424420AD8DBD00BD2DE0 /* outgoingRinging.wav */; }; 0B93EF7E1EC9DDCD0030D24D /* RCTBridgeWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B93EF7C1EC9DDCD0030D24D /* RCTBridgeWrapper.h */; }; @@ -59,7 +58,6 @@ 0B412F161EDEC65D00B1A0A6 /* JitsiMeetView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JitsiMeetView.h; sourceTree = ""; }; 0B412F171EDEC65D00B1A0A6 /* JitsiMeetView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JitsiMeetView.m; sourceTree = ""; }; 0B412F1B1EDEC80100B1A0A6 /* JitsiMeetViewDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = JitsiMeetViewDelegate.h; sourceTree = ""; }; - 0B44A0181F902126009D1D64 /* MPVolumeViewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPVolumeViewManager.m; sourceTree = ""; }; 0B49424320AD8DBD00BD2DE0 /* outgoingStart.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = outgoingStart.wav; path = ../../sounds/outgoingStart.wav; sourceTree = ""; }; 0B49424420AD8DBD00BD2DE0 /* outgoingRinging.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = outgoingRinging.wav; path = ../../sounds/outgoingRinging.wav; sourceTree = ""; }; 0B93EF7A1EC608550030D24D /* CoreText.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreText.framework; path = System/Library/Frameworks/CoreText.framework; sourceTree = SDKROOT; }; @@ -195,7 +193,6 @@ C6F99C13204DB63D0001F710 /* JitsiMeetView+Private.h */, 0B412F1B1EDEC80100B1A0A6 /* JitsiMeetViewDelegate.h */, DEFC743D21B178FA00E4DD96 /* LocaleDetector.m */, - 0B44A0181F902126009D1D64 /* MPVolumeViewManager.m */, C6A3426B204F127900E062DD /* picture-in-picture */, 0BCA495D1EC4B6C600B793EE /* POSIX.m */, 0BCA495E1EC4B6C600B793EE /* Proximity.m */, @@ -509,7 +506,6 @@ C6CC49AF207412CF000DFA42 /* PiPViewCoordinator.swift in Sources */, DEFC743F21B178FA00E4DD96 /* LocaleDetector.m in Sources */, 0BCA495F1EC4B6C600B793EE /* AudioMode.m in Sources */, - 0B44A0191F902126009D1D64 /* MPVolumeViewManager.m in Sources */, 0BCA49611EC4B6C600B793EE /* Proximity.m in Sources */, A480429C21EE335600289B73 /* AmplitudeModule.m in Sources */, C69EFA0C209A0F660027712B /* JMCallKitEmitter.swift in Sources */, diff --git a/ios/sdk/src/AudioMode.m b/ios/sdk/src/AudioMode.m index be1e402db..a25cb5425 100644 --- a/ios/sdk/src/AudioMode.m +++ b/ios/sdk/src/AudioMode.m @@ -1,6 +1,5 @@ /* - * Copyright @ 2018-present 8x8, Inc. - * Copyright @ 2017-2018 Atlassian Pty Ltd + * Copyright @ 2017-present 8x8, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +16,30 @@ #import -#import +#import #import #import + +// Audio mode typedef enum { kAudioModeDefault, kAudioModeAudioCall, kAudioModeVideoCall } JitsiMeetAudioMode; -@interface AudioMode : NSObject +// Events +static NSString * const kDevicesChanged = @"org.jitsi.meet:features/audio-mode#devices-update"; + +// Device types (must match JS and Java) +static NSString * const kDeviceTypeHeadphones = @"HEADPHONES"; +static NSString * const kDeviceTypeBluetooth = @"BLUETOOTH"; +static NSString * const kDeviceTypeEarpiece = @"EARPIECE"; +static NSString * const kDeviceTypeSpeaker = @"SPEAKER"; +static NSString * const kDeviceTypeUnknown = @"UNKNOWN"; + + +@interface AudioMode : RCTEventEmitter @property(nonatomic, strong) dispatch_queue_t workerQueue; @@ -38,6 +50,11 @@ typedef enum { RTCAudioSessionConfiguration *defaultConfig; RTCAudioSessionConfiguration *audioCallConfig; RTCAudioSessionConfiguration *videoCallConfig; + RTCAudioSessionConfiguration *earpieceConfig; + BOOL forceSpeaker; + BOOL forceEarpiece; + BOOL isSpeakerOn; + BOOL isEarpieceOn; } RCT_EXPORT_MODULE(); @@ -46,8 +63,13 @@ RCT_EXPORT_MODULE(); return NO; } +- (NSArray *)supportedEvents { + return @[ kDevicesChanged ]; +} + - (NSDictionary *)constantsToExport { return @{ + @"DEVICE_CHANGE_EVENT": kDevicesChanged, @"AUDIO_CALL" : [NSNumber numberWithInt: kAudioModeAudioCall], @"DEFAULT" : [NSNumber numberWithInt: kAudioModeDefault], @"VIDEO_CALL" : [NSNumber numberWithInt: kAudioModeVideoCall] @@ -58,8 +80,7 @@ RCT_EXPORT_MODULE(); self = [super init]; if (self) { dispatch_queue_attr_t attributes = - dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, - QOS_CLASS_USER_INITIATED, -1); + dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, -1); _workerQueue = dispatch_queue_create("AudioMode.queue", attributes); activeMode = kAudioModeDefault; @@ -71,7 +92,7 @@ RCT_EXPORT_MODULE(); audioCallConfig = [[RTCAudioSessionConfiguration alloc] init]; audioCallConfig.category = AVAudioSessionCategoryPlayAndRecord; - audioCallConfig.categoryOptions = AVAudioSessionCategoryOptionAllowBluetooth; + audioCallConfig.categoryOptions = AVAudioSessionCategoryOptionAllowBluetooth | AVAudioSessionCategoryOptionDefaultToSpeaker; audioCallConfig.mode = AVAudioSessionModeVoiceChat; videoCallConfig = [[RTCAudioSessionConfiguration alloc] init]; @@ -79,6 +100,17 @@ RCT_EXPORT_MODULE(); videoCallConfig.categoryOptions = AVAudioSessionCategoryOptionAllowBluetooth; videoCallConfig.mode = AVAudioSessionModeVideoChat; + // Manually routing audio to the earpiece doesn't quite work unless one disables BT (weird, I know). + earpieceConfig = [[RTCAudioSessionConfiguration alloc] init]; + earpieceConfig.category = AVAudioSessionCategoryPlayAndRecord; + earpieceConfig.categoryOptions = 0; + earpieceConfig.mode = AVAudioSessionModeVoiceChat; + + forceSpeaker = NO; + forceEarpiece = NO; + isSpeakerOn = NO; + isEarpieceOn = NO; + RTCAudioSession *session = [RTCAudioSession sharedInstance]; [session addDelegate:self]; } @@ -107,24 +139,20 @@ RCT_EXPORT_MODULE(); RCT_EXPORT_METHOD(setMode:(int)mode resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) { - RTCAudioSessionConfiguration *config; + RTCAudioSessionConfiguration *config = [self configForMode:mode]; NSError *error; - switch (mode) { - case kAudioModeAudioCall: - config = audioCallConfig; - break; - case kAudioModeDefault: - config = defaultConfig; - break; - case kAudioModeVideoCall: - config = videoCallConfig; - break; - default: + if (config == nil) { reject(@"setMode", @"Invalid mode", nil); return; } + // Reset. + if (mode == kAudioModeDefault) { + forceSpeaker = NO; + forceEarpiece = NO; + } + activeMode = mode; if ([self setConfig:config error:&error]) { @@ -132,6 +160,76 @@ RCT_EXPORT_METHOD(setMode:(int)mode } else { reject(@"setMode", error.localizedDescription, error); } + + [self notifyDevicesChanged]; +} + +RCT_EXPORT_METHOD(setAudioDevice:(NSString *)device + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) { + NSLog(@"[AudioMode] Selected device: %@", device); + + RTCAudioSession *session = [RTCAudioSession sharedInstance]; + [session lockForConfiguration]; + BOOL success; + NSError *error = nil; + + // Reset these, as we are about to compute them. + forceSpeaker = NO; + forceEarpiece = NO; + + // The speaker is special, so test for it first. + if ([device isEqualToString:kDeviceTypeSpeaker]) { + forceSpeaker = NO; + success = [session overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:&error]; + } else { + // Here we use AVAudioSession because RTCAudioSession doesn't expose availableInputs. + AVAudioSession *_session = [AVAudioSession sharedInstance]; + AVAudioSessionPortDescription *port = nil; + + // Find the matching input device. + for (AVAudioSessionPortDescription *portDesc in _session.availableInputs) { + if ([portDesc.UID isEqualToString:device]) { + port = portDesc; + break; + } + } + + if (port != nil) { + // First remove the override if we are going to select a different device. + if (isSpeakerOn) { + [session overrideOutputAudioPort:AVAudioSessionPortOverrideNone error:nil]; + } + + // Special case for the earpiece. + if ([port.portType isEqualToString:AVAudioSessionPortBuiltInMic]) { + forceEarpiece = YES; + [self setConfig:earpieceConfig error:nil]; + } else if (isEarpieceOn) { + // Reset the config. + RTCAudioSessionConfiguration *config = [self configForMode:activeMode]; + [self setConfig:config error:nil]; + } + + // Select our preferred input. + success = [session setPreferredInput:port error:&error]; + } else { + success = NO; + error = RCTErrorWithMessage(@"Could not find audio device"); + } + } + + [session unlockForConfiguration]; + + if (success) { + resolve(nil); + } else { + reject(@"setAudioDevice", error != nil ? error.localizedDescription : @"", error); + } +} + +RCT_EXPORT_METHOD(updateDeviceList) { + [self notifyDevicesChanged]; } #pragma mark - RTCAudioSessionDelegate @@ -139,26 +237,141 @@ RCT_EXPORT_METHOD(setMode:(int)mode - (void)audioSessionDidChangeRoute:(RTCAudioSession *)session reason:(AVAudioSessionRouteChangeReason)reason previousRoute:(AVAudioSessionRouteDescription *)previousRoute { - if (reason == AVAudioSessionRouteChangeReasonCategoryChange) { - // The category has changed. Check if it's the one we want and adjust as - // needed. This notification is posted on a secondary thread, so make - // sure we switch to our worker thread. - dispatch_async(_workerQueue, ^{ - // We don't want to touch the category when in default mode. - // This is to play well with other components which could be integrated - // into the final application. - if (self->activeMode != kAudioModeDefault) { - NSLog(@"Audio route changed, reapplying RTCAudioSession config"); - RTCAudioSessionConfiguration *config - = self->activeMode == kAudioModeAudioCall ? self->audioCallConfig : self->videoCallConfig; - [self setConfig:config error:nil]; + // Update JS about the changes. + [self notifyDevicesChanged]; + + dispatch_async(_workerQueue, ^{ + switch (reason) { + case AVAudioSessionRouteChangeReasonNewDeviceAvailable: + case AVAudioSessionRouteChangeReasonOldDeviceUnavailable: + // If the device list changed, reset our overrides. + self->forceSpeaker = NO; + self->forceEarpiece = NO; + break; + case AVAudioSessionRouteChangeReasonCategoryChange: + // The category has changed. Check if it's the one we want and adjust as + // needed. + break; + default: + return; + } + + // We don't want to touch the category when in default mode. + // This is to play well with other components which could be integrated + // into the final application. + if (self->activeMode != kAudioModeDefault) { + NSLog(@"[AudioMode] Route changed, reapplying RTCAudioSession config"); + RTCAudioSessionConfiguration *config = [self configForMode:self->activeMode]; + [self setConfig:config error:nil]; + if (self->forceSpeaker && !self->isSpeakerOn) { + RTCAudioSession *session = [RTCAudioSession sharedInstance]; + [session lockForConfiguration]; + [session overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:nil]; + [session unlockForConfiguration]; } - }); - } + } + }); } - (void)audioSession:(RTCAudioSession *)audioSession didSetActive:(BOOL)active { NSLog(@"[AudioMode] Audio session didSetActive:%d", active); } +#pragma mark - Helper methods + +- (RTCAudioSessionConfiguration *)configForMode:(int) mode { + if (mode != kAudioModeDefault && forceEarpiece) { + return earpieceConfig; + } + + switch (mode) { + case kAudioModeAudioCall: + return audioCallConfig; + case kAudioModeDefault: + return defaultConfig; + case kAudioModeVideoCall: + return videoCallConfig; + default: + return nil; + } +} + +// Here we convert input and output port types into a single type. +- (NSString *)portTypeToString:(AVAudioSessionPort) portType { + if ([portType isEqualToString:AVAudioSessionPortHeadphones] + || [portType isEqualToString:AVAudioSessionPortHeadsetMic]) { + return kDeviceTypeHeadphones; + } else if ([portType isEqualToString:AVAudioSessionPortBuiltInMic] + || [portType isEqualToString:AVAudioSessionPortBuiltInReceiver]) { + return kDeviceTypeEarpiece; + } else if ([portType isEqualToString:AVAudioSessionPortBuiltInSpeaker]) { + return kDeviceTypeSpeaker; + } else if ([portType isEqualToString:AVAudioSessionPortBluetoothHFP] + || [portType isEqualToString:AVAudioSessionPortBluetoothLE] + || [portType isEqualToString:AVAudioSessionPortBluetoothA2DP]) { + return kDeviceTypeBluetooth; + } else { + return kDeviceTypeUnknown; + } +} + +- (void)notifyDevicesChanged { + dispatch_async(_workerQueue, ^{ + NSMutableArray *data = [[NSMutableArray alloc] init]; + // Here we use AVAudioSession because RTCAudioSession doesn't expose availableInputs. + AVAudioSession *session = [AVAudioSession sharedInstance]; + NSString *currentPort = @""; + AVAudioSessionRouteDescription *currentRoute = session.currentRoute; + + // Check what the current device is. Because the speaker is somewhat special, we need to + // check for it first. + if (currentRoute != nil) { + AVAudioSessionPortDescription *output = currentRoute.outputs.firstObject; + AVAudioSessionPortDescription *input = currentRoute.inputs.firstObject; + if (output != nil && [output.portType isEqualToString:AVAudioSessionPortBuiltInSpeaker]) { + currentPort = kDeviceTypeSpeaker; + self->isSpeakerOn = YES; + } else if (input != nil) { + currentPort = input.UID; + self->isSpeakerOn = NO; + self->isEarpieceOn = [input.portType isEqualToString:AVAudioSessionPortBuiltInMic]; + } + } + + BOOL headphonesAvailable = NO; + for (AVAudioSessionPortDescription *portDesc in session.availableInputs) { + if ([portDesc.portType isEqualToString:AVAudioSessionPortHeadsetMic] || [portDesc.portType isEqualToString:AVAudioSessionPortHeadphones]) { + headphonesAvailable = YES; + break; + } + } + + for (AVAudioSessionPortDescription *portDesc in session.availableInputs) { + // Skip "Phone" if headphones are present. + if (headphonesAvailable && [portDesc.portType isEqualToString:AVAudioSessionPortBuiltInMic]) { + continue; + } + id deviceData + = @{ + @"type": [self portTypeToString:portDesc.portType], + @"name": portDesc.portName, + @"uid": portDesc.UID, + @"selected": [NSNumber numberWithBool:[portDesc.UID isEqualToString:currentPort]] + }; + [data addObject:deviceData]; + } + + // We need to manually add the speaker because it will never show up in the + // previous list, as it's not an input. + [data addObject: + @{ @"type": kDeviceTypeSpeaker, + @"name": @"Speaker", + @"uid": kDeviceTypeSpeaker, + @"selected": [NSNumber numberWithBool:[kDeviceTypeSpeaker isEqualToString:currentPort]] + }]; + + [self sendEventWithName:kDevicesChanged body:data]; + }); +} + @end diff --git a/ios/sdk/src/MPVolumeViewManager.m b/ios/sdk/src/MPVolumeViewManager.m deleted file mode 100644 index 99f075919..000000000 --- a/ios/sdk/src/MPVolumeViewManager.m +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright @ 2017-present Atlassian Pty Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import - -@import MediaPlayer; - - -@interface MPVolumeViewManager : RCTViewManager -@end - -@implementation MPVolumeViewManager - -RCT_EXPORT_MODULE() - -- (UIView *)view { - MPVolumeView *volumeView = [[MPVolumeView alloc] init]; - volumeView.showsRouteButton = YES; - volumeView.showsVolumeSlider = NO; - - return (UIView *) volumeView; -} - -RCT_EXPORT_METHOD(show:(nonnull NSNumber *)reactTag) { - [self.bridge.uiManager addUIBlock:^( - __unused RCTUIManager *uiManager, - NSDictionary *viewRegistry) { - id view = viewRegistry[reactTag]; - if (![view isKindOfClass:[MPVolumeView class]]) { - RCTLogError(@"Invalid view returned from registry, expecting \ - MPVolumeView, got: %@", view); - } else { - // Simulate a click - UIButton *btn = nil; - for (UIView *buttonView in ((UIView *) view).subviews) { - if ([buttonView isKindOfClass:[UIButton class]]) { - btn = (UIButton *) buttonView; - break; - } - } - if (btn != nil) { - [btn sendActionsForControlEvents:UIControlEventTouchUpInside]; - } - } - }]; -} - -@end diff --git a/lang/main.json b/lang/main.json index 39f4dff2e..25c6ef1f0 100644 --- a/lang/main.json +++ b/lang/main.json @@ -21,7 +21,8 @@ "bluetooth": "Bluetooth", "headphones": "Headphones", "phone": "Phone", - "speaker": "Speaker" + "speaker": "Speaker", + "none": "No audio devices available" }, "audioOnly": { "audioOnly": "Low bandwidth" diff --git a/react/features/mobile/audio-mode/actionTypes.js b/react/features/mobile/audio-mode/actionTypes.js new file mode 100644 index 000000000..fca2d3219 --- /dev/null +++ b/react/features/mobile/audio-mode/actionTypes.js @@ -0,0 +1,23 @@ +/** + * The type of redux action to set Audio Mode device list. + * + * { + * type: _SET_AUDIOMODE_DEVICES, + * devices: Array + * } + * + * @protected + */ +export const _SET_AUDIOMODE_DEVICES = '_SET_AUDIOMODE_DEVICES'; + +/** + * The type of redux action to set Audio Mode module's subscriptions. + * + * { + * type: _SET_AUDIOMODE_SUBSCRIPTIONS, + * subscriptions: Array|undefined + * } + * + * @protected + */ +export const _SET_AUDIOMODE_SUBSCRIPTIONS = '_SET_AUDIOMODE_SUBSCRIPTIONS'; diff --git a/react/features/mobile/audio-mode/components/AudioRouteButton.js b/react/features/mobile/audio-mode/components/AudioRouteButton.js index 6a5f8091d..a75c37603 100644 --- a/react/features/mobile/audio-mode/components/AudioRouteButton.js +++ b/react/features/mobile/audio-mode/components/AudioRouteButton.js @@ -1,13 +1,5 @@ // @flow -import React from 'react'; -import { - findNodeHandle, - NativeModules, - requireNativeComponent, - View -} from 'react-native'; - import { openDialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; import { connect } from '../../../base/redux'; @@ -16,19 +8,6 @@ import type { AbstractButtonProps } from '../../../base/toolbox'; import AudioRoutePickerDialog from './AudioRoutePickerDialog'; -/** - * The {@code MPVolumeView} React {@code Component}. It will only be available - * on iOS. - */ -const MPVolumeView - = NativeModules.MPVolumeViewManager - && requireNativeComponent('MPVolumeView'); - -/** - * The style required to hide the {@code MPVolumeView}, since it's displayed - * programmatically. - */ -const HIDE_VIEW_STYLE = { display: 'none' }; type Props = AbstractButtonProps & { @@ -47,30 +26,6 @@ class AudioRouteButton extends AbstractButton { iconName = 'icon-volume'; label = 'toolbar.audioRoute'; - _volumeComponent: ?Object; - - /** - * Initializes a new {@code AudioRouteButton} instance. - * - * @param {Props} props - The React {@code Component} props to initialize - * the new {@code AudioRouteButton} instance with. - */ - constructor(props: Props) { - super(props); - - /** - * The internal reference to the React {@code MPVolumeView} for - * showing the volume control view. - * - * @private - * @type {ReactElement} - */ - this._volumeComponent = null; - - // Bind event handlers so they are only bound once per instance. - this._setVolumeComponent = this._setVolumeComponent.bind(this); - } - /** * Handles clicking / pressing the button, and opens the appropriate dialog. * @@ -78,52 +33,7 @@ class AudioRouteButton extends AbstractButton { * @returns {void} */ _handleClick() { - if (MPVolumeView) { - NativeModules.MPVolumeViewManager.show( - findNodeHandle(this._volumeComponent)); - } else if (AudioRoutePickerDialog) { - this.props.dispatch(openDialog(AudioRoutePickerDialog)); - } - } - - _setVolumeComponent: (?Object) => void; - - /** - * Sets the internal reference to the React Component wrapping the - * {@code MPVolumeView} component. - * - * @param {ReactElement} component - React Component. - * @private - * @returns {void} - */ - _setVolumeComponent(component) { - this._volumeComponent = component; - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {React$Node} - */ - render() { - if (!MPVolumeView && !AudioRoutePickerDialog) { - return null; - } - - const element = super.render(); - - return ( - - { element } - { - MPVolumeView - && - } - - ); + this.props.dispatch(openDialog(AudioRoutePickerDialog)); } } diff --git a/react/features/mobile/audio-mode/components/AudioRoutePickerDialog.js b/react/features/mobile/audio-mode/components/AudioRoutePickerDialog.js index 0531d7d3e..1bc2d546a 100644 --- a/react/features/mobile/audio-mode/components/AudioRoutePickerDialog.js +++ b/react/features/mobile/audio-mode/components/AudioRoutePickerDialog.js @@ -13,6 +13,8 @@ import { ColorPalette, type StyleType } from '../../../base/styles'; import styles from './styles'; +const { AudioMode } = NativeModules; + /** * Type definition for a single entry in the device list. */ @@ -37,7 +39,38 @@ type Device = { /** * Device type. */ - type: string + type: string, + + /** + * Unique device ID. + */ + uid: ?string +}; + +/** + * "Raw" device, as returned by native. + */ +type RawDevice = { + + /** + * Display name for the device. + */ + name: ?string, + + /** + * is this device selected? + */ + selected: boolean, + + /** + * Device type. + */ + type: string, + + /** + * Unique device ID. + */ + uid: ?string }; /** @@ -50,6 +83,11 @@ type Props = { */ _bottomSheetStyles: StyleType, + /** + * Object describing available devices. + */ + _devices: Array, + /** * Used for hiding the dialog when the selection was completed. */ @@ -72,8 +110,6 @@ type State = { devices: Array }; -const { AudioMode } = NativeModules; - /** * Maps each device type to a display name and icon. */ @@ -101,11 +137,9 @@ const deviceInfoMap = { }; /** - * The exported React {@code Component}. {@code AudioRoutePickerDialog} is - * exported only if the {@code AudioMode} module has the capability to get / set - * audio devices. + * The exported React {@code Component}. */ -let AudioRoutePickerDialog_; +let AudioRoutePickerDialog_; // eslint-disable-line prefer-const /** * Implements a React {@code Component} which prompts the user when a password @@ -115,11 +149,47 @@ class AudioRoutePickerDialog extends Component { state = { /** * Available audio devices, it will be set in - * {@link #componentDidMount()}. + * {@link #getDerivedStateFromProps()}. */ devices: [] }; + /** + * Implements React's {@link Component#getDerivedStateFromProps()}. + * + * @inheritdoc + */ + static getDerivedStateFromProps(props: Props) { + const { _devices: devices } = props; + + if (!devices) { + return null; + } + + const audioDevices = []; + + for (const device of devices) { + const infoMap = deviceInfoMap[device.type]; + const text = device.type === 'BLUETOOTH' && device.name ? device.name : infoMap.text; + + if (infoMap) { + const info = { + ...infoMap, + selected: Boolean(device.selected), + text: props.t(text), + uid: device.uid + }; + + audioDevices.push(info); + } + } + + // Make sure devices is alphabetically sorted. + return { + devices: _.sortBy(audioDevices, 'text') + }; + } + /** * Initializes a new {@code PasswordRequiredPrompt} instance. * @@ -131,36 +201,9 @@ class AudioRoutePickerDialog extends Component { // Bind event handlers so they are only bound once per instance. this._onCancel = this._onCancel.bind(this); - } - /** - * Initializes the device list by querying {@code AudioMode}. - * - * @inheritdoc - */ - componentDidMount() { - AudioMode.getAudioDevices().then(({ devices, selected }) => { - const audioDevices = []; - - if (devices) { - for (const device of devices) { - if (deviceInfoMap[device]) { - const info = Object.assign({}, deviceInfoMap[device]); - - info.selected = device === selected; - info.text = this.props.t(info.text); - audioDevices.push(info); - } - } - } - - if (audioDevices) { - // Make sure devices is alphabetically sorted. - this.setState({ - devices: _.sortBy(audioDevices, 'text') - }); - } - }); + // Trigger an initial update. + AudioMode.updateDeviceList && AudioMode.updateDeviceList(); } /** @@ -197,7 +240,7 @@ class AudioRoutePickerDialog extends Component { _onSelectDeviceFn(device: Device) { return () => { this._hide(); - AudioMode.setAudioDevice(device.type); + AudioMode.setAudioDevice(device.uid || device.type); }; } @@ -230,6 +273,27 @@ class AudioRoutePickerDialog extends Component { ); } + /** + * Renders a "fake" device row indicating there are no devices. + * + * @private + * @returns {ReactElement} + */ + _renderNoDevices() { + const { _bottomSheetStyles, t } = this.props; + + return ( + + + + { t('audioDevices.none') } + + + ); + } + /** * Implements React's {@link Component#render()}. * @@ -238,14 +302,17 @@ class AudioRoutePickerDialog extends Component { */ render() { const { devices } = this.state; + let content; - if (!devices.length) { - return null; + if (devices.length === 0) { + content = this._renderNoDevices(); + } else { + content = this.state.devices.map(this._renderDevice, this); } return ( - { this.state.devices.map(this._renderDevice, this) } + { content } ); } @@ -259,14 +326,11 @@ class AudioRoutePickerDialog extends Component { */ function _mapStateToProps(state) { return { - _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet') + _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'), + _devices: state['features/mobile/audio-mode'].devices }; } -// Only export the dialog if we have support for getting / setting audio devices -// in AudioMode. -if (AudioMode.getAudioDevices && AudioMode.setAudioDevice) { - AudioRoutePickerDialog_ = translate(connect(_mapStateToProps)(AudioRoutePickerDialog)); -} +AudioRoutePickerDialog_ = translate(connect(_mapStateToProps)(AudioRoutePickerDialog)); export default AudioRoutePickerDialog_; diff --git a/react/features/mobile/audio-mode/index.js b/react/features/mobile/audio-mode/index.js index 7ce19e0e6..daaa5d02a 100644 --- a/react/features/mobile/audio-mode/index.js +++ b/react/features/mobile/audio-mode/index.js @@ -1,3 +1,4 @@ export * from './components'; import './middleware'; +import './reducer'; diff --git a/react/features/mobile/audio-mode/middleware.js b/react/features/mobile/audio-mode/middleware.js index e0c543aaf..e15409520 100644 --- a/react/features/mobile/audio-mode/middleware.js +++ b/react/features/mobile/audio-mode/middleware.js @@ -1,9 +1,9 @@ // @flow -import { NativeModules } from 'react-native'; +import { NativeEventEmitter, NativeModules } from 'react-native'; import { SET_AUDIO_ONLY } from '../../base/audio-only'; -import { APP_WILL_MOUNT } from '../../base/app'; +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app'; import { CONFERENCE_FAILED, CONFERENCE_LEFT, @@ -12,7 +12,10 @@ import { } from '../../base/conference'; import { MiddlewareRegistry } from '../../base/redux'; +import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes'; + const { AudioMode } = NativeModules; +const AudioModeEmitter = new NativeEventEmitter(AudioMode); const logger = require('jitsi-meet-logger').getLogger(__filename); /** @@ -23,55 +26,127 @@ const logger = require('jitsi-meet-logger').getLogger(__filename); * @param {Store} store - The redux store. * @returns {Function} */ -MiddlewareRegistry.register(({ getState }) => next => action => { - const result = next(action); +MiddlewareRegistry.register(store => next => action => { + /* eslint-disable no-fallthrough */ - if (AudioMode) { - let mode; + switch (action.type) { + case _SET_AUDIOMODE_SUBSCRIPTIONS: + _setSubscriptions(store); + break; + case APP_WILL_UNMOUNT: { + store.dispatch({ + type: _SET_AUDIOMODE_SUBSCRIPTIONS, + subscriptions: undefined + }); + break; + } + case APP_WILL_MOUNT: + _appWillMount(store); + case CONFERENCE_FAILED: // eslint-disable-line no-fallthrough + case CONFERENCE_LEFT: - switch (action.type) { - case APP_WILL_MOUNT: - case CONFERENCE_FAILED: - case CONFERENCE_LEFT: { - const conference = getCurrentConference(getState()); + /* + * NOTE: We moved the audio mode setting from CONFERENCE_WILL_JOIN to + * CONFERENCE_JOINED because in case of a locked room, the app goes + * through CONFERENCE_FAILED state and gets to CONFERENCE_JOINED only + * after a correct password, so we want to make sure we have the correct + * audio mode set up when we finally get to the conf, but also make sure + * that the app is in the right audio mode if the user leaves the + * conference after the password prompt appears. + */ + case CONFERENCE_JOINED: + case SET_AUDIO_ONLY: + return _updateAudioMode(store, next, action); - if (typeof conference === 'undefined') { - mode = AudioMode.DEFAULT; - } - - break; - } - - /* - * NOTE: We moved the audio mode setting from CONFERENCE_WILL_JOIN to - * CONFERENCE_JOINED because in case of a locked room, the app goes - * through CONFERENCE_FAILED state and gets to CONFERENCE_JOINED only - * after a correct password, so we want to make sure we have the correct - * audio mode set up when we finally get to the conf, but also make sure - * that the app is in the right audio mode if the user leaves the - * conference after the password prompt appears. - */ - case CONFERENCE_JOINED: - case SET_AUDIO_ONLY: { - const state = getState(); - const { conference } = state['features/base/conference']; - const { enabled: audioOnly } = state['features/base/audio-only']; - - conference - && (mode = audioOnly - ? AudioMode.AUDIO_CALL - : AudioMode.VIDEO_CALL); - break; - } - } - - if (typeof mode !== 'undefined') { - AudioMode.setMode(mode) - .catch(err => - logger.error( - `Failed to set audio mode ${String(mode)}: ${err}`)); - } } - return result; + /* eslint-enable no-fallthrough */ + + return next(action); }); + +/** + * Notifies this feature that the action {@link APP_WILL_MOUNT} is being + * dispatched within a specific redux {@code store}. + * + * @param {Store} store - The redux store in which the specified {@code action} + * is being dispatched. + * @private + * @returns {void} + */ +function _appWillMount(store) { + const subscriptions = [ + AudioModeEmitter.addListener(AudioMode.DEVICE_CHANGE_EVENT, _onDevicesUpdate, store) + ]; + + store.dispatch({ + type: _SET_AUDIOMODE_SUBSCRIPTIONS, + subscriptions + }); +} + +/** + * Handles audio device changes. The list will be stored on the redux store. + * + * @param {Object} devices - The current list of devices. + * @private + * @returns {void} + */ +function _onDevicesUpdate(devices) { + const { dispatch } = this; // eslint-disable-line no-invalid-this + + dispatch({ + type: _SET_AUDIOMODE_DEVICES, + devices + }); +} + +/** + * Notifies this feature that the action + * {@link _SET_AUDIOMODE_SUBSCRIPTIONS} is being dispatched within + * a specific redux {@code store}. + * + * @param {Store} store - The redux store in which the specified {@code action} + * is being dispatched. + * @private + * @returns {void} + */ +function _setSubscriptions({ getState }) { + const { subscriptions } = getState()['features/mobile/audio-mode']; + + if (subscriptions) { + for (const subscription of subscriptions) { + subscription.remove(); + } + } +} + +/** + * Updates the audio mode based on the current (redux) state. + * + * @param {Store} store - The redux store in which the specified {@code action} + * is being dispatched. + * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the + * specified {@code action} in the specified {@code store}. + * @param {Action} action - The redux action which is + * being dispatched in the specified {@code store}. + * @private + * @returns {*} The value returned by {@code next(action)}. + */ +function _updateAudioMode({ getState }, next, action) { + const result = next(action); + const state = getState(); + const conference = getCurrentConference(state); + const { enabled: audioOnly } = state['features/base/audio-only']; + let mode; + + if (conference) { + mode = audioOnly ? AudioMode.AUDIO_CALL : AudioMode.VIDEO_CALL; + } else { + mode = AudioMode.DEFAULT; + } + + AudioMode.setMode(mode).catch(err => logger.error(`Failed to set audio mode ${String(mode)}: ${err}`)); + + return result; +} diff --git a/react/features/mobile/audio-mode/reducer.js b/react/features/mobile/audio-mode/reducer.js new file mode 100644 index 000000000..e2d3e3ef8 --- /dev/null +++ b/react/features/mobile/audio-mode/reducer.js @@ -0,0 +1,28 @@ +// @flow + +import { equals, set, ReducerRegistry } from '../../base/redux'; + +import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes'; + +const DEFAULT_STATE = { + devices: [], + subscriptions: [] +}; + +ReducerRegistry.register('features/mobile/audio-mode', (state = DEFAULT_STATE, action) => { + switch (action.type) { + case _SET_AUDIOMODE_DEVICES: { + const { devices } = action; + + if (equals(state.devices, devices)) { + return state; + } + + return set(state, 'devices', devices); + } + case _SET_AUDIOMODE_SUBSCRIPTIONS: + return set(state, 'subscriptions', action.subscriptions); + } + + return state; +});