This commit is contained in:
23
react/features/mobile/audio-mode/actionTypes.ts
Normal file
23
react/features/mobile/audio-mode/actionTypes.ts
Normal 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';
|
||||
@@ -0,0 +1,30 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { openSheet } from '../../../base/dialog/actions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconVolumeUp } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
|
||||
import AudioRoutePickerDialog from './AudioRoutePickerDialog';
|
||||
|
||||
/**
|
||||
* Implements an {@link AbstractButton} to open the audio device list.
|
||||
*/
|
||||
class AudioDeviceToggleButton extends AbstractButton<AbstractButtonProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.audioRoute';
|
||||
override icon = IconVolumeUp;
|
||||
override label = 'toolbar.accessibilityLabel.audioRoute';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
this.props.dispatch(openSheet(AudioRoutePickerDialog));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default translate(connect()(AudioDeviceToggleButton));
|
||||
@@ -0,0 +1,329 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import React, { Component } from 'react';
|
||||
import { NativeModules, Text, TextStyle, TouchableHighlight, View, ViewStyle } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { hideSheet } from '../../../base/dialog/actions';
|
||||
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
|
||||
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Icon from '../../../base/icons/components/Icon';
|
||||
import {
|
||||
IconBluetooth,
|
||||
IconCar,
|
||||
IconDeviceHeadphone,
|
||||
IconPhoneRinging,
|
||||
IconVolumeUp
|
||||
} from '../../../base/icons/svg';
|
||||
import { StyleType } from '../../../base/styles/functions.any';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
import styles from './styles';
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
|
||||
/**
|
||||
* Type definition for a single entry in the device list.
|
||||
*/
|
||||
interface IDevice {
|
||||
|
||||
/**
|
||||
* Name of the icon which will be rendered on the right.
|
||||
*/
|
||||
icon: Function;
|
||||
|
||||
/**
|
||||
* True if the element is selected (will be highlighted in blue),
|
||||
* false otherwise.
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* Text which will be rendered in the row.
|
||||
*/
|
||||
text: string;
|
||||
|
||||
/**
|
||||
* Device type.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Unique device ID.
|
||||
*/
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Raw" device, as returned by native.
|
||||
*/
|
||||
export interface IRawDevice {
|
||||
|
||||
/**
|
||||
* Display name for the device.
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Is this device selected?
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
/**
|
||||
* Device type.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Unique device ID.
|
||||
*/
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code AudioRoutePickerDialog}'s React {@code Component} prop types.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Object describing available devices.
|
||||
*/
|
||||
_devices: Array<IRawDevice>;
|
||||
|
||||
/**
|
||||
* Used for hiding the dialog when the selection was completed.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code AudioRoutePickerDialog}'s React {@code Component} state types.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Array of available devices.
|
||||
*/
|
||||
devices: Array<IDevice>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each device type to a display name and icon.
|
||||
*/
|
||||
const deviceInfoMap = {
|
||||
BLUETOOTH: {
|
||||
icon: IconBluetooth,
|
||||
text: 'audioDevices.bluetooth',
|
||||
type: 'BLUETOOTH'
|
||||
},
|
||||
CAR: {
|
||||
icon: IconCar,
|
||||
text: 'audioDevices.car',
|
||||
type: 'CAR'
|
||||
},
|
||||
EARPIECE: {
|
||||
icon: IconPhoneRinging,
|
||||
text: 'audioDevices.phone',
|
||||
type: 'EARPIECE'
|
||||
},
|
||||
HEADPHONES: {
|
||||
icon: IconDeviceHeadphone,
|
||||
text: 'audioDevices.headphones',
|
||||
type: 'HEADPHONES'
|
||||
},
|
||||
SPEAKER: {
|
||||
icon: IconVolumeUp,
|
||||
text: 'audioDevices.speaker',
|
||||
type: 'SPEAKER'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a React {@code Component} which prompts the user when a password
|
||||
* is required to join a conference.
|
||||
*/
|
||||
class AudioRoutePickerDialog extends Component<IProps, IState> {
|
||||
override state = {
|
||||
/**
|
||||
* Available audio devices, it will be set in
|
||||
* {@link #getDerivedStateFromProps()}.
|
||||
*/
|
||||
devices: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#getDerivedStateFromProps()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
static getDerivedStateFromProps(props: IProps) {
|
||||
const { _devices: devices, t } = props;
|
||||
|
||||
if (!devices) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioDevices = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const infoMap = deviceInfoMap[device.type as keyof typeof deviceInfoMap];
|
||||
|
||||
// Skip devices with unknown type.
|
||||
if (!infoMap) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let text = t(infoMap.text);
|
||||
|
||||
// iOS provides descriptive names for these, use it.
|
||||
if ((device.type === 'BLUETOOTH' || device.type === 'CAR') && device.name) {
|
||||
text = device.name;
|
||||
}
|
||||
|
||||
if (infoMap) {
|
||||
const info = {
|
||||
...infoMap,
|
||||
selected: Boolean(device.selected),
|
||||
text,
|
||||
uid: device.uid
|
||||
};
|
||||
|
||||
audioDevices.push(info);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure devices is alphabetically sorted.
|
||||
return {
|
||||
devices: sortBy(audioDevices, 'text')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new {@code PasswordRequiredPrompt} instance.
|
||||
*
|
||||
* @param {IProps} props - The read-only React {@code Component} props with
|
||||
* which the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Trigger an initial update.
|
||||
AudioMode.updateDeviceList?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns a function which handles the selection of a device
|
||||
* on the sheet. The selected device will be used by {@code AudioMode}.
|
||||
*
|
||||
* @param {IDevice} device - Object representing the selected device.
|
||||
* @private
|
||||
* @returns {Function}
|
||||
*/
|
||||
_onSelectDeviceFn(device: IDevice) {
|
||||
return () => {
|
||||
this.props.dispatch(hideSheet());
|
||||
AudioMode.setAudioDevice(device.uid || device.type);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single device.
|
||||
*
|
||||
* @param {IDevice} device - Object representing a single device.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderDevice(device: IDevice) {
|
||||
const { icon, selected, text } = device;
|
||||
const selectedStyle = selected ? styles.selectedText : {};
|
||||
const borderRadiusHighlightStyles = {
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16
|
||||
};
|
||||
const speakerDeviceIsNotSelected = device.type !== 'SPEAKER';
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
key = { device.type }
|
||||
onPress = { this._onSelectDeviceFn(device) }
|
||||
style = { speakerDeviceIsNotSelected && borderRadiusHighlightStyles }
|
||||
underlayColor = { BaseTheme.palette.ui04 } >
|
||||
<View style = { styles.deviceRow as ViewStyle } >
|
||||
<Icon
|
||||
src = { icon }
|
||||
style = { [ styles.deviceIcon, bottomSheetStyles.buttons.iconStyle, selectedStyle
|
||||
] as StyleType[] } />
|
||||
<Text
|
||||
style = { [ styles.deviceText, bottomSheetStyles.buttons.labelStyle, selectedStyle
|
||||
] as TextStyle[] } >
|
||||
{ text }
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a "fake" device row indicating there are no devices.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderNoDevices() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.deviceRow as ViewStyle } >
|
||||
<Icon
|
||||
src = { deviceInfoMap.SPEAKER.icon }
|
||||
style = { [ styles.deviceIcon, bottomSheetStyles.buttons.iconStyle ] as StyleType[] } />
|
||||
<Text style = { [ styles.deviceText, bottomSheetStyles.buttons.labelStyle ] as TextStyle[] } >
|
||||
{ t('audioDevices.none') }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { devices } = this.state;
|
||||
let content;
|
||||
|
||||
if (devices.length === 0) {
|
||||
content = this._renderNoDevices();
|
||||
} else {
|
||||
content = this.state.devices.map(this._renderDevice, this);
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
{ content }
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the Redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
return {
|
||||
_devices: state['features/mobile/audio-mode'].devices
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AudioRoutePickerDialog));
|
||||
46
react/features/mobile/audio-mode/components/styles.ts
Normal file
46
react/features/mobile/audio-mode/components/styles.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MD_ITEM_HEIGHT } from '../../../base/dialog/components/native/styles';
|
||||
import { createStyleSheet } from '../../../base/styles/functions.any';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/**
|
||||
* The React {@code Component} styles of {@code AudioRoutePickerDialog}.
|
||||
*
|
||||
* It uses a {@code BottomSheet} and these have been implemented as per the
|
||||
* Material Design guidelines:
|
||||
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
/**
|
||||
* Base style for each row.
|
||||
*/
|
||||
deviceRow: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
height: MD_ITEM_HEIGHT,
|
||||
marginLeft: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the {@code Icon} element in a row.
|
||||
*/
|
||||
deviceIcon: {
|
||||
color: BaseTheme.palette.icon01,
|
||||
fontSize: BaseTheme.spacing[4]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the {@code Text} element in a row.
|
||||
*/
|
||||
deviceText: {
|
||||
color: BaseTheme.palette.text01,
|
||||
fontSize: 16,
|
||||
marginLeft: BaseTheme.spacing[5]
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for a row which is marked as selected.
|
||||
*/
|
||||
selectedText: {
|
||||
color: BaseTheme.palette.action01
|
||||
}
|
||||
});
|
||||
3
react/features/mobile/audio-mode/logger.ts
Normal file
3
react/features/mobile/audio-mode/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../../base/logging/functions';
|
||||
|
||||
export default getLogger('features/mobile/audio-mode');
|
||||
176
react/features/mobile/audio-mode/middleware.ts
Normal file
176
react/features/mobile/audio-mode/middleware.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { NativeEventEmitter, NativeModules } from 'react-native';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
import { IStore } from '../../app/types';
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
|
||||
import { SET_AUDIO_ONLY } from '../../base/audio-only/actionTypes';
|
||||
import {
|
||||
CONFERENCE_FAILED,
|
||||
CONFERENCE_JOINED,
|
||||
CONFERENCE_LEFT
|
||||
} from '../../base/conference/actionTypes';
|
||||
import { getCurrentConference } from '../../base/conference/functions';
|
||||
import { SET_CONFIG } from '../../base/config/actionTypes';
|
||||
import { AUDIO_FOCUS_DISABLED } from '../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../base/flags/functions';
|
||||
import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
|
||||
import { parseURIString } from '../../base/util/uri';
|
||||
|
||||
import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes';
|
||||
import logger from './logger';
|
||||
|
||||
const { AudioMode } = NativeModules;
|
||||
const AudioModeEmitter = new NativeEventEmitter(AudioMode);
|
||||
|
||||
/**
|
||||
* Middleware that captures conference actions and sets the correct audio mode
|
||||
* based on the type of conference. Audio-only conferences don't use the speaker
|
||||
* by default, and video conferences do.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
/* eslint-disable no-fallthrough */
|
||||
|
||||
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:
|
||||
case CONFERENCE_LEFT:
|
||||
|
||||
/*
|
||||
* 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);
|
||||
|
||||
case SET_CONFIG: {
|
||||
const { locationURL } = store.getState()['features/base/connection'];
|
||||
const location = parseURIString(locationURL?.href ?? '');
|
||||
|
||||
/**
|
||||
* Don't touch the current value if there is no room in the URL. This
|
||||
* avoids audio cutting off for a moment right after the user leaves
|
||||
* a meeting. The next meeting join will set it to the right value.
|
||||
*/
|
||||
if (location.room) {
|
||||
const { startSilent } = action.config;
|
||||
|
||||
AudioMode.setDisabled?.(Boolean(startSilent));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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: IStore) {
|
||||
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: any) {
|
||||
// @ts-ignore
|
||||
const { dispatch } = this; // eslint-disable-line @typescript-eslint/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 }: IStore) {
|
||||
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 }: IStore, next: Function, action: AnyAction) {
|
||||
const result = next(action);
|
||||
const state = getState();
|
||||
const conference = getCurrentConference(state);
|
||||
const { enabled: audioOnly } = state['features/base/audio-only'];
|
||||
let mode: string;
|
||||
|
||||
if (getFeatureFlag(state, AUDIO_FOCUS_DISABLED, false)) {
|
||||
return result;
|
||||
} else if (conference) {
|
||||
mode = audioOnly ? AudioMode.AUDIO_CALL : AudioMode.VIDEO_CALL;
|
||||
} else {
|
||||
mode = AudioMode.DEFAULT;
|
||||
}
|
||||
|
||||
AudioMode.setMode(mode).catch((err: any) => logger.error(`Failed to set audio mode ${String(mode)}: ${err}`));
|
||||
|
||||
return result;
|
||||
}
|
||||
36
react/features/mobile/audio-mode/reducer.ts
Normal file
36
react/features/mobile/audio-mode/reducer.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import ReducerRegistry from '../../base/redux/ReducerRegistry';
|
||||
import { equals, set } from '../../base/redux/functions';
|
||||
|
||||
import { _SET_AUDIOMODE_DEVICES, _SET_AUDIOMODE_SUBSCRIPTIONS } from './actionTypes';
|
||||
import { IRawDevice } from './components/AudioRoutePickerDialog';
|
||||
|
||||
export interface IMobileAudioModeState {
|
||||
devices: IRawDevice[];
|
||||
subscriptions: {
|
||||
remove: Function;
|
||||
}[];
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
devices: [],
|
||||
subscriptions: []
|
||||
};
|
||||
|
||||
ReducerRegistry.register<IMobileAudioModeState>('features/mobile/audio-mode',
|
||||
(state = DEFAULT_STATE, action): IMobileAudioModeState => {
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user