diff --git a/conference.js b/conference.js index 16c7a8381..bb99581d8 100644 --- a/conference.js +++ b/conference.js @@ -25,6 +25,7 @@ import { conferenceFailed, conferenceJoined, conferenceLeft, + toggleAudioOnly, EMAIL_COMMAND, lockStateChanged } from './react/features/base/conference'; @@ -74,7 +75,7 @@ const eventEmitter = new EventEmitter(); let room; let connection; let localAudio, localVideo; -let initialAudioMutedState = false, initialVideoMutedState = false; +let initialAudioMutedState = false; import {VIDEO_CONTAINER_TYPE} from "./modules/UI/videolayout/VideoContainer"; @@ -177,27 +178,40 @@ function getDisplayName(id) { * result of user interaction */ function muteLocalAudio(muted) { - muteLocalMedia(localAudio, muted, 'Audio'); + muteLocalMedia(localAudio, muted); } -function muteLocalMedia(localMedia, muted, localMediaTypeString) { - if (!localMedia) { - return; +/** + * Mute or unmute local media stream if it exists. + * @param {JitsiLocalTrack} localTrack + * @param {boolean} muted + * + * @returns {Promise} resolved in case mute/unmute operations succeeds or + * rejected with an error if something goes wrong. It is expected that often + * the error will be of the {@link JitsiTrackError} type, but it's not + * guaranteed. + */ +function muteLocalMedia(localTrack, muted) { + if (!localTrack) { + return Promise.resolve(); } const method = muted ? 'mute' : 'unmute'; - localMedia[method]().catch(reason => { - logger.warn(`${localMediaTypeString} ${method} was rejected:`, reason); - }); + return localTrack[method](); } /** * Mute or unmute local video stream if it exists. * @param {boolean} muted if video stream should be muted or unmuted. + * + * @returns {Promise} resolved in case mute/unmute operations succeeds or + * rejected with an error if something goes wrong. It is expected that often + * the error will be of the {@link JitsiTrackError} type, but it's not + * guaranteed. */ function muteLocalVideo(muted) { - muteLocalMedia(localVideo, muted, 'Video'); + return muteLocalMedia(localVideo, muted); } /** @@ -424,6 +438,12 @@ function _connectionFailedHandler(error) { } export default { + /** + * Flag used to delay modification of the muted status of local media tracks + * until those are created (or not, but at that point it's certain that + * the tracks won't exist). + */ + _localTracksInitialized: false, isModerator: false, audioMuted: false, videoMuted: false, @@ -462,11 +482,14 @@ export default { * Creates local media tracks and connects to a room. Will show error * dialogs in case accessing the local microphone and/or camera failed. Will * show guidance overlay for users on how to give access to camera and/or - * microphone, + * microphone. * @param {string} roomName * @param {object} options - * @param {boolean} options.startScreenSharing - if true should - * start with screensharing instead of camera video. + * @param {boolean} options.startAudioOnly=false - if true then + * only audio track will be created and the audio only mode will be turned + * on. + * @param {boolean} options.startScreenSharing=false - if true + * should start with screensharing instead of camera video. * @returns {Promise.} */ createInitialLocalTracksAndConnect(roomName, options = {}) { @@ -486,7 +509,20 @@ export default { let tryCreateLocalTracks; // FIXME the logic about trying to go audio only on error is duplicated - if (options.startScreenSharing) { + if (options.startAudioOnly) { + tryCreateLocalTracks + = createLocalTracks({ devices: ['audio'] }, true) + .catch(err => { + audioOnlyError = err; + + return []; + }); + + // Enable audio only mode + if (config.startAudioOnly) { + APP.store.dispatch(toggleAudioOnly()); + } + } else if (options.startScreenSharing) { tryCreateLocalTracks = this._createDesktopTrack() .then(desktopStream => { return createLocalTracks({ devices: ['audio'] }, true) @@ -594,16 +630,19 @@ export default { analytics.init(); return this.createInitialLocalTracksAndConnect( options.roomName, { + startAudioOnly: config.startAudioOnly, startScreenSharing: config.startScreenSharing }); }).then(([tracks, con]) => { tracks.forEach(track => { - if((track.isAudioTrack() && initialAudioMutedState) - || (track.isVideoTrack() && initialVideoMutedState)) { + if (track.isAudioTrack() && initialAudioMutedState) { + track.mute(); + } else if (track.isVideoTrack() && this.videoMuted) { track.mute(); } }); logger.log('initialized with %s local tracks', tracks.length); + this._localTracksInitialized = true; con.addEventListener( ConnectionEvents.CONNECTION_FAILED, _connectionFailedHandler); @@ -695,6 +734,8 @@ export default { */ toggleAudioMuted(force = false) { if(!localAudio && force) { + // NOTE this logic will be adjusted to the same one as for the video + // once 'startWithAudioMuted' option is added. initialAudioMutedState = !initialAudioMutedState; return; } @@ -703,22 +744,60 @@ export default { /** * Simulates toolbar button click for video mute. Used by shortcuts and API. * @param mute true for mute and false for unmute. + * @param {boolean} [showUI] when set to false will not display any error + * dialogs in case of media permissions error. */ - muteVideo(mute) { - muteLocalVideo(mute); + muteVideo(mute, showUI = true) { + // Not ready to modify track's state yet + if (!this._localTracksInitialized) { + this.videoMuted = mute; + + return; + } + + const maybeShowErrorDialog = (error) => { + if (showUI) { + APP.UI.showDeviceErrorDialog(null, error); + } + }; + + if (!localVideo && this.videoMuted && !mute) { + // Try to create local video if there wasn't any. + // This handles the case when user joined with no video + // (dismissed screen sharing screen or in audio only mode), but + // decided to add it later on by clicking on muted video icon or + // turning off the audio only mode. + // + // FIXME when local track creation is moved to react/redux + // it should take care of the use case described above + createLocalTracks({ devices: ['video'] }, false) + .then(([videoTrack]) => videoTrack) + .catch(error => { + // FIXME should send some feedback to the API on error ? + maybeShowErrorDialog(error); + + // Rollback the video muted status by using null track + return null; + }) + .then(videoTrack => this.useVideoStream(videoTrack)); + } else { + const oldMutedStatus = this.videoMuted; + + muteLocalVideo(mute) + .catch(error => { + maybeShowErrorDialog(error); + this.videoMuted = oldMutedStatus; + APP.UI.setVideoMuted(this.getMyUserId(), this.videoMuted); + }); + } }, /** * Simulates toolbar button click for video mute. Used by shortcuts and API. - * @param {boolean} force - If the track is not created, the operation - * will be executed after the track is created. Otherwise the operation - * will be ignored. + * @param {boolean} [showUI] when set to false will not display any error + * dialogs in case of media permissions error. */ - toggleVideoMuted(force = false) { - if(!localVideo && force) { - initialVideoMutedState = !initialVideoMutedState; - return; - } - this.muteVideo(!this.videoMuted); + toggleVideoMuted(showUI = true) { + this.muteVideo(!this.videoMuted, showUI); }, /** * Retrieve list of conference participants (without local user). @@ -1721,20 +1800,8 @@ export default { APP.UI.addListener(UIEvents.VIDEO_MUTED, muted => { if (this.isAudioOnly() && !muted) { this._displayAudioOnlyTooltip('videoMute'); - } else if (!localVideo && this.videoMuted && !muted) { - // Maybe try to create local video if there wasn't any ? - // This handles the case when user joined with no video - // (dismissed screen sharing screen), but decided to add it - // later on by clicking on muted video icon. - createLocalTracks({ devices: ['video'] }, false) - .then(([videoTrack]) => { - APP.conference.useVideoStream(videoTrack); - }) - .catch(error => { - APP.UI.showDeviceErrorDialog(null, error); - }); } else { - muteLocalVideo(muted); + this.muteVideo(muted); } }); @@ -1927,7 +1994,7 @@ export default { ); APP.UI.addListener(UIEvents.TOGGLE_AUDIO_ONLY, audioOnly => { - muteLocalVideo(audioOnly); + this.muteVideo(audioOnly); // Immediately update the UI by having remote videos and the large // video update themselves instead of waiting for some other event @@ -2038,7 +2105,7 @@ export default { JitsiMeetJS.mediaDevices.enumerateDevices(devices => { // Ugly way to synchronize real device IDs with local // storage and settings menu. This is a workaround until - // getConstraints() method will be implemented + // getConstraints() method will be implemented // in browsers. if (localAudio) { APP.settings.setMicDeviceId( diff --git a/config.js b/config.js index 1cf3c4b56..644ab18cc 100644 --- a/config.js +++ b/config.js @@ -76,6 +76,7 @@ var config = { // eslint-disable-line no-unused-vars // page redirection when call is hangup disableSimulcast: false, // requireDisplayName: true, // Forces the participants that doesn't have display name to enter it when they enter the room. + startAudioOnly: false, // Will start the conference in the audio only mode (no video is being received nor sent) startScreenSharing: false, // Will try to start with screensharing instead of camera // startAudioMuted: 10, // every participant after the Nth will start audio muted // startVideoMuted: 10, // every participant after the Nth will start video muted diff --git a/modules/API/API.js b/modules/API/API.js index 1a884a35c..7a21ca1c5 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -36,7 +36,9 @@ function initCommands() { 'display-name': APP.conference.changeLocalDisplayName.bind(APP.conference), 'toggle-audio': () => APP.conference.toggleAudioMuted(true), - 'toggle-video': () => APP.conference.toggleVideoMuted(true), + 'toggle-video': () => { + APP.conference.toggleVideoMuted(false /* no UI */); + }, 'toggle-film-strip': APP.UI.toggleFilmstrip, 'toggle-chat': APP.UI.toggleChat, 'toggle-contact-list': APP.UI.toggleContactList, diff --git a/modules/UI/videolayout/VideoLayout.js b/modules/UI/videolayout/VideoLayout.js index a040bfc1c..ec4090b22 100644 --- a/modules/UI/videolayout/VideoLayout.js +++ b/modules/UI/videolayout/VideoLayout.js @@ -336,13 +336,11 @@ var VideoLayout = { remoteVideo.addRemoteStreamElement(stream); - // if track is muted make sure we reflect that - if(stream.isMuted()) - { - if(stream.getType() === "audio") - this.onAudioMute(stream.getParticipantId(), true); - else - this.onVideoMute(stream.getParticipantId(), true); + // Make sure track's muted state is reflected + if (stream.getType() === "audio") { + this.onAudioMute(stream.getParticipantId(), stream.isMuted()); + } else { + this.onVideoMute(stream.getParticipantId(), stream.isMuted()); } }, @@ -353,6 +351,30 @@ var VideoLayout = { if (remoteVideo) { remoteVideo.removeRemoteStreamElement(stream); } + this.updateMutedForNoTracks(id, stream.getType()); + }, + + /** + * FIXME get rid of this method once muted indicator are reactified (by + * making sure that user with no tracks is displayed as muted ) + * + * If participant has no tracks will make the UI display muted status. + * @param {string} participantId + * @param {string} mediaType 'audio' or 'video' + */ + updateMutedForNoTracks(participantId, mediaType) { + const participant = APP.conference.getParticipantById(participantId); + + if (participant + && !participant.getTracksByMediaType(mediaType).length) { + if (mediaType === 'audio') { + APP.UI.setAudioMuted(participantId, true); + } else if (mediaType === 'video') { + APP.UI.setVideoMuted(participantId, true); + } else { + logger.error(`Unsupported media type: ${mediaType}`); + } + } }, /** @@ -446,6 +468,9 @@ var VideoLayout = { this._setRemoteControlProperties(user, remoteVideo); this.addRemoteVideoContainer(id, remoteVideo); + this.updateMutedForNoTracks(id, 'audio'); + this.updateMutedForNoTracks(id, 'video'); + const remoteVideosCount = Object.keys(remoteVideos).length; if (remoteVideosCount === 1) { diff --git a/react/features/base/conference/middleware.js b/react/features/base/conference/middleware.js index bd61aaae4..fbec4c46a 100644 --- a/react/features/base/conference/middleware.js +++ b/react/features/base/conference/middleware.js @@ -15,7 +15,7 @@ import { _setAudioOnlyVideoMuted, setLastN } from './actions'; -import { SET_AUDIO_ONLY, SET_LASTN } from './actionTypes'; +import { CONFERENCE_JOINED, SET_AUDIO_ONLY, SET_LASTN } from './actionTypes'; import { _addLocalTracksToConference, _handleParticipantError, @@ -33,6 +33,9 @@ MiddlewareRegistry.register(store => next => action => { case CONNECTION_ESTABLISHED: return _connectionEstablished(store, next, action); + case CONFERENCE_JOINED: + return _conferenceJoined(store, next, action); + case PIN_PARTICIPANT: return _pinParticipant(store, next, action); @@ -76,6 +79,35 @@ function _connectionEstablished(store, next, action) { return result; } +/** + * Does extra sync up on properties that may need to be updated, after + * the conference was joined. + * + * @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 CONFERENCE_JOINED 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 _conferenceJoined(store, next, action) { + const result = next(action); + const { audioOnly, conference } + = store.getState()['features/base/conference']; + + // FIXME On Web the audio only mode for "start audio only" is toggled before + // conference is added to the redux store ("on conference joined" action) + // and the LastN value needs to be synchronized here. + if (audioOnly && conference.getLastN() !== 0) { + store.dispatch(setLastN(0)); + } + + return result; +} + /** * Notifies the feature base/conference that the action PIN_PARTICIPANT is being * dispatched within a specific Redux store. Pins the specified remote