Switch local audio and video track when list of available devices changes

This commit is contained in:
Kostiantyn Tsaregradskyi 2016-05-17 18:58:25 +03:00
parent eda11f4657
commit b270256a7a
7 changed files with 394 additions and 69 deletions

View File

@ -170,16 +170,25 @@ function hangup (requestFeedback = false) {
/**
* Create local tracks of specified types.
* @param {string[]} devices required track types ('audio', 'video' etc.)
* @param {string[]} devices - required track types ('audio', 'video' etc.)
* @param {string|null} [cameraDeviceId] - camera device id, if undefined - one
* from settings will be used
* @param {string|null} [micDeviceId] - microphone device id, if undefined - one
* from settings will be used
* @returns {Promise<JitsiLocalTrack[]>}
*/
function createLocalTracks (...devices) {
function createLocalTracks (devices, cameraDeviceId, micDeviceId) {
return JitsiMeetJS.createLocalTracks({
// copy array to avoid mutations inside library
devices: devices.slice(0),
resolution: config.resolution,
cameraDeviceId: APP.settings.getCameraDeviceId(),
micDeviceId: APP.settings.getMicDeviceId(),
cameraDeviceId: typeof cameraDeviceId === 'undefined'
|| cameraDeviceId === null
? APP.settings.getCameraDeviceId()
: cameraDeviceId,
micDeviceId: typeof micDeviceId === 'undefined' || micDeviceId === null
? APP.settings.getMicDeviceId()
: micDeviceId,
// adds any ff fake device settings if any
firefox_fake_device: config.firefox_fake_device
}).catch(function (err) {
@ -327,6 +336,7 @@ export default {
* @returns {Promise}
*/
init(options) {
let self = this;
this.roomName = options.roomName;
JitsiMeetJS.setLogLevel(JitsiMeetJS.logLevels.TRACE);
@ -356,9 +366,9 @@ export default {
return JitsiMeetJS.init(config).then(() => {
return Promise.all([
// try to retrieve audio and video
createLocalTracks('audio', 'video')
createLocalTracks(['audio', 'video'])
// if failed then try to retrieve only audio
.catch(() => createLocalTracks('audio'))
.catch(() => createLocalTracks(['audio']))
// if audio also failed then just return empty array
.catch(() => []),
connect(options.roomName)
@ -370,15 +380,49 @@ export default {
this.isDesktopSharingEnabled =
JitsiMeetJS.isDesktopSharingEnabled();
// if user didn't give access to mic or camera or doesn't have
// them at all, we disable corresponding toolbar buttons
if (!tracks.find((t) => t.isAudioTrack())) {
APP.UI.disableMicrophoneButton();
}
if (!tracks.find((t) => t.isVideoTrack())) {
APP.UI.disableCameraButton();
}
// update list of available devices
if (JitsiMeetJS.mediaDevices.isDeviceListAvailable() &&
JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) {
JitsiMeetJS.mediaDevices.enumerateDevices(
APP.UI.onAvailableDevicesChanged);
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());
}
if (localVideo) {
localVideo._setRealDeviceIdFromDeviceList(devices);
APP.settings.setCameraDeviceId(
localVideo.getDeviceId());
}
APP.UI.onAvailableDevicesChanged(devices);
});
JitsiMeetJS.mediaDevices.addEventListener(
JitsiMeetJS.events.mediaDevices.DEVICE_LIST_CHANGED,
APP.UI.onAvailableDevicesChanged);
(devices) => {
// Just defer callback until other event callbacks are
// processed.
window.setTimeout(() => {
checkLocalDevicesAfterDeviceListChanged(devices)
.then(() => {
APP.UI.onAvailableDevicesChanged(devices);
});
}, 0);
});
}
if (config.iAmRecorder)
this.recorder = new Recorder();
@ -388,6 +432,164 @@ 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) {
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) {
return createLocalTracks(type, cameraDeviceId, micDeviceId)
.then(onTracksCreated)
.catch(() => {
// if we tried to create both audio and video tracks
// at once and failed, let's try again only with
// audio. Such situation may happen in case if we
// granted access only to microphone, but not to
// camera.
if (type.indexOf('audio') !== -1
&& type.indexOf('video') !== -1) {
return createLocalTracks(['audio'], null,
micDeviceId);
}
})
.then(onTracksCreated)
.catch(() => {
// if we tried to create both audio and video tracks
// at once and failed, let's try again only with
// video. Such situation may happen in case if we
// granted access only to camera, but not to
// microphone.
if (type.indexOf('audio') !== -1
&& type.indexOf('video') !== -1) {
return createLocalTracks(['video'],
cameraDeviceId,
null);
}
})
.then(onTracksCreated)
.catch(() => {
// can't do anything in this case, so just ignore;
});
}
function onTracksCreated(tracks) {
tracks && tracks.forEach(track => {
if (track.isAudioTrack()) {
self.useAudioStream(track).then(() => {
console.log('switched local audio');
// If we have more than 1 device - mute.
// We check with 2 for audio, because
// it always has 'default' if device is
// available at all.
// TODO: this is not 100% solution - need
// to investigate more
if (availableAudioInputDevices.length > 2) {
muteLocalAudio(true);
}
});
} else if (track.isVideoTrack()) {
self.useVideoStream(track).then(() => {
console.log('switched local video');
// TODO: maybe make video large if we
// are not in conference yet
// If we have more than 1 device - mute.
// TODO: this is not 100% solution - need
// to investigate more
if (availableVideoInputDevices.length > 1) {
muteLocalVideo(true);
}
});
} else {
console.error("Ignored not an audio nor a "
+ "video track: ", track);
}
});
}
}
});
},
/**
@ -667,6 +869,8 @@ export default {
this.isSharingScreen = false;
}
stream.videoType === 'camera' && APP.UI.enableCameraButton();
APP.UI.setVideoMuted(this.localId, this.videoMuted);
APP.UI.updateDesktopSharingButtons();
@ -701,6 +905,7 @@ export default {
this.audioMuted = false;
}
APP.UI.enableMicrophoneButton();
APP.UI.setAudioMuted(this.localId, this.audioMuted);
});
},
@ -719,7 +924,7 @@ export default {
this.videoSwitchInProgress = true;
if (shareScreen) {
createLocalTracks('desktop').then(([stream]) => {
createLocalTracks(['desktop']).then(([stream]) => {
stream.on(
TrackEvents.LOCAL_TRACK_STOPPED,
() => {
@ -767,7 +972,7 @@ export default {
);
});
} else {
createLocalTracks('video').then(
createLocalTracks(['video']).then(
([stream]) => this.useVideoStream(stream)
).then(() => {
this.videoSwitchInProgress = false;
@ -1118,7 +1323,7 @@ export default {
UIEvents.VIDEO_DEVICE_CHANGED,
(cameraDeviceId) => {
APP.settings.setCameraDeviceId(cameraDeviceId);
createLocalTracks('video').then(([stream]) => {
createLocalTracks(['video']).then(([stream]) => {
this.useVideoStream(stream);
console.log('switched local video device');
});
@ -1129,7 +1334,7 @@ export default {
UIEvents.AUDIO_DEVICE_CHANGED,
(micDeviceId) => {
APP.settings.setMicDeviceId(micDeviceId);
createLocalTracks('audio').then(([stream]) => {
createLocalTracks(['audio']).then(([stream]) => {
this.useAudioStream(stream);
console.log('switched local audio device');
});

View File

@ -58,6 +58,10 @@ html, body{
vertical-align: middle;
}
.button[disabled] {
opacity: 0.5;
}
.toolbar_span>span {
display: inline-block;
position: absolute;

View File

@ -66,7 +66,9 @@
"dialpad": "Show dialpad",
"sharedVideoMutedPopup": "Your shared video has been muted so<br/>that you can talk to the other participants.",
"micMutedPopup": "Your microphone has been muted so that you<br/>would fully enjoy your shared video.",
"unableToUnmutePopup": "You cannot un-mute while the shared video is on."
"unableToUnmutePopup": "You cannot un-mute while the shared video is on.",
"cameraDisabled": "Camera is not available",
"micDisabled": "Microphone is not available"
},
"bottomtoolbar": {
"chat": "Open / close chat",
@ -90,7 +92,9 @@
"selectCamera": "Select camera",
"selectMic": "Select microphone",
"selectAudioOutput": "Select audio output",
"followMe": "Enable follow me"
"followMe": "Enable follow me",
"noDevice": "None",
"noPermission": "Permission to use device is not granted"
},
"videothumbnail":
{

View File

@ -1142,4 +1142,32 @@ UI.onSharedVideoStop = function (id, attributes) {
sharedVideoManager.onSharedVideoStop(id, attributes);
};
/**
* Disables camera toolbar button.
*/
UI.disableCameraButton = function () {
Toolbar.markVideoIconAsDisabled(true);
};
/**
* Enables camera toolbar button.
*/
UI.enableCameraButton = function () {
Toolbar.markVideoIconAsDisabled(false);
};
/**
* Disables microphone toolbar button.
*/
UI.disableMicrophoneButton = function () {
Toolbar.markAudioIconAsDisabled(true);
};
/**
* Enables microphone toolbar button.
*/
UI.enableMicrophoneButton = function () {
Toolbar.markAudioIconAsDisabled(false);
};
module.exports = UI;

View File

@ -30,10 +30,16 @@ function generateLanguagesOptions(items, currentLang) {
* Generate html select options for available physical devices.
* @param {{ deviceId, label }[]} items available devices
* @param {string} [selectedId] id of selected device
* @param {boolean} permissionGranted if permission to use selected device type
* is granted
* @returns {string}
*/
function generateDevicesOptions(items, selectedId) {
return items.map(function (item) {
function generateDevicesOptions(items, selectedId, permissionGranted) {
if (!permissionGranted && items.length) {
return '<option data-i18n="settings.noPermission"></option>';
}
var options = items.map(function (item) {
let attrs = {
value: item.deviceId
};
@ -44,7 +50,13 @@ function generateDevicesOptions(items, selectedId) {
let attrsStr = UIUtil.attrsToString(attrs);
return `<option ${attrsStr}>${item.label}</option>`;
}).join('\n');
});
if (!items.length) {
options.unshift('<option data-i18n="settings.noDevice"></option>');
}
return options.join('');
}
@ -111,26 +123,30 @@ export default {
// DEVICES LIST
this.changeDevicesList([]);
$('#selectCamera').change(function () {
let cameraDeviceId = $(this).val();
if (cameraDeviceId !== Settings.getCameraDeviceId()) {
emitter.emit(UIEvents.VIDEO_DEVICE_CHANGED, cameraDeviceId);
}
});
$('#selectMic').change(function () {
let micDeviceId = $(this).val();
if (micDeviceId !== Settings.getMicDeviceId()) {
emitter.emit(UIEvents.AUDIO_DEVICE_CHANGED, micDeviceId);
}
});
$('#selectAudioOutput').change(function () {
let audioOutputDeviceId = $(this).val();
if (audioOutputDeviceId !== Settings.getAudioOutputDeviceId()) {
emitter.emit(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED,
audioOutputDeviceId);
}
});
if (JitsiMeetJS.mediaDevices.isDeviceListAvailable() &&
JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) {
this.changeDevicesList([]);
$('#selectCamera').change(function () {
let cameraDeviceId = $(this).val();
if (cameraDeviceId !== Settings.getCameraDeviceId()) {
emitter.emit(UIEvents.VIDEO_DEVICE_CHANGED, cameraDeviceId);
}
});
$('#selectMic').change(function () {
let micDeviceId = $(this).val();
if (micDeviceId !== Settings.getMicDeviceId()) {
emitter.emit(UIEvents.AUDIO_DEVICE_CHANGED, micDeviceId);
}
});
$('#selectAudioOutput').change(function () {
let audioOutputDeviceId = $(this).val();
if (audioOutputDeviceId !== Settings.getAudioOutputDeviceId()) {
emitter.emit(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED,
audioOutputDeviceId);
}
});
}
},
/**
@ -193,41 +209,58 @@ export default {
* @param {{ deviceId, label, kind }[]} devices list of available devices
*/
changeDevicesList (devices) {
let $devicesOptions = $('#devicesOptions');
if (!devices.length) {
$devicesOptions.hide();
return;
}
let $selectCamera= $('#selectCamera'),
$selectMic = $('#selectMic'),
$selectAudioOutput = $('#selectAudioOutput'),
$selectAudioOutputParent = $selectAudioOutput.parent();
let audio = devices.filter(device => device.kind === 'audioinput');
let video = devices.filter(device => device.kind === 'videoinput');
let audioOutput = devices
.filter(device => device.kind === 'audiooutput');
let audio = devices.filter(device => device.kind === 'audioinput'),
video = devices.filter(device => device.kind === 'videoinput'),
audioOutput = devices
.filter(device => device.kind === 'audiooutput'),
selectedAudioDevice = audio.find(
d => d.deviceId === Settings.getMicDeviceId()) || audio[0],
selectedVideoDevice = video.find(
d => d.deviceId === Settings.getCameraDeviceId()) || video[0],
selectedAudioOutputDevice = audioOutput.find(
d => d.deviceId === Settings.getAudioOutputDeviceId()),
videoPermissionGranted =
JitsiMeetJS.mediaDevices.isDevicePermissionGranted('video'),
audioPermissionGranted =
JitsiMeetJS.mediaDevices.isDevicePermissionGranted('audio');
$selectCamera.html(
generateDevicesOptions(video, Settings.getCameraDeviceId())
);
$selectMic.html(
generateDevicesOptions(audio, Settings.getMicDeviceId())
);
$selectCamera
.html(generateDevicesOptions(
video,
selectedVideoDevice ? selectedVideoDevice.deviceId : '',
videoPermissionGranted))
.prop('disabled', !video.length || !videoPermissionGranted);
if (audioOutput.length &&
JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
$selectAudioOutput.html(
generateDevicesOptions(audioOutput,
Settings.getAudioOutputDeviceId()));
$selectMic
.html(generateDevicesOptions(
audio,
selectedAudioDevice ? selectedAudioDevice.deviceId : '',
audioPermissionGranted))
.prop('disabled', !audio.length || !audioPermissionGranted);
if (JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
$selectAudioOutput
.html(generateDevicesOptions(
audioOutput,
selectedAudioOutputDevice
? selectedAudioOutputDevice.deviceId
: 'default',
videoPermissionGranted || audioPermissionGranted))
.prop('disabled', !audioOutput.length ||
(!videoPermissionGranted && !audioPermissionGranted));
$selectAudioOutputParent.show();
} else {
$selectAudioOutputParent.hide();
}
$devicesOptions.show();
$('#devicesOptions').show();
APP.translation.translateElement($('#settingsmenu option'));
}
};

View File

@ -191,7 +191,9 @@ const Toolbar = {
UIUtil.hideDisabledButtons(defaultToolbarButtons);
Object.keys(buttonHandlers).forEach(
buttonId => $(`#${buttonId}`).click(buttonHandlers[buttonId])
buttonId => $(`#${buttonId}`).click(function(event) {
!$(this).prop('disabled') && buttonHandlers[buttonId](event);
})
);
},
/**
@ -361,6 +363,29 @@ const Toolbar = {
$('#toolbar_button_camera').toggleClass("icon-camera-disabled", muted);
},
/**
* Marks video icon as disabled or not.
* @param {boolean} disabled if icon should look like disabled or not
*/
markVideoIconAsDisabled (disabled) {
var $btn = $('#toolbar_button_camera');
$btn
.prop("disabled", disabled)
.attr("data-i18n", disabled
? "[content]toolbar.cameraDisabled"
: "[content]toolbar.videomute")
.attr("shortcut", disabled ? "" : "toggleVideoPopover");
disabled
? $btn.attr("disabled", "disabled")
: $btn.removeAttr("disabled");
APP.translation.translateElement($btn);
disabled && this.markVideoIconAsMuted(disabled);
},
/**
* Marks audio icon as muted or not.
* @param {boolean} muted if icon should look like muted or not
@ -370,6 +395,29 @@ const Toolbar = {
!muted).toggleClass("icon-mic-disabled", muted);
},
/**
* Marks audio icon as disabled or not.
* @param {boolean} disabled if icon should look like disabled or not
*/
markAudioIconAsDisabled (disabled) {
var $btn = $('#toolbar_button_mute');
$btn
.prop("disabled", disabled)
.attr("data-i18n", disabled
? "[content]toolbar.micDisabled"
: "[content]toolbar.mute")
.attr("shortcut", disabled ? "" : "mutePopover");
disabled
? $btn.attr("disabled", "disabled")
: $btn.removeAttr("disabled");
APP.translation.translateElement($btn);
disabled && this.markAudioIconAsMuted(disabled);
},
/**
* Indicates if the toolbar is currently hovered.
* @return {true} if the toolbar is currently hovered, {false} otherwise

View File

@ -43,12 +43,15 @@ if (supportsLocalStorage()) {
window.localStorage.welcomePageDisabled || false
);
var audioOutputDeviceId = window.localStorage.audioOutputDeviceId;
// Currently audio output device change is supported only in Chrome and
// default output always has 'default' device ID
var audioOutputDeviceId = window.localStorage.audioOutputDeviceId
|| 'default';
if (typeof audioOutputDeviceId !== 'undefined' && audioOutputDeviceId !==
JitsiMeetJS.mediaDevices.getAudioOutputDevice()) {
JitsiMeetJS.mediaDevices.setAudioOutputDevice(
window.localStorage.audioOutputDeviceId).catch((ex) => {
if (audioOutputDeviceId !==
JitsiMeetJS.mediaDevices.getAudioOutputDevice()) {
JitsiMeetJS.mediaDevices.setAudioOutputDevice(audioOutputDeviceId)
.catch((ex) => {
console.error('failed to set audio output device from local ' +
'storage', ex);
});
@ -166,10 +169,10 @@ export default {
/**
* Set device id of the audio output device which is currently in use.
* Empty string stands for default device.
* @param {string} newId new audio output device id
* @param {string} newId='default' - new audio output device id
* @returns {Promise}
*/
setAudioOutputDeviceId: function (newId = '') {
setAudioOutputDeviceId: function (newId = 'default') {
return JitsiMeetJS.mediaDevices.setAudioOutputDevice(newId)
.then(() => window.localStorage.audioOutputDeviceId = newId);
},