[RN] Add audio only mode for conferences

The behavior can be triggered with the toggleAudioOnly action, which is
currently fired with a button.

The following aspects of the conference will change when in audio only mode:

- local video is muted
- last N is set to 0 (effectively muting remote video)
- full-screen mode is exited
- audio mode is set to "audio chat" (default output is the earpiece)
- the wake lock is disengaged

One aspect not handled in this patch is disabling the video mute button while in
audio only mode. The user should not be able to turn back video on in that case.
This commit is contained in:
Saúl Ibarra Corretgé 2017-03-29 14:07:05 +02:00 committed by Lyubo Marinov
parent 4ec4c45a90
commit 8fe3dce649
9 changed files with 235 additions and 25 deletions

View File

@ -68,6 +68,31 @@ export const CONFERENCE_WILL_LEAVE = Symbol('CONFERENCE_WILL_LEAVE');
*/ */
export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED'); export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED');
/**
* The type of the Redux action which sets the audio-only flag for the current
* conference.
*
* {
* type: SET_AUDIO_ONLY,
* audioOnly: boolean
* }
*/
export const SET_AUDIO_ONLY = Symbol('SET_AUDIO_ONLY');
/**
* The type of redux action which signals that video will be muted because the
* audio-only mode was enabled / disabled.
*
* {
* type: _SET_AUDIO_ONLY_VIDEO_MUTED,
* muted: boolean
* }
*
* @protected
*/
export const _SET_AUDIO_ONLY_VIDEO_MUTED
= Symbol('_SET_AUDIO_ONLY_VIDEO_MUTED');
/** /**
* The type of redux action which sets the video channel's lastN (value). * The type of redux action which sets the video channel's lastN (value).
* *
@ -75,7 +100,6 @@ export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED');
* type: SET_LASTN, * type: SET_LASTN,
* lastN: number * lastN: number
* } * }
*
*/ */
export const SET_LASTN = Symbol('SET_LASTN'); export const SET_LASTN = Symbol('SET_LASTN');

View File

@ -1,4 +1,5 @@
import { JitsiConferenceEvents } from '../lib-jitsi-meet'; import { JitsiConferenceEvents } from '../lib-jitsi-meet';
import { setVideoMuted } from '../media';
import { import {
dominantSpeakerChanged, dominantSpeakerChanged,
getLocalParticipant, getLocalParticipant,
@ -17,6 +18,8 @@ import {
CONFERENCE_WILL_JOIN, CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE, CONFERENCE_WILL_LEAVE,
LOCK_STATE_CHANGED, LOCK_STATE_CHANGED,
SET_AUDIO_ONLY,
_SET_AUDIO_ONLY_VIDEO_MUTED,
SET_LASTN, SET_LASTN,
SET_PASSWORD, SET_PASSWORD,
SET_ROOM SET_ROOM
@ -295,6 +298,63 @@ function _lockStateChanged(conference, locked) {
}; };
} }
/**
* Sets the audio-only flag for the current JitsiConference.
*
* @param {boolean} audioOnly - True if the conference should be audio only;
* false, otherwise.
* @private
* @returns {{
* type: SET_AUDIO_ONLY,
* audioOnly: boolean
* }}
*/
function _setAudioOnly(audioOnly) {
return {
type: SET_AUDIO_ONLY,
audioOnly
};
}
/**
* Signals that the app should mute video because it's now in audio-only mode,
* or unmute it because it no longer is. If video was already muted, nothing
* will happen; otherwise, it will be muted. When audio-only mode is disabled,
* the previous state will be restored.
*
* @param {boolean} muted - True if video should be muted; false, otherwise.
* @protected
* @returns {Function}
*/
export function _setAudioOnlyVideoMuted(muted: boolean) {
return (dispatch, getState) => {
if (muted) {
const { video } = getState()['features/base/media'];
if (video.muted) {
// Video is already muted, do nothing.
return;
}
} else {
const { audioOnlyVideoMuted }
= getState()['features/base/conference'];
if (!audioOnlyVideoMuted) {
// We didn't mute video, do nothing.
return;
}
}
// Remember that local video was muted due to the audio-only mode
// vs user's choice.
dispatch({
type: _SET_AUDIO_ONLY_VIDEO_MUTED,
muted
});
dispatch(setVideoMuted(muted));
};
}
/** /**
* Sets the video channel's last N (value) of the current conference. A value of * Sets the video channel's last N (value) of the current conference. A value of
* undefined shall be used to reset it to the default value. * undefined shall be used to reset it to the default value.
@ -403,3 +463,16 @@ export function setRoom(room) {
room room
}; };
} }
/**
* Toggles the audio-only flag for the current JitsiConference.
*
* @returns {Function}
*/
export function toggleAudioOnly() {
return (dispatch: Dispatch<*>, getState: Function) => {
const { audioOnly } = getState()['features/base/conference'];
return dispatch(_setAudioOnly(!audioOnly));
};
}

