audio-mode: refactor device handling

This commit refactors device selection (more heavily on iOS) to make it
consistent across platforms.

Due to its complexity I couldn't break out each step into separate commits,
apologies to the reviewer.

Changes made to device handling:

- speaker is always the default, regardless of the mode
- "Phone" shows as a selectable option, even in video call mode
- "Phone" is not displayed when wired headphones are present
- Shared device picker between iOS and Android
- Runtime device updates while the picker is open
This commit is contained in:
Saúl Ibarra Corretgé 2019-08-09 12:41:52 +02:00 committed by Saúl Ibarra Corretgé
parent 9721d99918
commit 1c1e8a942b
11 changed files with 559 additions and 311 deletions

View File

@ -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<String, Object> getConstants() {
Map<String, Object> 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;
}
}

View File

@ -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 = "<group>"; };
0B412F171EDEC65D00B1A0A6 /* JitsiMeetView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = JitsiMeetView.m; sourceTree = "<group>"; };
0B412F1B1EDEC80100B1A0A6 /* JitsiMeetViewDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = JitsiMeetViewDelegate.h; sourceTree = "<group>"; };
0B44A0181F902126009D1D64 /* MPVolumeViewManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MPVolumeViewManager.m; sourceTree = "<group>"; };
0B49424320AD8DBD00BD2DE0 /* outgoingStart.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = outgoingStart.wav; path = ../../sounds/outgoingStart.wav; sourceTree = "<group>"; };
0B49424420AD8DBD00BD2DE0 /* outgoingRinging.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = outgoingRinging.wav; path = ../../sounds/outgoingRinging.wav; sourceTree = "<group>"; };
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 */,

View File

@ -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 <AVFoundation/AVFoundation.h>
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
#import <React/RCTLog.h>
#import <WebRTC/WebRTC.h>
// Audio mode
typedef enum {
kAudioModeDefault,
kAudioModeAudioCall,
kAudioModeVideoCall
} JitsiMeetAudioMode;
@interface AudioMode : NSObject<RCTBridgeModule, RTCAudioSessionDelegate>
// 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<RTCAudioSessionDelegate>
@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<NSString *> *)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

View File

@ -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 <React/RCTUIManager.h>
#import <React/RCTViewManager.h>
@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<NSNumber *, UIView *> *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

View File

@ -21,7 +21,8 @@
"bluetooth": "Bluetooth",
"headphones": "Headphones",
"phone": "Phone",
"speaker": "Speaker"
"speaker": "Speaker",
"none": "No audio devices available"
},
"audioOnly": {
"audioOnly": "Low bandwidth"

View File

@ -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';

View File

@ -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<Props, *> {
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<Props, *> {
* @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 (
<View>
{ element }
{
MPVolumeView
&& <MPVolumeView
ref = { this._setVolumeComponent }
style = { HIDE_VIEW_STYLE } />
}
</View>
);
this.props.dispatch(openDialog(AudioRoutePickerDialog));
}
}

View File

@ -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<RawDevice>,
/**
* Used for hiding the dialog when the selection was completed.
*/
@ -72,8 +110,6 @@ type State = {
devices: Array<Device>
};
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<Props, State> {
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<Props, State> {
// 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<Props, State> {
_onSelectDeviceFn(device: Device) {
return () => {
this._hide();
AudioMode.setAudioDevice(device.type);
AudioMode.setAudioDevice(device.uid || device.type);
};
}
@ -230,6 +273,27 @@ class AudioRoutePickerDialog extends Component<Props, State> {
);
}
/**
* Renders a "fake" device row indicating there are no devices.
*
* @private
* @returns {ReactElement}
*/
_renderNoDevices() {
const { _bottomSheetStyles, t } = this.props;
return (
<View style = { styles.deviceRow } >
<Icon
name = { deviceInfoMap.SPEAKER.iconName }
style = { [ styles.deviceIcon, _bottomSheetStyles.iconStyle ] } />
<Text style = { [ styles.deviceText, _bottomSheetStyles.labelStyle ] } >
{ t('audioDevices.none') }
</Text>
</View>
);
}
/**
* Implements React's {@link Component#render()}.
*
@ -238,14 +302,17 @@ class AudioRoutePickerDialog extends Component<Props, State> {
*/
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 (
<BottomSheet onCancel = { this._onCancel }>
{ this.state.devices.map(this._renderDevice, this) }
{ content }
</BottomSheet>
);
}
@ -259,14 +326,11 @@ class AudioRoutePickerDialog extends Component<Props, State> {
*/
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_;

View File

@ -1,3 +1,4 @@
export * from './components';
import './middleware';
import './reducer';

View File

@ -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;
}

View File

@ -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;
});