diff --git a/conference.js b/conference.js index 82537e97c..a3f1d8362 100644 --- a/conference.js +++ b/conference.js @@ -12,6 +12,8 @@ import Recorder from './modules/recorder/Recorder'; import CQEvents from './service/connectionquality/CQEvents'; import UIEvents from './service/UI/UIEvents'; +import mediaDeviceHelper from './modules/devices/mediaDeviceHelper'; + const ConnectionEvents = JitsiMeetJS.events.connection; const ConnectionErrors = JitsiMeetJS.errors.connection; @@ -22,7 +24,6 @@ const TrackEvents = JitsiMeetJS.events.track; const TrackErrors = JitsiMeetJS.errors.track; let room, connection, localAudio, localVideo, roomLocker; -let currentAudioInputDevices, currentVideoInputDevices; import {VIDEO_CONTAINER_TYPE} from "./modules/UI/videolayout/LargeVideo"; @@ -198,17 +199,6 @@ function createLocalTracks (devices, cameraDeviceId, micDeviceId) { }); } -/** - * Stores lists of current 'audioinput' and 'videoinput' devices - * @param {MediaDeviceInfo[]} devices - */ -function setCurrentMediaDevices(devices) { - currentAudioInputDevices = devices.filter( - d => d.kind === 'audioinput'); - currentVideoInputDevices = devices.filter( - d => d.kind === 'videoinput'); -} - class ConferenceConnector { constructor(resolve, reject) { this._resolve = resolve; @@ -423,44 +413,8 @@ export default { APP.UI.disableCameraButton(); } - // update list of available devices - if (JitsiMeetJS.mediaDevices.isDeviceListAvailable() && - JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) { - JitsiMeetJS.mediaDevices.enumerateDevices(function(devices) { - // Ugly way to synchronize real device IDs with local - // storage and settings menu. This is a workaround until - // getConstraints() method will be implemented in browsers. - if (localAudio) { - localAudio._setRealDeviceIdFromDeviceList(devices); - APP.settings.setMicDeviceId(localAudio.getDeviceId()); - } + this._initDeviceList(); - if (localVideo) { - localVideo._setRealDeviceIdFromDeviceList(devices); - APP.settings.setCameraDeviceId( - localVideo.getDeviceId()); - } - - setCurrentMediaDevices(devices); - - APP.UI.onAvailableDevicesChanged(devices); - }); - - JitsiMeetJS.mediaDevices.addEventListener( - JitsiMeetJS.events.mediaDevices.DEVICE_LIST_CHANGED, - (devices) => { - // Just defer callback until other event callbacks are - // processed. - window.setTimeout(() => { - checkLocalDevicesAfterDeviceListChanged(devices) - .then(() => { - setCurrentMediaDevices(devices); - - APP.UI.onAvailableDevicesChanged(devices); - }); - }, 0); - }); - } if (config.iAmRecorder) this.recorder = new Recorder(); @@ -469,220 +423,6 @@ export default { return new Promise((resolve, reject) => { (new ConferenceConnector(resolve, reject)).connect(); }); - - function checkAudioOutputDeviceAfterDeviceListChanged(newDevices) { - if (!JitsiMeetJS.mediaDevices - .isDeviceChangeAvailable('output')) { - return; - } - - var selectedAudioOutputDeviceId = - APP.settings.getAudioOutputDeviceId(), - availableAudioOutputDevices = newDevices.filter(d => { - return d.kind === 'audiooutput'; - }); - - if (selectedAudioOutputDeviceId !== 'default' && - !availableAudioOutputDevices.find(d => - d.deviceId === selectedAudioOutputDeviceId)) { - APP.settings.setAudioOutputDeviceId('default'); - } - } - - function checkLocalDevicesAfterDeviceListChanged(newDevices) { - // Event handler can be fire before direct enumerateDevices() - // call, so handle this situation here. - if (!currentAudioInputDevices && !currentVideoInputDevices) { - setCurrentMediaDevices(newDevices); - } - - checkAudioOutputDeviceAfterDeviceListChanged(newDevices); - - let availableAudioInputDevices = newDevices.filter( - d => d.kind === 'audioinput'), - availableVideoInputDevices = newDevices.filter( - d => d.kind === 'videoinput'), - selectedAudioInputDeviceId = APP.settings.getMicDeviceId(), - selectedVideoInputDeviceId = - APP.settings.getCameraDeviceId(), - selectedAudioInputDevice = availableAudioInputDevices.find( - d => d.deviceId === selectedAudioInputDeviceId), - selectedVideoInputDevice = availableVideoInputDevices.find( - d => d.deviceId === selectedVideoInputDeviceId), - tracksToCreate = [], - micIdToUse = null, - cameraIdToUse = null; - - // Here we handle case when no device was initially plugged, but - // then it's connected OR new device was connected when previous - // track has ended. - if (!localAudio || localAudio.disposed || localAudio.isEnded()){ - if (availableAudioInputDevices.length - && availableAudioInputDevices[0].label !== '') { - tracksToCreate.push('audio'); - micIdToUse = availableAudioInputDevices[0].deviceId; - } else { - APP.UI.disableMicrophoneButton(); - } - } - - if ((!localVideo || localVideo.disposed || localVideo.isEnded()) - && !self.isSharingScreen){ - if (availableVideoInputDevices.length - && availableVideoInputDevices[0].label !== '') { - tracksToCreate.push('video'); - cameraIdToUse = availableVideoInputDevices[0].deviceId; - } else { - APP.UI.disableCameraButton(); - } - } - - if (localAudio && !localAudio.disposed && !localAudio.isEnded() - && selectedAudioInputDevice - && selectedAudioInputDeviceId !== localAudio.getDeviceId() - && tracksToCreate.indexOf('audio') === -1) { - tracksToCreate.push('audio'); - micIdToUse = selectedAudioInputDeviceId; - } - - if (localVideo && !localVideo.disposed && !localVideo.isEnded() - && selectedVideoInputDevice - && selectedVideoInputDeviceId !== localVideo.getDeviceId() - && tracksToCreate.indexOf('video') === -1 - && !self.isSharingScreen) { - tracksToCreate.push('video'); - cameraIdToUse = selectedVideoInputDeviceId; - } - - if (tracksToCreate.length) { - return createNewTracks( - tracksToCreate, cameraIdToUse, micIdToUse); - } else { - return Promise.resolve(); - } - - function createNewTracks(type, cameraDeviceId, micDeviceId) { - let audioTrackCreationError; - let videoTrackCreationError; - let audioRequested = type.indexOf('audio') !== -1; - let videoRequested = type.indexOf('video') !== -1; - let promise; - - if (audioRequested && micDeviceId !== null) { - if (videoRequested && cameraDeviceId !== null) { - promise = createLocalTracks( - type, cameraDeviceId, micDeviceId) - .catch(() => { - return Promise.all([ - createAudioTrack(false), - createVideoTrack(false)]); - }) - .then((audioTracks, videoTracks) => { - if (audioTrackCreationError) { - if (videoTrackCreationError) { - APP.UI.showDeviceErrorDialog( - audioTrackCreationError, - videoTrackCreationError); - } else { - APP.UI.showDeviceErrorDialog( - audioTrackCreationError, - null); - } - } else if (videoTrackCreationError) { - APP.UI.showDeviceErrorDialog( - null, - videoTrackCreationError); - } - - return (audioTracks || []) - .concat(videoTracks || []); - }); - } else { - promise = createAudioTrack(); - } - } else if (videoRequested && cameraDeviceId !== null) { - promise = createVideoTrack(); - } else { - promise = Promise.resolve([]); - } - - return promise - .then(onTracksCreated); - - function createAudioTrack(showError) { - return createLocalTracks(['audio'], null, micDeviceId) - .catch(err => { - audioTrackCreationError = err; - - if (showError) { - APP.UI.showDeviceErrorDialog(err, null); - } - - return []; - }); - } - - function createVideoTrack(showError) { - return createLocalTracks( - ['video'], cameraDeviceId, null) - .catch(err => { - videoTrackCreationError = err; - - if (showError) { - APP.UI.showDeviceErrorDialog(null, err); - } - - return []; - }); - } - } - - function onTracksCreated(tracks) { - return Promise.all((tracks || []).map(track => { - if (track.isAudioTrack()) { - let audioWasMuted = self.audioMuted; - - return self.useAudioStream(track).then(() => { - console.log('switched local audio'); - - // If we plugged-in new device (and switched to - // it), but video was muted before, or we - // unplugged current device and selected new - // one, then mute new video track. - if (audioWasMuted || - currentAudioInputDevices.length > - availableAudioInputDevices.length) { - muteLocalAudio(true); - } - }); - } else if (track.isVideoTrack()) { - let videoWasMuted = self.videoMuted; - - return self.useVideoStream(track).then(() => { - console.log('switched local video'); - - // TODO: maybe make video large if we - // are not in conference yet - - // If we plugged-in new device (and switched to - // it), but video was muted before, or we - // unplugged current device and selected new - // one, then mute new video track. - if (videoWasMuted || - (currentVideoInputDevices.length > - availableVideoInputDevices.length)) { - muteLocalVideo(true); - } - }); - } else { - console.error("Ignored not an audio nor a " - + "video track: ", track); - - return Promise.resolve(); - } - })); - } - } }); }, /** @@ -896,16 +636,7 @@ export default { room = connection.initJitsiConference(APP.conference.roomName, this._getConferenceOptions()); this.localId = room.myUserId(); - localTracks.forEach((track) => { - if (track.isAudioTrack()) { - this.useAudioStream(track); - } else if (track.isVideoTrack()) { - this.useVideoStream(track); - } else { - console.error( - "Ignored not an audio nor a video track: ", track); - } - }); + this._setLocalAudioVideoStreams(localTracks); roomLocker = createRoomLocker(room); this._room = room; // FIXME do not use this @@ -922,6 +653,26 @@ export default { this._setupListeners(); }, + /** + * Sets local video and audio streams. + * @param {JitsiLocalTrack[]} tracks=[] + * @returns {Promise[]} + * @private + */ + _setLocalAudioVideoStreams(tracks = []) { + return tracks.map(track => { + if (track.isAudioTrack()) { + return this.useAudioStream(track); + } else if (track.isVideoTrack()) { + return this.useVideoStream(track); + } else { + console.error( + "Ignored not an audio nor a video track: ", track); + return Promise.resolve(); + } + }); + }, + _getConferenceOptions() { let options = config; if(config.enableRecording && !config.recordingType) { @@ -1425,7 +1176,7 @@ export default { APP.UI.addListener( UIEvents.VIDEO_DEVICE_CHANGED, (cameraDeviceId) => { - createLocalTracks(['video']) + createLocalTracks(['video'], cameraDeviceId, null) .then(([stream]) => { this.useVideoStream(stream); console.log('switched local video device'); @@ -1441,7 +1192,7 @@ export default { APP.UI.addListener( UIEvents.AUDIO_DEVICE_CHANGED, (micDeviceId) => { - createLocalTracks(['audio']) + createLocalTracks(['audio'], null, micDeviceId) .then(([stream]) => { this.useAudioStream(stream); console.log('switched local audio device'); @@ -1519,11 +1270,112 @@ export default { }); }, /** - * Adss any room listener. - * @param eventName one of the ConferenceEvents - * @param callBack the function to be called when the event occurs - */ - addConferenceListener(eventName, callBack) { + * Adds any room listener. + * @param eventName one of the ConferenceEvents + * @param callBack the function to be called when the event occurs + */ + addConferenceListener(eventName, callBack) { room.on(eventName, callBack); + }, + /** + * Inits list of current devices and event listener for device change. + * @private + */ + _initDeviceList() { + if (JitsiMeetJS.mediaDevices.isDeviceListAvailable() && + JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) { + 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 in browsers. + if (localAudio) { + localAudio._setRealDeviceIdFromDeviceList(devices); + APP.settings.setMicDeviceId(localAudio.getDeviceId()); + } + + if (localVideo) { + localVideo._setRealDeviceIdFromDeviceList(devices); + APP.settings.setCameraDeviceId(localVideo.getDeviceId()); + } + + mediaDeviceHelper.setCurrentMediaDevices(devices); + + APP.UI.onAvailableDevicesChanged(devices); + }); + + JitsiMeetJS.mediaDevices.addEventListener( + JitsiMeetJS.events.mediaDevices.DEVICE_LIST_CHANGED, + (devices) => + window.setTimeout( + () => this._onDeviceListChanged(devices), 0)); + } + }, + /** + * Event listener for JitsiMediaDevicesEvents.DEVICE_LIST_CHANGED to + * handle change of available media devices. + * @private + * @param {MediaDeviceInfo[]} devices + * @returns {Promise} + */ + _onDeviceListChanged(devices) { + let currentDevices = mediaDeviceHelper.getCurrentMediaDevices(); + + // Event handler can be fired before direct + // enumerateDevices() call, so handle this situation here. + if (!currentDevices.audioinput && + !currentDevices.videoinput && + !currentDevices.audiooutput) { + mediaDeviceHelper.setCurrentMediaDevices(devices); + currentDevices = mediaDeviceHelper.getCurrentMediaDevices(); + } + + let newDevices = + mediaDeviceHelper.getNewMediaDevicesAfterDeviceListChanged( + devices, this.isSharingScreen, localVideo, localAudio); + let promises = []; + let audioWasMuted = this.audioMuted; + let videoWasMuted = this.videoMuted; + let availableAudioInputDevices = + mediaDeviceHelper.getDevicesFromListByKind(devices, 'audioinput'); + let availableVideoInputDevices = + mediaDeviceHelper.getDevicesFromListByKind(devices, 'videoinput'); + + if (typeof newDevices.audiooutput !== 'undefined') { + // Just ignore any errors in catch block. + promises.push(APP.settings + .setAudioOutputDeviceId(newDevices.audiooutput) + .catch()); + } + + promises.push( + mediaDeviceHelper.createLocalTracksAfterDeviceListChanged( + createLocalTracks, + newDevices.videoinput, + newDevices.audioinput) + .then(tracks => + Promise.all(this._setLocalAudioVideoStreams(tracks))) + .then(() => { + // If audio was muted before, or we unplugged current device + // and selected new one, then mute new audio track. + if (audioWasMuted || + currentDevices.audioinput.length > + availableAudioInputDevices.length) { + muteLocalAudio(true); + } + + // If video was muted before, or we unplugged current device + // and selected new one, then mute new video track. + if (videoWasMuted || + currentDevices.videoinput.length > + availableVideoInputDevices.length) { + muteLocalVideo(true); + } + })); + + return Promise.all(promises) + .then(() => { + mediaDeviceHelper.setCurrentMediaDevices(devices); + APP.UI.onAvailableDevicesChanged(devices); + }); } -}; +}; \ No newline at end of file diff --git a/modules/devices/mediaDeviceHelper.js b/modules/devices/mediaDeviceHelper.js new file mode 100644 index 000000000..8b68ad838 --- /dev/null +++ b/modules/devices/mediaDeviceHelper.js @@ -0,0 +1,233 @@ +/* global $, APP, JitsiMeetJS, config, interfaceConfig */ + +let currentAudioInputDevices, + currentVideoInputDevices, + currentAudioOutputDevices; + +/** + * Determines if currently selected audio output device should be changed after + * list of available devices has been changed. + * @param {MediaDeviceInfo[]} newDevices + * @returns {string|undefined} - ID of new audio output device to use, undefined + * if audio output device should not be changed. + */ +function getNewAudioOutputDevice(newDevices) { + if (!JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) { + return; + } + + let selectedAudioOutputDeviceId = APP.settings.getAudioOutputDeviceId(); + let availableAudioOutputDevices = newDevices.filter( + d => d.kind === 'audiooutput'); + + // Switch to 'default' audio output device if we don't have the selected one + // available anymore. + if (selectedAudioOutputDeviceId !== 'default' && + !availableAudioOutputDevices.find(d => + d.deviceId === selectedAudioOutputDeviceId)) { + return 'default'; + } +} + +/** + * Determines if currently selected audio input device should be changed after + * list of available devices has been changed. + * @param {MediaDeviceInfo[]} newDevices + * @param {JitsiLocalTrack} localAudio + * @returns {string|undefined} - ID of new microphone device to use, undefined + * if audio input device should not be changed. + */ +function getNewAudioInputDevice(newDevices, localAudio) { + let availableAudioInputDevices = newDevices.filter( + d => d.kind === 'audioinput'); + let selectedAudioInputDeviceId = APP.settings.getMicDeviceId(); + let selectedAudioInputDevice = availableAudioInputDevices.find( + d => d.deviceId === selectedAudioInputDeviceId); + + // Here we handle case when no device was initially plugged, but + // then it's connected OR new device was connected when previous + // track has ended. + if (!localAudio || localAudio.disposed || localAudio.isEnded()) { + // If we have new audio device and permission to use it was granted + // (label is not an empty string), then we will try to use the first + // available device. + if (availableAudioInputDevices.length && + availableAudioInputDevices[0].label !== '') { + return availableAudioInputDevices[0].deviceId; + } + // Otherwise we assume that we don't have any audio input devices + // to use and that's why disable microphone button on UI. + else { + APP.UI.disableMicrophoneButton(); + } + } else { + // And here we handle case when we already have some device working, + // but we plug-in a "preferred" (previously selected in settings, stored + // in local storage) device. + if (selectedAudioInputDevice && + selectedAudioInputDeviceId !== localAudio.getDeviceId()) { + return selectedAudioInputDeviceId; + } + } +} + +/** + * Determines if currently selected video input device should be changed after + * list of available devices has been changed. + * @param {MediaDeviceInfo[]} newDevices + * @param {JitsiLocalTrack} localVideo + * @returns {string|undefined} - ID of new camera device to use, undefined + * if video input device should not be changed. + */ +function getNewVideoInputDevice(newDevices, localVideo) { + let availableVideoInputDevices = newDevices.filter( + d => d.kind === 'videoinput'); + let selectedVideoInputDeviceId = APP.settings.getCameraDeviceId(); + let selectedVideoInputDevice = availableVideoInputDevices.find( + d => d.deviceId === selectedVideoInputDeviceId); + + // Here we handle case when no video input device was initially plugged, + // but then device is connected OR new device was connected when + // previous track has ended. + if (!localVideo || localVideo.disposed || localVideo.isEnded()) { + // If we have new video device and permission to use it was granted + // (label is not an empty string), then we will try to use the first + // available device. + if (availableVideoInputDevices.length && + availableVideoInputDevices[0].label !== '') { + return availableVideoInputDevices[0].deviceId; + } + // Otherwise we assume that we don't have any video input devices + // to use and that's why disable microphone button on UI. + else { + APP.UI.disableCameraButton(); + } + } else { + // And here we handle case when we already have some device working, + // but we plug-in a "preferred" (previously selected in settings, stored + // in local storage) device. + if (selectedVideoInputDevice && + selectedVideoInputDeviceId !== localVideo.getDeviceId()) { + return selectedVideoInputDeviceId; + } + } +} + +export default { + /** + * Returns list of devices of single kind. + * @param {MediaDeviceInfo[]} devices + * @param {'audioinput'|'audiooutput'|'videoinput'} kind + * @returns {MediaDeviceInfo[]} + */ + getDevicesFromListByKind(devices, kind) { + return devices.filter(d => d.kind === kind); + }, + /** + * Stores lists of current 'audioinput', 'videoinput' and 'audiooutput' + * devices. + * @param {MediaDeviceInfo[]} devices + */ + setCurrentMediaDevices(devices) { + currentAudioInputDevices = + this.getDevicesFromListByKind(devices, 'audioinput'); + currentVideoInputDevices = + this.getDevicesFromListByKind(devices, 'videoinput'); + currentAudioOutputDevices = + this.getDevicesFromListByKind(devices, 'audiooutput'); + }, + /** + * Returns lists of current 'audioinput', 'videoinput' and 'audiooutput' + * devices. + * @returns {{ + * audioinput: (MediaDeviceInfo[]|undefined), + * videoinput: (MediaDeviceInfo[]|undefined), + * audiooutput: (MediaDeviceInfo[]|undefined), + * }} + */ + getCurrentMediaDevices() { + return { + audioinput: currentAudioInputDevices, + videoinput: currentVideoInputDevices, + audiooutput: currentAudioOutputDevices + }; + }, + /** + * Determines if currently selected media devices should be changed after + * list of available devices has been changed. + * @param {MediaDeviceInfo[]} newDevices + * @param {boolean} isSharingScreen + * @param {JitsiLocalTrack} localVideo + * @param {JitsiLocalTrack} localAudio + * @returns {{ + * audioinput: (string|undefined), + * videoinput: (string|undefined), + * audiooutput: (string|undefined) + * }} + */ + getNewMediaDevicesAfterDeviceListChanged( + newDevices, isSharingScreen, localVideo, localAudio) { + return { + audioinput: getNewAudioInputDevice(newDevices, localAudio), + videoinput: !isSharingScreen && + getNewVideoInputDevice(newDevices, localVideo), + audiooutput: getNewAudioOutputDevice(newDevices) + }; + }, + /** + * Tries to create new local tracks for new devices obtained after device + * list changed. Shows error dialog in case of failures. + * @param {function} createLocalTracks + * @param {string} (cameraDeviceId) + * @param {string} (micDeviceId) + * @returns {Promise.} + */ + createLocalTracksAfterDeviceListChanged( + createLocalTracks, cameraDeviceId, micDeviceId) { + let audioTrackError; + let videoTrackError; + let audioRequested = !!micDeviceId; + let videoRequested = !!cameraDeviceId; + + if (audioRequested && videoRequested) { + // First we try to create both audio and video tracks together. + return createLocalTracks( + ['audio', 'video'], cameraDeviceId, micDeviceId) + // If we fail to do this, try to create them separately. + .catch(() => Promise.all( + [createAudioTrack(false), createVideoTrack(false)])) + .then((audioTracks, videoTracks) => { + if (audioTrackError || videoTrackError) { + APP.UI.showDeviceErrorDialog( + audioTrackError, videoTrackError); + } + + return (audioTracks || []).concat(videoTracks || []); + }); + } else if (videoRequested && !audioRequested) { + return createVideoTrack(); + } else if (audioRequested && !videoRequested) { + return createAudioTrack(); + } else { + return Promise.resolve([]); + } + + function createAudioTrack(showError) { + return createLocalTracks(['audio'], null, micDeviceId) + .catch(err => { + audioTrackError = err; + showError && APP.UI.showDeviceErrorDialog(err, null); + return []; + }); + } + + function createVideoTrack(showError) { + return createLocalTracks(['video'], cameraDeviceId, null) + .catch(err => { + videoTrackError = err; + showError && APP.UI.showDeviceErrorDialog(null, err); + return []; + }); + } + } +}; \ No newline at end of file