View File

@ -8,8 +8,12 @@ import {
import { MiddlewareRegistry } from '../redux'; import { MiddlewareRegistry } from '../redux';
import { TRACK_ADDED, TRACK_REMOVED } from '../tracks'; import { TRACK_ADDED, TRACK_REMOVED } from '../tracks';
import { createConference } from './actions'; import {
import { SET_LASTN } from './actionTypes'; createConference,
_setAudioOnlyVideoMuted,
setLastN
} from './actions';
import { SET_AUDIO_ONLY, SET_LASTN } from './actionTypes';
import { import {
_addLocalTracksToConference, _addLocalTracksToConference,
_handleParticipantError, _handleParticipantError,
@ -30,6 +34,9 @@ MiddlewareRegistry.register(store => next => action => {
case PIN_PARTICIPANT: case PIN_PARTICIPANT:
return _pinParticipant(store, next, action); return _pinParticipant(store, next, action);
case SET_AUDIO_ONLY:
return _setAudioOnly(store, next, action);
case SET_LASTN: case SET_LASTN:
return _setLastN(store, next, action); return _setLastN(store, next, action);
@ -116,6 +123,35 @@ function _pinParticipant(store, next, action) {
return next(action); return next(action);
} }
/**
* Sets the audio-only flag for the current conference. When audio-only is set,
* local video is muted and last N is set to 0 to avoid receiving remote video.
*
* @param {Store} store - The Redux store in which the specified action is being
* dispatched.
* @param {Dispatch} next - The Redux dispatch function to dispatch the
* specified action to the specified store.
* @param {Action} action - The Redux action SET_AUDIO_ONLY which is being
* dispatched in the specified store.
* @private
* @returns {Object} The new state that is the result of the reduction of the
* specified action.
*/
function _setAudioOnly(store, next, action) {
const result = next(action);
const { audioOnly } = action;
// Set lastN to 0 in case audio-only is desired; leave it as undefined,
// otherwise, and the default lastN value will be chosen automatically.
store.dispatch(setLastN(audioOnly ? 0 : undefined));
// Mute local video
store.dispatch(_setAudioOnlyVideoMuted(audioOnly));
return result;
}
/** /**
* Sets the last N (value) of the video channel in the conference. * Sets the last N (value) of the video channel in the conference.
* *

View File

@ -11,6 +11,8 @@ import {
CONFERENCE_LEFT, CONFERENCE_LEFT,
CONFERENCE_WILL_LEAVE, CONFERENCE_WILL_LEAVE,
LOCK_STATE_CHANGED, LOCK_STATE_CHANGED,
SET_AUDIO_ONLY,
_SET_AUDIO_ONLY_VIDEO_MUTED,
SET_PASSWORD, SET_PASSWORD,
SET_ROOM SET_ROOM
} from './actionTypes'; } from './actionTypes';
@ -37,6 +39,12 @@ ReducerRegistry.register('features/base/conference', (state = {}, action) => {
case LOCK_STATE_CHANGED: case LOCK_STATE_CHANGED:
return _lockStateChanged(state, action); return _lockStateChanged(state, action);
case SET_AUDIO_ONLY:
return _setAudioOnly(state, action);
case _SET_AUDIO_ONLY_VIDEO_MUTED:
return _setAudioOnlyVideoMuted(state, action);
case SET_PASSWORD: case SET_PASSWORD:
return _setPassword(state, action); return _setPassword(state, action);
@ -71,6 +79,8 @@ function _conferenceFailed(state, action) {
return ( return (
setStateProperties(state, { setStateProperties(state, {
audioOnly: undefined,
audioOnlyVideoMuted: undefined,
conference: undefined, conference: undefined,
leaving: undefined, leaving: undefined,
locked: undefined, locked: undefined,
@ -144,6 +154,8 @@ function _conferenceLeft(state, action) {
return ( return (
setStateProperties(state, { setStateProperties(state, {
audioOnly: undefined,
audioOnlyVideoMuted: undefined,
conference: undefined, conference: undefined,
leaving: undefined, leaving: undefined,
locked: undefined, locked: undefined,
@ -200,6 +212,35 @@ function _lockStateChanged(state, action) {
return setStateProperty(state, 'locked', action.locked || undefined); return setStateProperty(state, 'locked', action.locked || undefined);
} }
/**
* Reduces a specific Redux action SET_AUDIO_ONLY of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action SET_AUDIO_ONLY to reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _setAudioOnly(state, action) {
return setStateProperty(state, 'audioOnly', action.audioOnly);
}
/**
* Reduces a specific Redux action _SET_AUDIO_ONLY_VIDEO_MUTED of the feature
* base/conference.
*
* @param {Object} state - The Redux state of the feature base/conference.
* @param {Action} action - The Redux action SET_AUDIO_ONLY_VIDEO_MUTED to
* reduce.
* @private
* @returns {Object} The new state of the feature base/conference after the
* reduction of the specified action.
*/
function _setAudioOnlyVideoMuted(state, action) {
return setStateProperty(state, 'audioOnlyVideoMuted', action.muted);
}
/** /**
* Reduces a specific Redux action SET_PASSWORD of the feature base/conference. * Reduces a specific Redux action SET_PASSWORD of the feature base/conference.
* *

View File

@ -6,7 +6,8 @@ import { APP_WILL_MOUNT } from '../../app';
import { import {
CONFERENCE_FAILED, CONFERENCE_FAILED,
CONFERENCE_LEFT, CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN CONFERENCE_WILL_JOIN,
SET_AUDIO_ONLY
} from '../../base/conference'; } from '../../base/conference';
import { MiddlewareRegistry } from '../../base/redux'; import { MiddlewareRegistry } from '../../base/redux';
@ -21,8 +22,6 @@ import { MiddlewareRegistry } from '../../base/redux';
MiddlewareRegistry.register(store => next => action => { MiddlewareRegistry.register(store => next => action => {
const AudioMode = NativeModules.AudioMode; const AudioMode = NativeModules.AudioMode;
// The react-native module AudioMode is implemented on iOS at the time of
// this writing.
if (AudioMode) { if (AudioMode) {
let mode; let mode;
@ -34,14 +33,18 @@ MiddlewareRegistry.register(store => next => action => {
break; break;
case CONFERENCE_WILL_JOIN: { case CONFERENCE_WILL_JOIN: {
const conference = store.getState()['features/base/conference']; const { audioOnly } = store.getState()['features/base/conference'];
mode = audioOnly ? AudioMode.AUDIO_CALL : AudioMode.VIDEO_CALL;
break;
}
case SET_AUDIO_ONLY:
mode mode
= conference.audioOnly = action.audioOnly
? AudioMode.AUDIO_CALL ? AudioMode.AUDIO_CALL
: AudioMode.VIDEO_CALL; : AudioMode.VIDEO_CALL;
break; break;
}
default: default:
mode = null; mode = null;

View File

@ -42,10 +42,12 @@ export function _setBackgroundVideoMuted(muted: boolean) {
// for last N will be chosen automatically. // for last N will be chosen automatically.
const { audioOnly } = getState()['features/base/conference']; const { audioOnly } = getState()['features/base/conference'];
if (!audioOnly) { if (audioOnly) {
dispatch(setLastN(muted ? 0 : undefined)); return;
} }
dispatch(setLastN(muted ? 0 : undefined));
if (muted) { if (muted) {
const { video } = getState()['features/base/media']; const { video } = getState()['features/base/media'];

View File

@ -7,7 +7,8 @@ import { APP_STATE_CHANGED } from '../background';
import { import {
CONFERENCE_FAILED, CONFERENCE_FAILED,
CONFERENCE_LEFT, CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN CONFERENCE_WILL_JOIN,
SET_AUDIO_ONLY
} from '../../base/conference'; } from '../../base/conference';
import { Platform } from '../../base/react'; import { Platform } from '../../base/react';
import { MiddlewareRegistry } from '../../base/redux'; import { MiddlewareRegistry } from '../../base/redux';
@ -50,6 +51,10 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_LEFT: case CONFERENCE_LEFT:
fullScreen = false; fullScreen = false;
break; break;
case SET_AUDIO_ONLY:
fullScreen = !action.audioOnly;
break;
} }
if (fullScreen !== null) { if (fullScreen !== null) {

View File

@ -3,7 +3,8 @@ import KeepAwake from 'react-native-keep-awake';
import { import {
CONFERENCE_FAILED, CONFERENCE_FAILED,
CONFERENCE_JOINED, CONFERENCE_JOINED,
CONFERENCE_LEFT CONFERENCE_LEFT,
SET_AUDIO_ONLY
} from '../../base/conference'; } from '../../base/conference';
import { MiddlewareRegistry } from '../../base/redux'; import { MiddlewareRegistry } from '../../base/redux';
@ -18,12 +19,9 @@ import { MiddlewareRegistry } from '../../base/redux';
MiddlewareRegistry.register(store => next => action => { MiddlewareRegistry.register(store => next => action => {
switch (action.type) { switch (action.type) {
case CONFERENCE_JOINED: { case CONFERENCE_JOINED: {
const state = store.getState()['features/base/conference']; const { audioOnly } = store.getState()['features/base/conference'];
// TODO(saghul): Implement audio-only mode. _setWakeLock(!audioOnly);
if (!state.audioOnly) {
_setWakeLock(true);
}
break; break;
} }
@ -31,6 +29,10 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_LEFT: case CONFERENCE_LEFT:
_setWakeLock(false); _setWakeLock(false);
break; break;
case SET_AUDIO_ONLY:
_setWakeLock(!action.audioOnly);
break;
} }
return next(action); return next(action);

View File

@ -2,6 +2,7 @@ import React, { Component } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleAudioOnly } from '../../base/conference';
import { MEDIA_TYPE, toggleCameraFacingMode } from '../../base/media'; import { MEDIA_TYPE, toggleCameraFacingMode } from '../../base/media';
import { Container } from '../../base/react'; import { Container } from '../../base/react';
import { ColorPalette } from '../../base/styles'; import { ColorPalette } from '../../base/styles';
@ -40,7 +41,7 @@ class Toolbox extends Component {
_onHangup: React.PropTypes.func, _onHangup: React.PropTypes.func,
/** /**
* Handler for room locking. * Sets the lock i.e. password protection of the conference/room.
*/ */
_onRoomLock: React.PropTypes.func, _onRoomLock: React.PropTypes.func,
@ -50,7 +51,13 @@ class Toolbox extends Component {
_onToggleAudio: React.PropTypes.func, _onToggleAudio: React.PropTypes.func,
/** /**
* Handler for toggling camera facing mode. * Toggles the audio-only flag of the conference.
*/
_onToggleAudioOnly: React.PropTypes.func,
/**
* Switches between the front/user-facing and back/environment-facing
* cameras.
*/ */
_onToggleCameraFacingMode: React.PropTypes.func, _onToggleCameraFacingMode: React.PropTypes.func,
@ -198,6 +205,12 @@ class Toolbox extends Component {
onClick = { this.props._onRoomLock } onClick = { this.props._onRoomLock }
style = { style } style = { style }
underlayColor = { underlayColor } /> underlayColor = { underlayColor } />
<ToolbarButton
iconName = 'star'
iconStyle = { iconStyle }
onClick = { this.props._onToggleAudioOnly }
style = { style }
underlayColor = { underlayColor } />
</View> </View>
); );
@ -224,6 +237,7 @@ Object.assign(Toolbox.prototype, {
* @param {Function} dispatch - Redux action dispatcher. * @param {Function} dispatch - Redux action dispatcher.
* @returns {{ * @returns {{
* _onRoomLock: Function, * _onRoomLock: Function,
* _onToggleAudioOnly: Function,
* _onToggleCameraFacingMode: Function, * _onToggleCameraFacingMode: Function,
* }} * }}
* @private * @private
@ -233,11 +247,10 @@ function _mapDispatchToProps(dispatch) {
...abstractMapDispatchToProps(dispatch), ...abstractMapDispatchToProps(dispatch),
/** /**
* Dispatches an action to set the lock i.e. password protection of the * Sets the lock i.e. password protection of the conference/room.
* conference/room.
* *
* @private * @private
* @returns {Object} - Dispatched action. * @returns {Object} Dispatched action.
* @type {Function} * @type {Function}
*/ */
_onRoomLock() { _onRoomLock() {
@ -245,11 +258,22 @@ function _mapDispatchToProps(dispatch) {
}, },
/** /**
* Switches between the front/user-facing and rear/environment-facing * Toggles the audio-only flag of the conference.
*
* @private
* @returns {Object} Dispatched action.
* @type {Function}
*/
_onToggleAudioOnly() {
return dispatch(toggleAudioOnly());
},
/**
* Switches between the front/user-facing and back/environment-facing
* cameras. * cameras.
* *
* @private * @private
* @returns {Object} - Dispatched action. * @returns {Object} Dispatched action.
* @type {Function} * @type {Function}
*/ */
_onToggleCameraFacingMode() { _onToggleCameraFacingMode() {