fix(base/tracks): handle GUM in progress

This commit adds extra actions/Redux state to be able to deal with
the GUM operation being in progress. There will be early local track
stub in the Redux state for any a local track for which GUM has been
called, but not completed yet.

Local track is considered valid only after TRACK_ADDED event when it
will have JitsiLocalTrack instance set.
This commit is contained in:
paweldomas 2017-11-09 15:59:31 -06:00 committed by Lyubo Marinov
parent 90dcb251c3
commit f37a12c332
6 changed files with 220 additions and 114 deletions

View File

@ -12,7 +12,7 @@ import {
participantRoleChanged, participantRoleChanged,
participantUpdated participantUpdated
} from '../participants'; } from '../participants';
import { trackAdded, trackRemoved } from '../tracks'; import { getLocalTracks, trackAdded, trackRemoved } from '../tracks';
import { import {
CONFERENCE_FAILED, CONFERENCE_FAILED,
@ -222,8 +222,7 @@ export function conferenceLeft(conference: Object) {
function _conferenceWillJoin(conference: Object) { function _conferenceWillJoin(conference: Object) {
return (dispatch: Dispatch<*>, getState: Function) => { return (dispatch: Dispatch<*>, getState: Function) => {
const localTracks const localTracks
= getState()['features/base/tracks'] = getLocalTracks(getState()['features/base/tracks'])
.filter(t => t.local)
.map(t => t.jitsiTrack); .map(t => t.jitsiTrack);
if (localTracks.length) { if (localTracks.length) {

View File

@ -10,15 +10,45 @@
export const TRACK_ADDED = Symbol('TRACK_ADDED'); export const TRACK_ADDED = Symbol('TRACK_ADDED');
/** /**
* Action for when a track cannot be created because permissions have not been * Action triggered when a local track starts being created through the WebRTC
* granted. * getUserMedia call. It will include extra 'gumProcess' field which is
* a Promise with extra 'cancel' method which can be used to cancel the process.
* Canceling will result in disposing any JitsiLocalTrack returned by the GUM
* callback. There will be TRACK_CREATE_CANCELED event instead of track
* added/gum failed events.
* *
* { * {
* type: TRACK_PERMISSION_ERROR, * type: TRACK_BEING_CREATED
* trackType: string * track: {
* local: true,
* gumProcess: Promise with cancel() method to abort,
* mediaType: MEDIA_TYPE
* }
* } * }
*/ */
export const TRACK_PERMISSION_ERROR = Symbol('TRACK_PERMISSION_ERROR'); export const TRACK_BEING_CREATED = Symbol('TRACK_BEING_CREATED');
/**
* Action sent when canceled GUM process completes either successfully or with
* an error (error is ignored and track is immediately disposed if created).
*
* {
* type: TRACK_CREATE_CANCELED,
* trackType: MEDIA_TYPE
* }
*/
export const TRACK_CREATE_CANCELED = Symbol('TRACK_CREATE_CANCELED');
/**
* Action sent when GUM fails with an error other than permission denied.
*
* {
* type: TRACK_CREATE_ERROR,
* permissionDenied: Boolean,
* trackType: MEDIA_TYPE
* }
*/
export const TRACK_CREATE_ERROR = Symbol('TRACK_CREATE_ERROR');
/** /**
* Action for when a track has been removed from the conference, * Action for when a track has been removed from the conference,

View File

@ -10,7 +10,9 @@ import { getLocalParticipant } from '../participants';
import { import {
TRACK_ADDED, TRACK_ADDED,
TRACK_PERMISSION_ERROR, TRACK_BEING_CREATED,
TRACK_CREATE_CANCELED,
TRACK_CREATE_ERROR,
TRACK_REMOVED, TRACK_REMOVED,
TRACK_UPDATED TRACK_UPDATED
} from './actionTypes'; } from './actionTypes';
@ -79,7 +81,11 @@ export function createLocalTracksA(options = {}) {
// to implement them) and the right thing to do is to ask for each // to implement them) and the right thing to do is to ask for each
// device separately. // device separately.
for (const device of devices) { for (const device of devices) {
createLocalTracksF( if (getState()['features/base/tracks']
.find(t => t.local && t.mediaType === device)) {
throw new Error(`Local track for ${device} already exists`);
}
const gumProcess = createLocalTracksF(
{ {
cameraDeviceId: options.cameraDeviceId, cameraDeviceId: options.cameraDeviceId,
devices: [ device ], devices: [ device ],
@ -89,9 +95,48 @@ export function createLocalTracksA(options = {}) {
/* firePermissionPromptIsShownEvent */ false, /* firePermissionPromptIsShownEvent */ false,
store) store)
.then( .then(
localTracks => dispatch(_updateLocalTracks(localTracks)), localTracks => {
// Because GUM is called for 1 device (which is actually
// a media type 'audio','video', 'screen' etc.) we should
// not get more than one JitsiTrack.
if (localTracks.length !== 1) {
throw new Error(
'Expected exactly 1 track, but was '
+ `given ${localTracks.length} tracks`
+ `for device: ${device}.`);
}
if (gumProcess.canceled) {
return _disposeTracks(localTracks)
.then(
() =>
dispatch(
_trackCreateCanceled(device)));
}
return dispatch(trackAdded(localTracks[0]));
},
// eslint-disable-next-line no-confusing-arrow
reason => reason =>
dispatch(_onCreateLocalTracksRejected(reason, device))); dispatch(
gumProcess.canceled
? _trackCreateCanceled(device)
: _onCreateLocalTracksRejected(reason, device)));
gumProcess.cancel = () => {
gumProcess.canceled = true;
return gumProcess;
};
dispatch({
type: TRACK_BEING_CREATED,
track: {
local: true,
gumProcess,
mediaType: device
}
});
} }
}; };
} }
@ -103,12 +148,17 @@ export function createLocalTracksA(options = {}) {
* @returns {Function} * @returns {Function}
*/ */
export function destroyLocalTracks() { export function destroyLocalTracks() {
return (dispatch, getState) => return (dispatch, getState) => {
dispatch( // First wait until any getUserMedia in progress is settled and then get
_disposeAndRemoveTracks( // rid of all local tracks.
getState()['features/base/tracks'] _cancelAllGumInProgress(getState)
.filter(t => t.local) .then(
.map(t => t.jitsiTrack))); () => dispatch(
_disposeAndRemoveTracks(
getState()['features/base/tracks']
.filter(t => t.local)
.map(t => t.jitsiTrack))));
};
} }
/** /**
@ -315,6 +365,52 @@ function _addTracks(tracks) {
return dispatch => Promise.all(tracks.map(t => dispatch(trackAdded(t)))); return dispatch => Promise.all(tracks.map(t => dispatch(trackAdded(t))));
} }
/**
* Signals that track create operation for given media track has been canceled.
* Will clean up local track stub from the Redux state which holds the
* 'gumProcess' reference.
*
* @param {MEDIA_TYPE} mediaType - The type of the media for which the track was
* being created.
* @returns {{
* type,
* trackType: MEDIA_TYPE
* }}
* @private
*/
function _trackCreateCanceled(mediaType) {
return {
type: TRACK_CREATE_CANCELED,
trackType: mediaType
};
}
/**
* Cancels and waits for any get user media operations currently in progress to
* complete.
*
* @param {Function} getState - The Redux store {@code getState} method used to
* obtain the state.
* @returns {Promise} - A Promise resolved once all {@code gumProcess.cancel}
* Promises are settled. That is when they are either resolved or rejected,
* because all we care about here is to be sure that get user media callbacks
* have completed (returned from the native side).
* @private
*/
function _cancelAllGumInProgress(getState) {
// FIXME use logger
const logError
= error =>
console.error('gumProcess.cancel failed', JSON.stringify(error));
return Promise.all(
getState()['features/base/tracks']
.filter(t => t.local)
.map(
t => t.gumProcess
&& t.gumProcess.cancel().catch(logError)));
}
/** /**
* Disposes passed tracks and signals them to be removed. * Disposes passed tracks and signals them to be removed.
* *
@ -324,73 +420,31 @@ function _addTracks(tracks) {
*/ */
export function _disposeAndRemoveTracks(tracks) { export function _disposeAndRemoveTracks(tracks) {
return dispatch => return dispatch =>
Promise.all( _disposeTracks(tracks)
tracks.map(t => .then(
t.dispose() () => Promise.all(tracks.map(t => dispatch(trackRemoved(t)))));
.catch(err => {
// Track might be already disposed so ignore such an
// error. Of course, re-throw any other error(s).
if (err.name !== JitsiTrackErrors.TRACK_IS_DISPOSED) {
throw err;
}
})
))
.then(Promise.all(tracks.map(t => dispatch(trackRemoved(t)))));
} }
/** /**
* Finds the first {@code JitsiLocalTrack} in a specific array/list of * Disposes passed tracks.
* {@code JitsiTrack}s which is of a specific {@code MEDIA_TYPE}.
* *
* @param {JitsiTrack[]} tracks - The array/list of {@code JitsiTrack}s to look * @param {(JitsiLocalTrack|JitsiRemoteTrack)[]} tracks - List of tracks.
* through. * @protected
* @param {MEDIA_TYPE} mediaType - The {@code MEDIA_TYPE} of the first * @returns {Promise} - A Promise resolved once {@link JitsiTrack.dispose()} is
* {@code JitsiLocalTrack} to be returned. * done for every track from the list.
* @private
* @returns {JitsiLocalTrack} The first {@code JitsiLocalTrack}, if any, in the
* specified {@code tracks} of the specified {@code mediaType}.
*/ */
function _getLocalTrack(tracks, mediaType) { function _disposeTracks(tracks) {
return tracks.find(track => return Promise.all(
track.isLocal() tracks.map(t =>
t.dispose()
// XXX JitsiTrack#getType() returns a MEDIA_TYPE value in the terms .catch(err => {
// of lib-jitsi-meet while mediaType is in the terms of jitsi-meet. // Track might be already disposed so ignore such an
&& track.getType() === mediaType); // error. Of course, re-throw any other error(s).
} if (err.name !== JitsiTrackErrors.TRACK_IS_DISPOSED) {
throw err;
/** }
* Determines which local media tracks should be added and which removed. })
* ));
* @param {(JitsiLocalTrack|JitsiRemoteTrack)[]} currentTracks - List of
* current/existing media tracks.
* @param {(JitsiLocalTrack|JitsiRemoteTrack)[]} newTracks - List of new media
* tracks.
* @private
* @returns {{
* tracksToAdd: JitsiLocalTrack[],
* tracksToRemove: JitsiLocalTrack[]
* }}
*/
function _getLocalTracksToChange(currentTracks, newTracks) {
const tracksToAdd = [];
const tracksToRemove = [];
for (const mediaType of [ MEDIA_TYPE.AUDIO, MEDIA_TYPE.VIDEO ]) {
const newTrack = _getLocalTrack(newTracks, mediaType);
if (newTrack) {
const currentTrack = _getLocalTrack(currentTracks, mediaType);
tracksToAdd.push(newTrack);
currentTrack && tracksToRemove.push(currentTrack);
}
}
return {
tracksToAdd,
tracksToRemove
};
} }
/** /**
@ -430,8 +484,10 @@ function _onCreateLocalTracksRejected({ gum }, device) {
trackPermissionError = error instanceof DOMException; trackPermissionError = error instanceof DOMException;
break; break;
} }
trackPermissionError && dispatch({
type: TRACK_PERMISSION_ERROR, dispatch({
type: TRACK_CREATE_ERROR,
permissionDenied: trackPermissionError,
trackType: device trackType: device
}); });
} }
@ -468,24 +524,3 @@ function _shouldMirror(track) {
// but that may not be the case tomorrow. // but that may not be the case tomorrow.
&& track.getCameraFacingMode() === CAMERA_FACING_MODE.USER); && track.getCameraFacingMode() === CAMERA_FACING_MODE.USER);
} }
/**
* Set new local tracks replacing any existing tracks that were previously
* available. Currently only one audio and one video local tracks are allowed.
*
* @param {(JitsiLocalTrack|JitsiRemoteTrack)[]} [newTracks=[]] - List of new
* media tracks.
* @private
* @returns {Function}
*/
function _updateLocalTracks(newTracks = []) {
return (dispatch, getState) => {
const tracks
= getState()['features/base/tracks'].map(t => t.jitsiTrack);
const { tracksToAdd, tracksToRemove }
= _getLocalTracksToChange(tracks, newTracks);
return dispatch(_disposeAndRemoveTracks(tracksToRemove))
.then(() => dispatch(_addTracks(tracksToAdd)));
};
}

View File

@ -106,7 +106,26 @@ export function getLocalAudioTrack(tracks) {
* @returns {(Track|undefined)} * @returns {(Track|undefined)}
*/ */
export function getLocalTrack(tracks, mediaType) { export function getLocalTrack(tracks, mediaType) {
return tracks.find(t => t.local && t.mediaType === mediaType); return getLocalTracks(tracks).find(t => t.mediaType === mediaType);
}
/**
* Returns an array containing local tracks. Local tracks without valid
* JitsiTrack will not be included in the list.
*
* @param {Track[]} tracks - An array of all local tracks.
* @returns {Track[]}
*/
export function getLocalTracks(tracks) {
// XXX A local track is considered ready only once it has 'jitsiTrack' field
// set by the TRACK_ADDED action. Until then there is a stub added just
// before get user media call with a cancellable 'gumInProgress' field which
// then can be used to destroy the track that has not yet been added to
// the Redux store. Once GUM is cancelled it will never make it to the store
// nor there will be any TRACK_ADDED/TRACK_REMOVED related events fired for
// it.
return tracks.filter(t => t.local && t.jitsiTrack);
} }
/** /**

View File

@ -3,15 +3,22 @@ import { ReducerRegistry } from '../redux';
import { import {
TRACK_ADDED, TRACK_ADDED,
TRACK_BEING_CREATED,
TRACK_CREATE_CANCELED,
TRACK_CREATE_ERROR,
TRACK_REMOVED, TRACK_REMOVED,
TRACK_UPDATED TRACK_UPDATED
} from './actionTypes'; } from './actionTypes';
/** /**
* @typedef {Object} Track * @typedef {Object} Track
* @property {(JitsiLocalTrack|JitsiRemoteTrack)} jitsiTrack - JitsiTrack * @property {(JitsiLocalTrack|JitsiRemoteTrack)} [jitsiTrack] - JitsiTrack
* instance. * instance. Optional for local tracks if those are being created (GUM in
* progress).
* @property {boolean} local=false - If track is local. * @property {boolean} local=false - If track is local.
* @property {Promise} [gumProcess] - if local track is being created it
* will have no JitsiTrack, but a 'gumProcess' set to a Promise with and extra
* cancel().
* @property {MEDIA_TYPE} mediaType=false - Media type of track. * @property {MEDIA_TYPE} mediaType=false - Media type of track.
* @property {boolean} mirror=false - The indicator which determines whether the * @property {boolean} mirror=false - The indicator which determines whether the
* display/rendering of the track should be mirrored. It only makes sense in the * display/rendering of the track should be mirrored. It only makes sense in the
@ -81,11 +88,25 @@ ReducerRegistry.register('features/base/tracks', (state = [], action) => {
case TRACK_UPDATED: case TRACK_UPDATED:
return state.map(t => track(t, action)); return state.map(t => track(t, action));
case TRACK_ADDED: case TRACK_ADDED: {
return [ let withoutTrackStub = state;
...state,
action.track if (action.track.local) {
]; withoutTrackStub
= state.filter(
t => !t.local || t.mediaType !== action.track.mediaType);
}
return [ ...withoutTrackStub, action.track ];
}
case TRACK_BEING_CREATED:
return [ ...state, action.track ];
case TRACK_CREATE_CANCELED:
case TRACK_CREATE_ERROR: {
return state.filter(t => !t.local || t.mediaType !== action.trackType);
}
case TRACK_REMOVED: case TRACK_REMOVED:
return state.filter(t => t.jitsiTrack !== action.track.jitsiTrack); return state.filter(t => t.jitsiTrack !== action.track.jitsiTrack);

View File

@ -5,7 +5,7 @@ import { Alert, Linking, NativeModules } from 'react-native';
import { isRoomValid } from '../../base/conference'; import { isRoomValid } from '../../base/conference';
import { Platform } from '../../base/react'; import { Platform } from '../../base/react';
import { MiddlewareRegistry } from '../../base/redux'; import { MiddlewareRegistry } from '../../base/redux';
import { TRACK_PERMISSION_ERROR } from '../../base/tracks'; import { TRACK_CREATE_ERROR } from '../../base/tracks';
/** /**
* Middleware that captures track permission errors and alerts the user so they * Middleware that captures track permission errors and alerts the user so they
@ -18,14 +18,16 @@ MiddlewareRegistry.register(store => next => action => {
const result = next(action); const result = next(action);
switch (action.type) { switch (action.type) {
case TRACK_PERMISSION_ERROR: case TRACK_CREATE_ERROR:
// XXX We do not currently have user interface outside of a conference // XXX We do not currently have user interface outside of a conference
// which the user may tap and cause a permission-related error. If we // which the user may tap and cause a permission-related error. If we
// alert whenever we (intend to) ask for a permission, the scenario of // alert whenever we (intend to) ask for a permission, the scenario of
// entering the WelcomePage, being asked for the camera permission, me // entering the WelcomePage, being asked for the camera permission, me
// denying it, and being alerted that there is an error is overwhelming // denying it, and being alerted that there is an error is overwhelming
// me. // me.
if (isRoomValid(store.getState()['features/base/conference'].room)) { if (action.permissionDenied
&& isRoomValid(
store.getState()['features/base/conference'].room)) {
_alertPermissionErrorWithSettings(action.trackType); _alertPermissionErrorWithSettings(action.trackType);
} }
break; break;