Merge pull request #1447 from virtuacoplenny/device-picker
New device selection modal
This commit is contained in:
commit
98004c2328
|
@ -30,6 +30,9 @@ import {
|
|||
conferenceLeft,
|
||||
EMAIL_COMMAND
|
||||
} from './react/features/base/conference';
|
||||
import {
|
||||
updateDeviceList
|
||||
} from './react/features/base/devices';
|
||||
import {
|
||||
isFatalJitsiConnectionError
|
||||
} from './react/features/base/lib-jitsi-meet';
|
||||
|
@ -1029,6 +1032,15 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the current local video track in use.
|
||||
*
|
||||
* @returns {JitsiLocalTrack}
|
||||
*/
|
||||
getLocalVideoTrack() {
|
||||
return room.getLocalVideoTrack();
|
||||
},
|
||||
|
||||
/**
|
||||
* Start using provided audio stream.
|
||||
* Stops previous audio stream.
|
||||
|
@ -1058,6 +1070,15 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the current local audio track in use.
|
||||
*
|
||||
* @returns {JitsiLocalTrack}
|
||||
*/
|
||||
getLocalAudioTrack() {
|
||||
return room.getLocalAudioTrack();
|
||||
},
|
||||
|
||||
videoSwitchInProgress: false,
|
||||
toggleScreenSharing (shareScreen = !this.isSharingScreen) {
|
||||
if (this.videoSwitchInProgress) {
|
||||
|
@ -1622,7 +1643,6 @@ export default {
|
|||
})
|
||||
.catch((err) => {
|
||||
APP.UI.showDeviceErrorDialog(null, err);
|
||||
APP.UI.setSelectedCameraFromSettings();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@ -1644,7 +1664,6 @@ export default {
|
|||
})
|
||||
.catch((err) => {
|
||||
APP.UI.showDeviceErrorDialog(err, null);
|
||||
APP.UI.setSelectedMicFromSettings();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@ -1660,7 +1679,6 @@ export default {
|
|||
logger.warn('Failed to change audio output device. ' +
|
||||
'Default or previously set audio output device ' +
|
||||
'will be used instead.', err);
|
||||
APP.UI.setSelectedAudioOutputFromSettings();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
@ -1756,8 +1774,8 @@ export default {
|
|||
}
|
||||
|
||||
mediaDeviceHelper.setCurrentMediaDevices(devices);
|
||||
|
||||
APP.UI.onAvailableDevicesChanged(devices);
|
||||
APP.store.dispatch(updateDeviceList(devices));
|
||||
});
|
||||
|
||||
this.deviceChangeListener = (devices) =>
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
.settingsContent {
|
||||
display: flex;
|
||||
display: -webkit-flex;
|
||||
|
||||
#localVideoPreview {
|
||||
width: 50%;
|
||||
align-self: baseline;
|
||||
}
|
||||
|
||||
.deviceSelection {
|
||||
display: flex;
|
||||
display: -webkit-flex;
|
||||
-webkit-flex: 1;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
align-items: left;
|
||||
margin-left: 10px;
|
||||
|
||||
.device {
|
||||
display: flex;
|
||||
margin-bottom: 5px;
|
||||
|
||||
select {
|
||||
flex: 1;
|
||||
margin_right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -113,6 +113,12 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
#deviceOptionsWrapper {
|
||||
button {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile
|
||||
*/
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
@import 'inlay';
|
||||
@import 'reload_overlay/reload_overlay';
|
||||
@import 'modals/desktop-picker/desktop-picker';
|
||||
@import 'modals/device-selection/device-selection';
|
||||
@import 'modals/dialog';
|
||||
@import 'modals/feedback/feedback';
|
||||
@import 'modals/speaker_stats/speaker_stats';
|
||||
|
@ -54,7 +55,6 @@
|
|||
@import 'welcome_page';
|
||||
@import 'toolbars';
|
||||
@import 'side_toolbar_container';
|
||||
@import 'device_settings_dialog';
|
||||
@import 'jquery.contextMenu';
|
||||
@import 'keyboard-shortcuts';
|
||||
@import 'redirect_page';
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
.device-selection {
|
||||
color: $feedbackInputTextColor;
|
||||
|
||||
.device-selectors {
|
||||
font-size: 14px;
|
||||
|
||||
> div {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
> div:last-child {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.device-selection-column-selectors,
|
||||
.device-selection-column-video {
|
||||
padding: 10px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
.device-selection-column-selectors {
|
||||
width: 46%;
|
||||
}
|
||||
.device-selection-column-video {
|
||||
width: 49%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.device-selection-video-container {
|
||||
background: black;
|
||||
height: 156px;
|
||||
margin: 15px 0 5px;
|
||||
|
||||
.video-input-preview {
|
||||
position: relative;
|
||||
|
||||
.video-input-preview-muted {
|
||||
color: $participantNameColor;
|
||||
display: none;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
&.video-muted .video-input-preview-muted {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-input-preview-display {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.audio-output-preview {
|
||||
text-align: right;
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.audio-input-preview {
|
||||
background: #f4f5f7;
|
||||
border-radius: 5px;
|
||||
height: 6px;
|
||||
|
||||
.audio-input-preview-level {
|
||||
background: #0052cc;
|
||||
border-radius: 5px;
|
||||
height: 100%;
|
||||
-webkit-transition: width .1s ease-in-out;
|
||||
-moz-transition: width .1s ease-in-out;
|
||||
-o-transition: width .1s ease-in-out;
|
||||
transition: width .1s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -422,5 +422,11 @@
|
|||
"seconds": "__count__s",
|
||||
"speakerStats": "Speaker Stats",
|
||||
"speakerTime": "Speaker Time"
|
||||
},
|
||||
"deviceSelection": {
|
||||
"deviceSettings": "Device settings",
|
||||
"noOtherDevices": "No other devices available",
|
||||
"selectADevice": "Select a device",
|
||||
"testAudio": "Test sound"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,10 @@ const logger = require("jitsi-meet-logger").getLogger(__filename);
|
|||
|
||||
var UI = {};
|
||||
|
||||
import {
|
||||
updateDeviceList
|
||||
} from '../../react/features/base/devices';
|
||||
|
||||
import Chat from "./side_pannels/chat/Chat";
|
||||
import SidePanels from "./side_pannels/SidePanels";
|
||||
import Avatar from "./avatar/Avatar";
|
||||
|
@ -1080,29 +1084,7 @@ UI.onLocalRaiseHandChanged = function (isRaisedHand) {
|
|||
* @param {object[]} devices new list of available devices
|
||||
*/
|
||||
UI.onAvailableDevicesChanged = function (devices) {
|
||||
SettingsMenu.changeDevicesList(devices);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets microphone's <select> element to select microphone ID from settings.
|
||||
*/
|
||||
UI.setSelectedMicFromSettings = function () {
|
||||
SettingsMenu.setSelectedMicFromSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets camera's <select> element to select camera ID from settings.
|
||||
*/
|
||||
UI.setSelectedCameraFromSettings = function () {
|
||||
SettingsMenu.setSelectedCameraFromSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets audio outputs's <select> element to select audio output ID from
|
||||
* settings.
|
||||
*/
|
||||
UI.setSelectedAudioOutputFromSettings = function () {
|
||||
SettingsMenu.setSelectedAudioOutputFromSettings();
|
||||
APP.store.dispatch(updateDeviceList(devices));
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
/* global $, APP, AJS, interfaceConfig, JitsiMeetJS */
|
||||
|
||||
import { openDialog } from '../../../../react/features/base/dialog';
|
||||
import { LANGUAGES } from "../../../../react/features/base/i18n";
|
||||
import { DeviceSelectionDialog }
|
||||
from '../../../../react/features/device-selection';
|
||||
|
||||
import UIUtil from "../../util/UIUtil";
|
||||
import UIEvents from "../../../../service/UI/UIEvents";
|
||||
import Settings from '../../../settings/Settings';
|
||||
|
||||
const sidePanelsContainerId = 'sideToolbarContainer';
|
||||
const deviceSelectionButtonClasses
|
||||
= 'button-control button-control_primary button-control_full-width';
|
||||
const htmlStr = `
|
||||
<div id="settings_container" class="sideToolbarContainer__inner">
|
||||
<div class="title" data-i18n="settings.title"></div>
|
||||
|
@ -19,17 +22,11 @@ const htmlStr = `
|
|||
<div id="deviceOptionsTitle" class="subTitle hide"
|
||||
data-i18n="settings.audioVideo"></div>
|
||||
<div class="sideToolbarBlock first">
|
||||
<label class="first" data-i18n="settings.selectCamera">
|
||||
</label>
|
||||
<select id="selectCamera"></select>
|
||||
</div>
|
||||
<div class="sideToolbarBlock">
|
||||
<label data-i18n="settings.selectMic"></label>
|
||||
<select id="selectMic"></select>
|
||||
</div>
|
||||
<div class="sideToolbarBlock">
|
||||
<label data-i18n="settings.selectAudioOutput"></label>
|
||||
<select id="selectAudioOutput"></select>
|
||||
<button
|
||||
class="${deviceSelectionButtonClasses}"
|
||||
data-i18n="deviceSelection.deviceSettings"
|
||||
id="deviceSelection"
|
||||
type="button"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="moderatorOptionsWrapper" class="hide">
|
||||
|
@ -89,40 +86,6 @@ function generateLanguagesOptions(items, currentLang) {
|
|||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, permissionGranted) {
|
||||
if (!permissionGranted && items.length) {
|
||||
return '<option data-i18n="settings.noPermission"></option>';
|
||||
}
|
||||
|
||||
var options = items.map(function (item) {
|
||||
let attrs = {
|
||||
value: item.deviceId
|
||||
};
|
||||
|
||||
if (item.deviceId === selectedId) {
|
||||
attrs.selected = 'selected';
|
||||
}
|
||||
|
||||
let attrsStr = UIUtil.attrsToString(attrs);
|
||||
return `<option ${attrsStr}>${item.label}</option>`;
|
||||
});
|
||||
|
||||
if (!items.length) {
|
||||
options.unshift('<option data-i18n="settings.noDevice"></option>');
|
||||
}
|
||||
|
||||
return options.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace html select element to select2 custom dropdown
|
||||
*
|
||||
|
@ -138,6 +101,34 @@ function initSelect2($el, onSelectedCb) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open DeviceSelectionDialog with a configuration based on the environment's
|
||||
* supported abilities.
|
||||
*
|
||||
* @param {boolean} isDeviceListAvailable - Whether or not device enumeration
|
||||
* is possible. This is a value obtained through an async operation whereas all
|
||||
* other configurations for the modal are obtained synchronously.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
function _openDeviceSelectionModal(isDeviceListAvailable) {
|
||||
APP.store.dispatch(openDialog(DeviceSelectionDialog, {
|
||||
currentAudioOutputId: APP.settings.getAudioOutputDeviceId(),
|
||||
currentAudioTrack: APP.conference.getLocalAudioTrack(),
|
||||
currentVideoTrack: APP.conference.getLocalVideoTrack(),
|
||||
disableAudioInputChange: !JitsiMeetJS.isMultipleAudioInputSupported(),
|
||||
disableDeviceChange: !isDeviceListAvailable
|
||||
|| !JitsiMeetJS.mediaDevices.isDeviceChangeAvailable(),
|
||||
hasAudioPermission: JitsiMeetJS.mediaDevices
|
||||
.isDevicePermissionGranted('audio'),
|
||||
hasVideoPermission: JitsiMeetJS.mediaDevices
|
||||
.isDevicePermissionGranted('video'),
|
||||
hideAudioInputPreview: !JitsiMeetJS.isCollectingLocalStats(),
|
||||
hideAudioOutputSelect: !JitsiMeetJS.mediaDevices
|
||||
.isDeviceChangeAvailable('output')
|
||||
}));
|
||||
}
|
||||
|
||||
export default {
|
||||
init (emitter) {
|
||||
initHTML();
|
||||
|
@ -181,11 +172,11 @@ export default {
|
|||
|
||||
JitsiMeetJS.mediaDevices.isDeviceListAvailable()
|
||||
.then((isDeviceListAvailable) => {
|
||||
if (isDeviceListAvailable &&
|
||||
JitsiMeetJS.mediaDevices.isDeviceChangeAvailable()) {
|
||||
this._initializeDeviceSelectionSettings(emitter);
|
||||
}
|
||||
$('#deviceSelection').on('click', () => {
|
||||
_openDeviceSelectionModal(isDeviceListAvailable);
|
||||
});
|
||||
});
|
||||
|
||||
// Only show the subtitle if this isn't the only setting section.
|
||||
if (interfaceConfig.SETTINGS_SECTIONS.length > 1)
|
||||
UIUtil.setVisible("deviceOptionsTitle", true);
|
||||
|
@ -219,30 +210,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
_initializeDeviceSelectionSettings(emitter) {
|
||||
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 start audio muted/start video muted options should be visible or not.
|
||||
* @param {boolean} show
|
||||
|
@ -286,91 +253,5 @@ export default {
|
|||
*/
|
||||
isVisible () {
|
||||
return UIUtil.isVisible(document.getElementById("settings_container"));
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets microphone's <select> element to select microphone ID from settings.
|
||||
*/
|
||||
setSelectedMicFromSettings () {
|
||||
$('#selectMic').val(Settings.getMicDeviceId());
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets camera's <select> element to select camera ID from settings.
|
||||
*/
|
||||
setSelectedCameraFromSettings () {
|
||||
$('#selectCamera').val(Settings.getCameraDeviceId());
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets audio outputs's <select> element to select audio output ID from
|
||||
* settings.
|
||||
*/
|
||||
setSelectedAudioOutputFromSettings () {
|
||||
$('#selectAudioOutput').val(Settings.getAudioOutputDeviceId());
|
||||
},
|
||||
|
||||
/**
|
||||
* Change available cameras/microphones or hide selects completely if
|
||||
* no devices available.
|
||||
* @param {{ deviceId, label, kind }[]} devices list of available devices
|
||||
*/
|
||||
changeDevicesList (devices) {
|
||||
let $selectCamera= AJS.$('#selectCamera'),
|
||||
$selectMic = AJS.$('#selectMic'),
|
||||
$selectAudioOutput = AJS.$('#selectAudioOutput'),
|
||||
$selectAudioOutputParent = $selectAudioOutput.parent();
|
||||
|
||||
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,
|
||||
selectedVideoDevice ? selectedVideoDevice.deviceId : '',
|
||||
videoPermissionGranted))
|
||||
.prop('disabled', !video.length || !videoPermissionGranted);
|
||||
|
||||
initSelect2($selectCamera);
|
||||
|
||||
$selectMic
|
||||
.html(generateDevicesOptions(
|
||||
audio,
|
||||
selectedAudioDevice ? selectedAudioDevice.deviceId : '',
|
||||
audioPermissionGranted))
|
||||
.prop('disabled', !audio.length || !audioPermissionGranted);
|
||||
|
||||
initSelect2($selectMic);
|
||||
|
||||
if (JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
|
||||
$selectAudioOutput
|
||||
.html(generateDevicesOptions(
|
||||
audioOutput,
|
||||
selectedAudioOutputDevice
|
||||
? selectedAudioOutputDevice.deviceId
|
||||
: 'default',
|
||||
videoPermissionGranted || audioPermissionGranted))
|
||||
.prop('disabled', !audioOutput.length ||
|
||||
(!videoPermissionGranted && !audioPermissionGranted));
|
||||
initSelect2($selectAudioOutput);
|
||||
|
||||
$selectAudioOutputParent.show();
|
||||
} else {
|
||||
$selectAudioOutputParent.hide();
|
||||
}
|
||||
|
||||
APP.translation.translateElement($('#settings_container option'));
|
||||
}
|
||||
};
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
"@atlaskit/button-group": "1.0.0",
|
||||
"@atlaskit/field-text": "2.0.3",
|
||||
"@atlaskit/modal-dialog": "1.2.4",
|
||||
"@atlaskit/single-select": "1.6.1",
|
||||
"@atlaskit/tabs": "1.2.5",
|
||||
"async": "0.9.0",
|
||||
"autosize": "1.18.13",
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { Symbol } from '../react';
|
||||
|
||||
/**
|
||||
* The type of Redux action which signals that the currently used audio
|
||||
* input device should be changed.
|
||||
*
|
||||
* {
|
||||
* type: SET_AUDIO_INPUT_DEVICE,
|
||||
* deviceId: string,
|
||||
* }
|
||||
*/
|
||||
export const SET_AUDIO_INPUT_DEVICE = Symbol('SET_AUDIO_INPUT_DEVICE');
|
||||
|
||||
/**
|
||||
* The type of Redux action which signals that the currently used audio
|
||||
* output device should be changed.
|
||||
*
|
||||
* {
|
||||
* type: SET_AUDIO_OUTPUT_DEVICE,
|
||||
* deviceId: string,
|
||||
* }
|
||||
*/
|
||||
export const SET_AUDIO_OUTPUT_DEVICE = Symbol('SET_AUDIO_OUTPUT_DEVICE');
|
||||
|
||||
/**
|
||||
* The type of Redux action which signals that the currently used video
|
||||
* input device should be changed.
|
||||
*
|
||||
* {
|
||||
* type: SET_VIDEO_INPUT_DEVICE,
|
||||
* deviceId: string,
|
||||
* }
|
||||
*/
|
||||
export const SET_VIDEO_INPUT_DEVICE = Symbol('SET_VIDEO_INPUT_DEVICE');
|
||||
|
||||
/**
|
||||
* The type of Redux action which signals that the list of known available
|
||||
* audio and video sources has changed.
|
||||
*
|
||||
* {
|
||||
* type: UPDATE_DEVICE_LIST,
|
||||
* devices: Array<MediaDeviceInfo>,
|
||||
* }
|
||||
*/
|
||||
export const UPDATE_DEVICE_LIST = Symbol('UPDATE_DEVICE_LIST');
|
|
@ -0,0 +1,71 @@
|
|||
import {
|
||||
SET_AUDIO_INPUT_DEVICE,
|
||||
SET_AUDIO_OUTPUT_DEVICE,
|
||||
SET_VIDEO_INPUT_DEVICE,
|
||||
UPDATE_DEVICE_LIST
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Signals to update the currently used audio input device.
|
||||
*
|
||||
* @param {string} deviceId - The id of the new audio input device.
|
||||
* @returns {{
|
||||
* type: SET_AUDIO_INPUT_DEVICE,
|
||||
* deviceId: string
|
||||
* }}
|
||||
*/
|
||||
export function setAudioInputDevice(deviceId) {
|
||||
return {
|
||||
type: SET_AUDIO_INPUT_DEVICE,
|
||||
deviceId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to update the currently used audio output device.
|
||||
*
|
||||
* @param {string} deviceId - The id of the new audio ouput device.
|
||||
* @returns {{
|
||||
* type: SET_AUDIO_OUTPUT_DEVICE,
|
||||
* deviceId: string
|
||||
* }}
|
||||
*/
|
||||
export function setAudioOutputDevice(deviceId) {
|
||||
return {
|
||||
type: SET_AUDIO_OUTPUT_DEVICE,
|
||||
deviceId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to update the currently used video input device.
|
||||
*
|
||||
* @param {string} deviceId - The id of the new video input device.
|
||||
* @returns {{
|
||||
* type: SET_VIDEO_INPUT_DEVICE,
|
||||
* deviceId: string
|
||||
* }}
|
||||
*/
|
||||
export function setVideoInputDevice(deviceId) {
|
||||
return {
|
||||
type: SET_VIDEO_INPUT_DEVICE,
|
||||
deviceId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to update the list of known audio and video devices.
|
||||
*
|
||||
* @param {Array<MediaDeviceInfo>} devices - All known available audio input,
|
||||
* audio output, and video input devices.
|
||||
* @returns {{
|
||||
* type: UPDATE_DEVICE_LIST,
|
||||
* devices: Array<MediaDeviceInfo>
|
||||
* }}
|
||||
*/
|
||||
export function updateDeviceList(devices) {
|
||||
return {
|
||||
type: UPDATE_DEVICE_LIST,
|
||||
devices
|
||||
};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export * from './actions';
|
||||
export * from './actionTypes';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
|
@ -0,0 +1,34 @@
|
|||
/* global APP */
|
||||
|
||||
import UIEvents from '../../../../service/UI/UIEvents';
|
||||
|
||||
import { MiddlewareRegistry } from '../redux';
|
||||
|
||||
import {
|
||||
SET_AUDIO_INPUT_DEVICE,
|
||||
SET_AUDIO_OUTPUT_DEVICE,
|
||||
SET_VIDEO_INPUT_DEVICE
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Implements the middleware of the feature base/devices.
|
||||
*
|
||||
* @param {Store} store - Redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
switch (action.type) {
|
||||
case SET_AUDIO_INPUT_DEVICE:
|
||||
APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
|
||||
break;
|
||||
case SET_AUDIO_OUTPUT_DEVICE:
|
||||
APP.UI.emitEvent(UIEvents.AUDIO_OUTPUT_DEVICE_CHANGED, action.deviceId);
|
||||
break;
|
||||
case SET_VIDEO_INPUT_DEVICE:
|
||||
APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
|
||||
break;
|
||||
}
|
||||
|
||||
return next(action);
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
SET_AUDIO_INPUT_DEVICE,
|
||||
SET_AUDIO_OUTPUT_DEVICE,
|
||||
SET_VIDEO_INPUT_DEVICE,
|
||||
UPDATE_DEVICE_LIST
|
||||
} from './actionTypes';
|
||||
|
||||
import { ReducerRegistry } from '../redux';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
audioInput: [],
|
||||
audioOutput: [],
|
||||
videoInput: []
|
||||
};
|
||||
|
||||
/**
|
||||
* Listen for actions which changes the state of known and used devices.
|
||||
*
|
||||
* @param {Object} state - The Redux state of the feature features/base/devices.
|
||||
* @param {Object} action - Action object.
|
||||
* @param {string} action.type - Type of action.
|
||||
* @param {Array<MediaDeviceInfo>} action.devices - All available audio and
|
||||
* video devices.
|
||||
* @returns {Object}
|
||||
*/
|
||||
ReducerRegistry.register(
|
||||
'features/base/devices',
|
||||
(state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case UPDATE_DEVICE_LIST: {
|
||||
const deviceList = _groupDevicesByKind(action.devices);
|
||||
|
||||
return {
|
||||
...deviceList
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Changing of current audio and video device id is currently
|
||||
// handled outside of react/redux. Fall through to default logic for
|
||||
// now.
|
||||
case SET_AUDIO_INPUT_DEVICE:
|
||||
case SET_VIDEO_INPUT_DEVICE:
|
||||
case SET_AUDIO_OUTPUT_DEVICE:
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts an array of media devices into an object organized by device kind.
|
||||
*
|
||||
* @param {Array<MediaDeviceInfo>} devices - Available media devices.
|
||||
* @private
|
||||
* @returns {Object} An object with the media devices split by type. The keys
|
||||
* are device type and the values are arrays with devices matching the device
|
||||
* type.
|
||||
*/
|
||||
function _groupDevicesByKind(devices) {
|
||||
return {
|
||||
audioInput: devices.filter(device => device.kind === 'audioinput'),
|
||||
audioOutput: devices.filter(device => device.kind === 'audiooutput'),
|
||||
videoInput: devices.filter(device => device.kind === 'videoinput')
|
||||
};
|
||||
}
|
|
@ -60,3 +60,24 @@ export function loadConfig(host: string, path: string = '/config.js') {
|
|||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JitsiLocalTrack model from the given device id.
|
||||
*
|
||||
* @param {string} type - The media type of track being created. Expected values
|
||||
* are "video" or "audio".
|
||||
* @param {string} deviceId - The id of the target media source.
|
||||
* @returns {Promise<JitsiLocalTrack>}
|
||||
*/
|
||||
export function createLocalTrack(type, deviceId) {
|
||||
return JitsiMeetJS
|
||||
.createLocalTracks({
|
||||
devices: [ type ],
|
||||
micDeviceId: deviceId,
|
||||
cameraDeviceId: deviceId,
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
firefox_fake_device: window.config
|
||||
&& window.config.firefox_fake_device
|
||||
}).then(([ jitsiLocalTrack ]) => jitsiLocalTrack);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { JitsiTrackEvents } from '../../base/lib-jitsi-meet';
|
||||
|
||||
/**
|
||||
* React component for displaying a audio level meter for a JitsiLocalTrack.
|
||||
*/
|
||||
class AudioInputPreview extends PureComponent {
|
||||
/**
|
||||
* AudioInputPreview component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/*
|
||||
* The JitsiLocalTrack to show an audio level meter for.
|
||||
*/
|
||||
track: React.PropTypes.object
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new AudioInputPreview instance.
|
||||
*
|
||||
* @param {Object} props - The read-only React Component props with which
|
||||
* the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
audioLevel: 0
|
||||
};
|
||||
|
||||
this._updateAudioLevel = this._updateAudioLevel.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening for audio level updates after the initial render.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._listenForAudioUpdates(this.props.track);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listening for audio level updates on the old track and starts
|
||||
* listening instead on the new track.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this._listenForAudioUpdates(nextProps.track);
|
||||
this._updateAudioLevel(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from audio level updates.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this._stopListeningForAudioUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const audioMeterFill = {
|
||||
width: `${Math.floor(this.state.audioLevel * 100)}%`
|
||||
};
|
||||
|
||||
return (
|
||||
<div className = 'audio-input-preview' >
|
||||
<div
|
||||
className = 'audio-input-preview-level'
|
||||
style = { audioMeterFill } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts listening for audio level updates from the library.
|
||||
*
|
||||
* @param {JitstiLocalTrack} track - The track to listen to for audio level
|
||||
* updates.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_listenForAudioUpdates(track) {
|
||||
this._stopListeningForAudioUpdates();
|
||||
|
||||
track && track.on(
|
||||
JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
|
||||
this._updateAudioLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops listening to further updates from the current track.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_stopListeningForAudioUpdates() {
|
||||
this.props.track && this.props.track.off(
|
||||
JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
|
||||
this._updateAudioLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the internal state of the last know audio level. The level should
|
||||
* be between 0 and 1, as the level will be used as a percentage out of 1.
|
||||
*
|
||||
* @param {number} audioLevel - The new audio level for the track.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateAudioLevel(audioLevel) {
|
||||
this.setState({
|
||||
audioLevel
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AudioInputPreview;
|
|
@ -0,0 +1,122 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
const TEST_SOUND_PATH = 'sounds/ring.wav';
|
||||
|
||||
/**
|
||||
* React component for playing a test sound through a specified audio device.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class AudioOutputPreview extends Component {
|
||||
/**
|
||||
* AudioOutputPreview component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* The device id of the audio output device to use.
|
||||
*/
|
||||
deviceId: React.PropTypes.string,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: React.PropTypes.func
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new AudioOutputPreview instance.
|
||||
*
|
||||
* @param {Object} props - The read-only React Component props with which
|
||||
* the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._audioElement = null;
|
||||
|
||||
this._onClick = this._onClick.bind(this);
|
||||
this._setAudioElement = this._setAudioElement.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the target output device on the component's audio element after
|
||||
* initial render.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._setAudioSink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the audio element when the target output device changes and the
|
||||
* audio element has re-rendered.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidUpdate() {
|
||||
this._setAudioSink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
return (
|
||||
<div className = 'audio-output-preview'>
|
||||
<a onClick = { this._onClick }>
|
||||
{ this.props.t('deviceSelection.testAudio') }
|
||||
</a>
|
||||
<audio
|
||||
preload = 'auto'
|
||||
ref = { this._setAudioElement }
|
||||
src = { TEST_SOUND_PATH } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a test sound.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
this._audioElement
|
||||
&& this._audioElement.play();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the instance variable for the component's audio element so it can be
|
||||
* accessed directly.
|
||||
*
|
||||
* @param {Object} element - The DOM element for the component's audio.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setAudioElement(element) {
|
||||
this._audioElement = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the target output device for playing the test sound.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setAudioSink() {
|
||||
this._audioElement
|
||||
&& this._audioElement.setSinkId(this.props.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(AudioOutputPreview);
|
|
@ -0,0 +1,597 @@
|
|||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
setVideoInputDevice
|
||||
} from '../../base/devices';
|
||||
import {
|
||||
Dialog,
|
||||
hideDialog
|
||||
} from '../../base/dialog';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { createLocalTrack } from '../../base/lib-jitsi-meet';
|
||||
|
||||
import AudioInputPreview from './AudioInputPreview';
|
||||
import AudioOutputPreview from './AudioOutputPreview';
|
||||
import DeviceSelector from './DeviceSelector';
|
||||
import VideoInputPreview from './VideoInputPreview';
|
||||
|
||||
/**
|
||||
* React component for previewing and selecting new audio and video sources.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class DeviceSelectionDialog extends Component {
|
||||
/**
|
||||
* DeviceSelectionDialog component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* All known audio and video devices split by type. This prop comes from
|
||||
* the app state.
|
||||
*/
|
||||
_devices: React.PropTypes.object,
|
||||
|
||||
/**
|
||||
* Device id for the current audio output device.
|
||||
*/
|
||||
currentAudioOutputId: React.PropTypes.string,
|
||||
|
||||
/**
|
||||
* JitsiLocalTrack for the current local audio.
|
||||
*
|
||||
* JitsiLocalTracks for the current audio and video, if any, should be
|
||||
* passed in for re-use in the previews. This is needed for Internet
|
||||
* Explorer, which cannot get multiple tracks from the same device, even
|
||||
* across tabs.
|
||||
*/
|
||||
currentAudioTrack: React.PropTypes.object,
|
||||
|
||||
/**
|
||||
* JitsiLocalTrack for the current local video.
|
||||
*
|
||||
* Needed for reuse. See comment for propTypes.currentAudioTrack.
|
||||
*/
|
||||
currentVideoTrack: React.PropTypes.object,
|
||||
|
||||
/**
|
||||
* Whether or not the audio selector can be interacted with. If true,
|
||||
* the audio input selector will be rendered as disabled. This is
|
||||
* specifically used to prevent audio device changing in Firefox, which
|
||||
* currently does not work due to a browser-side regression.
|
||||
*/
|
||||
disableAudioInputChange: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* True if device changing is configured to be disallowed. Selectors
|
||||
* will display as disabled.
|
||||
*/
|
||||
disableDeviceChange: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Invoked to notify the store of app state changes.
|
||||
*/
|
||||
dispatch: React.PropTypes.func,
|
||||
|
||||
/**
|
||||
* Whether or not new audio input source can be selected.
|
||||
*/
|
||||
hasAudioPermission: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not new video input sources can be selected.
|
||||
*/
|
||||
hasVideoPermission: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* If true, the audio meter will not display. Necessary for browsers or
|
||||
* configurations that do not support local stats to prevent a
|
||||
* non-responsive mic preview from displaying.
|
||||
*/
|
||||
hideAudioInputPreview: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Whether or not the audio output source selector should display. If
|
||||
* true, the audio output selector and test audio link will not be
|
||||
* rendered. This is specifically used for hiding audio output on
|
||||
* temasys browsers which do not support such change.
|
||||
*/
|
||||
hideAudioOutputSelect: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: React.PropTypes.func
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new DeviceSelectionDialog instance.
|
||||
*
|
||||
* @param {Object} props - The read-only React Component props with which
|
||||
* the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// JitsiLocalTracks to use for live previewing.
|
||||
previewAudioTrack: null,
|
||||
previewVideoTrack: null,
|
||||
|
||||
// Device ids to keep track of new selections.
|
||||
videInput: null,
|
||||
audioInput: null,
|
||||
audioOutput: null
|
||||
};
|
||||
|
||||
// Preventing closing while cleaning up previews is important for
|
||||
// supporting temasys video cleanup. Temasys requires its video object
|
||||
// to be in the dom and visible for proper detaching of tracks. Delaying
|
||||
// closure until cleanup is complete ensures no errors in the process.
|
||||
this._isClosing = false;
|
||||
|
||||
this._closeModal = this._closeModal.bind(this);
|
||||
this._getAndSetAudioOutput = this._getAndSetAudioOutput.bind(this);
|
||||
this._getAndSetAudioTrack = this._getAndSetAudioTrack.bind(this);
|
||||
this._getAndSetVideoTrack = this._getAndSetVideoTrack.bind(this);
|
||||
this._onCancel = this._onCancel.bind(this);
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up any preview tracks that might not have been cleaned up already.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
// This handles the case where neither submit nor cancel were triggered,
|
||||
// such as on modal switch. In that case, make a dying attempt to clean
|
||||
// up previews.
|
||||
if (!this._isClosing) {
|
||||
this._attemptPreviewTrackCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
return (
|
||||
<Dialog
|
||||
cancelTitleKey = { 'dialog.Cancel' }
|
||||
okTitleKey = { 'dialog.Save' }
|
||||
onCancel = { this._onCancel }
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'deviceSelection.deviceSettings' >
|
||||
<div className = 'device-selection'>
|
||||
<div className = 'device-selection-column-selectors'>
|
||||
<div className = 'device-selectors'>
|
||||
{ this._renderSelectors() }
|
||||
</div>
|
||||
{ this._renderAudioOutputPreview() }
|
||||
</div>
|
||||
<div className = 'device-selection-column-video'>
|
||||
<div className = 'device-selection-video-container'>
|
||||
<VideoInputPreview
|
||||
track = { this.state.previewVideoTrack
|
||||
|| this.props.currentVideoTrack } />
|
||||
</div>
|
||||
{ this._renderAudioInputPreview() }
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up preview tracks if they are not active tracks.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Promise>} Zero to two promises will be returned. One
|
||||
* promise can be for video cleanup and another for audio cleanup.
|
||||
*/
|
||||
_attemptPreviewTrackCleanup() {
|
||||
const cleanupPromises = [];
|
||||
|
||||
if (!this._isPreviewingCurrentVideoTrack()) {
|
||||
cleanupPromises.push(this._disposeVideoPreview());
|
||||
}
|
||||
|
||||
if (!this._isPreviewingCurrentAudioTrack()) {
|
||||
cleanupPromises.push(this._disposeAudioPreview());
|
||||
}
|
||||
|
||||
return cleanupPromises;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals to close DeviceSelectionDialog.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_closeModal() {
|
||||
this.props.dispatch(hideDialog());
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for disposing the current audio preview.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_disposeAudioPreview() {
|
||||
return this.state.previewAudioTrack
|
||||
? this.state.previewAudioTrack.dispose() : Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for disposing the current video preview.
|
||||
*
|
||||
* @private
|
||||
* @returns {Promise}
|
||||
*/
|
||||
_disposeVideoPreview() {
|
||||
return this.state.previewVideoTrack
|
||||
? this.state.previewVideoTrack.dispose() : Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when a new audio output device has been selected.
|
||||
* Updates the internal state of the user's selection.
|
||||
*
|
||||
* @param {string} deviceId - The id of the chosen audio output device.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_getAndSetAudioOutput(deviceId) {
|
||||
this.setState({
|
||||
audioOutput: deviceId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when a new audio input device has been selected.
|
||||
* Updates the internal state of the user's selection as well as the audio
|
||||
* track that should display in the preview. Will reuse the current local
|
||||
* audio track if it has been selected.
|
||||
*
|
||||
* @param {string} deviceId - The id of the chosen audio input device.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_getAndSetAudioTrack(deviceId) {
|
||||
this.setState({
|
||||
audioInput: deviceId
|
||||
}, () => {
|
||||
const cleanupPromise = this._isPreviewingCurrentAudioTrack()
|
||||
? Promise.resolve() : this._disposeAudioPreview();
|
||||
|
||||
if (this._isCurrentAudioTrack(deviceId)) {
|
||||
cleanupPromise
|
||||
.then(() => {
|
||||
this.setState({
|
||||
previewAudioTrack: this.props.currentAudioTrack
|
||||
});
|
||||
});
|
||||
} else {
|
||||
cleanupPromise
|
||||
.then(() => createLocalTrack('audio', deviceId))
|
||||
.then(jitsiLocalTrack => {
|
||||
this.setState({
|
||||
previewAudioTrack: jitsiLocalTrack
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when a new video input device has been selected. Updates
|
||||
* the internal state of the user's selection as well as the video track
|
||||
* that should display in the preview. Will reuse the current local video
|
||||
* track if it has been selected.
|
||||
*
|
||||
* @param {string} deviceId - The id of the chosen video input device.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_getAndSetVideoTrack(deviceId) {
|
||||
this.setState({
|
||||
videoInput: deviceId
|
||||
}, () => {
|
||||
const cleanupPromise = this._isPreviewingCurrentVideoTrack()
|
||||
? Promise.resolve() : this._disposeVideoPreview();
|
||||
|
||||
if (this._isCurrentVideoTrack(deviceId)) {
|
||||
cleanupPromise
|
||||
.then(() => {
|
||||
this.setState({
|
||||
previewVideoTrack: this.props.currentVideoTrack
|
||||
});
|
||||
});
|
||||
} else {
|
||||
cleanupPromise
|
||||
.then(() => createLocalTrack('video', deviceId))
|
||||
.then(jitsiLocalTrack => {
|
||||
this.setState({
|
||||
previewVideoTrack: jitsiLocalTrack
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for determining if the current local audio track has the
|
||||
* passed in device id.
|
||||
*
|
||||
* @param {string} deviceId - The device id to match against.
|
||||
* @private
|
||||
* @returns {boolean} True if the device id is being used by the local audio
|
||||
* track.
|
||||
*/
|
||||
_isCurrentAudioTrack(deviceId) {
|
||||
return this.props.currentAudioTrack
|
||||
&& this.props.currentAudioTrack.getDeviceId() === deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for determining if the current local video track has the
|
||||
* passed in device id.
|
||||
*
|
||||
* @param {string} deviceId - The device id to match against.
|
||||
* @private
|
||||
* @returns {boolean} True if the device id is being used by the local
|
||||
* video track.
|
||||
*/
|
||||
_isCurrentVideoTrack(deviceId) {
|
||||
return this.props.currentVideoTrack
|
||||
&& this.props.currentVideoTrack.getDeviceId() === deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for detecting if the current audio preview track is not
|
||||
* the currently used audio track.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} True if the current audio track is being used for
|
||||
* the preview.
|
||||
*/
|
||||
_isPreviewingCurrentAudioTrack() {
|
||||
return !this.state.previewAudioTrack
|
||||
|| this.state.previewAudioTrack === this.props.currentAudioTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for detecting if the current video preview track is not
|
||||
* the currently used video track.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} True if the current video track is being used as the
|
||||
* preview.
|
||||
*/
|
||||
_isPreviewingCurrentVideoTrack() {
|
||||
return !this.state.previewVideoTrack
|
||||
|| this.state.previewVideoTrack === this.props.currentVideoTrack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans existing preview tracks and signal to closeDeviceSelectionDialog.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} Returns false to prevent closure until cleanup is
|
||||
* complete.
|
||||
*/
|
||||
_onCancel() {
|
||||
if (this._isClosing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._isClosing = true;
|
||||
|
||||
const cleanupPromises = this._attemptPreviewTrackCleanup();
|
||||
|
||||
Promise.all(cleanupPromises)
|
||||
.then(this._closeModal)
|
||||
.catch(this._closeModal);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify changes to the preferred input/output devices and perform
|
||||
* necessary cleanup and requests to use those devices. Closes the modal
|
||||
* after cleanup and device change requests complete.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} Returns false to prevent closure until cleanup is
|
||||
* complete.
|
||||
*/
|
||||
_onSubmit() {
|
||||
if (this._isClosing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._isClosing = true;
|
||||
|
||||
const deviceChangePromises = [];
|
||||
|
||||
if (this.state.videoInput && !this._isPreviewingCurrentVideoTrack()) {
|
||||
const changeVideoPromise = this._disposeVideoPreview()
|
||||
.then(() => {
|
||||
this.props.dispatch(setVideoInputDevice(
|
||||
this.state.videoInput));
|
||||
});
|
||||
|
||||
deviceChangePromises.push(changeVideoPromise);
|
||||
}
|
||||
|
||||
if (this.state.audioInput && !this._isPreviewingCurrentAudioTrack()) {
|
||||
const changeAudioPromise = this._disposeAudioPreview()
|
||||
.then(() => {
|
||||
this.props.dispatch(setAudioInputDevice(
|
||||
this.state.audioInput));
|
||||
});
|
||||
|
||||
deviceChangePromises.push(changeAudioPromise);
|
||||
}
|
||||
|
||||
if (this.state.audioOutput
|
||||
&& this.state.audioOutput !== this.props.currentAudioOutputId) {
|
||||
this.props.dispatch(setAudioOutputDevice(this.state.audioOutput));
|
||||
}
|
||||
|
||||
Promise.all(deviceChangePromises)
|
||||
.then(this._closeModal)
|
||||
.catch(this._closeModal);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AudioInputPreview for previewing if audio is being received.
|
||||
* Null will be returned if local stats for tracking audio input levels
|
||||
* cannot be obtained.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactComponent|null}
|
||||
*/
|
||||
_renderAudioInputPreview() {
|
||||
if (this.props.hideAudioInputPreview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AudioInputPreview
|
||||
track = { this.state.previewAudioTrack
|
||||
|| this.props.currentAudioTrack } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AudioOutputPreview instance for playing a test sound with the
|
||||
* passed in device id. Null will be returned if hideAudioOutput is truthy.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactComponent|null}
|
||||
*/
|
||||
_renderAudioOutputPreview() {
|
||||
if (this.props.hideAudioOutputSelect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AudioOutputPreview
|
||||
deviceId = { this.state.audioOutput
|
||||
|| this.props.currentAudioOutputId } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DeviceSelector instance based on the passed in configuration.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} props - The props for the DeviceSelector.
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderSelector(props) {
|
||||
return (
|
||||
<DeviceSelector { ...props } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates DeviceSelector instances for video output, audio input, and audio
|
||||
* output.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<ReactElement>} DeviceSelector instances.
|
||||
*/
|
||||
_renderSelectors() {
|
||||
const availableDevices = this.props._devices;
|
||||
const currentAudioId = this.state.audioInput
|
||||
|| (this.props.currentAudioTrack
|
||||
&& this.props.currentAudioTrack.getDeviceId());
|
||||
const currentAudioOutId = this.state.audioOutput
|
||||
|| this.props.currentAudioOutputId;
|
||||
|
||||
// FIXME: On temasys, without a device selected and put into local
|
||||
// storage as the default device to use, the current video device id is
|
||||
// a blank string. This is because the library gets a local video track
|
||||
// and then maps the track's device id by matching the track's label to
|
||||
// the MediaDeviceInfos returned from enumerateDevices. In WebRTC, the
|
||||
// track label is expected to return the camera device label. However,
|
||||
// temasys video track labels refer to track id, not device label, so
|
||||
// the library cannot match the track to a device. The workaround of
|
||||
// defaulting to the first videoInput available has been re-used from
|
||||
// the previous device settings implementation.
|
||||
const currentVideoId = this.state.videoInput
|
||||
|| (this.props.currentVideoTrack
|
||||
&& this.props.currentVideoTrack.getDeviceId())
|
||||
|| (availableDevices.videoInput[0]
|
||||
&& availableDevices.videoInput[0].deviceId)
|
||||
|| ''; // DeviceSelector expects a string for prop selectedDeviceId.
|
||||
|
||||
const configurations = [
|
||||
{
|
||||
devices: availableDevices.videoInput,
|
||||
hasPermission: this.props.hasVideoPermission,
|
||||
isDisabled: this.props.disableDeviceChange,
|
||||
key: 'videoInput',
|
||||
label: 'settings.selectCamera',
|
||||
onSelect: this._getAndSetVideoTrack,
|
||||
selectedDeviceId: currentVideoId
|
||||
},
|
||||
{
|
||||
devices: availableDevices.audioInput,
|
||||
hasPermission: this.props.hasAudioPermission,
|
||||
isDisabled: this.props.disableAudioInputChange
|
||||
|| this.props.disableDeviceChange,
|
||||
key: 'audioInput',
|
||||
label: 'settings.selectMic',
|
||||
onSelect: this._getAndSetAudioTrack,
|
||||
selectedDeviceId: currentAudioId
|
||||
}
|
||||
];
|
||||
|
||||
if (!this.props.hideAudioOutputSelect) {
|
||||
configurations.push({
|
||||
devices: availableDevices.audioOutput,
|
||||
hasPermission: this.props.hasAudioPermission
|
||||
|| this.props.hasVideoPermission,
|
||||
isDisabled: this.props.disableDeviceChange,
|
||||
key: 'audioOutput',
|
||||
label: 'settings.selectAudioOutput',
|
||||
onSelect: this._getAndSetAudioOutput,
|
||||
selectedDeviceId: currentAudioOutId
|
||||
});
|
||||
}
|
||||
|
||||
return configurations.map(this._renderSelector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated DeviceSelectionDialog's
|
||||
* props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _devices: Object
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state) {
|
||||
return {
|
||||
_devices: state['features/base/devices']
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(DeviceSelectionDialog));
|
|
@ -0,0 +1,180 @@
|
|||
import Select from '@atlaskit/single-select';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
/**
|
||||
* React component for selecting a device from a select element. Wraps Select
|
||||
* with device selection specific logic.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class DeviceSelector extends Component {
|
||||
/**
|
||||
* DeviceSelector component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* MediaDeviceInfos used for display in the select element.
|
||||
*/
|
||||
devices: React.PropTypes.array,
|
||||
|
||||
/**
|
||||
* If false, will return a selector with no selection options.
|
||||
*/
|
||||
hasPermission: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* If true, will render the selector disabled with a default selection.
|
||||
*/
|
||||
isDisabled: React.PropTypes.bool,
|
||||
|
||||
/**
|
||||
* The translation key to display as a menu label.
|
||||
*/
|
||||
label: React.PropTypes.string,
|
||||
|
||||
/**
|
||||
* The callback to invoke when a selection is made.
|
||||
*/
|
||||
onSelect: React.PropTypes.func,
|
||||
|
||||
/**
|
||||
* The default device to display as selected.
|
||||
*/
|
||||
selectedDeviceId: React.PropTypes.string,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: React.PropTypes.func
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new DeviceSelector instance.
|
||||
*
|
||||
* @param {Object} props - The read-only React Component props with which
|
||||
* the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._onSelect = this._onSelect.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
if (!this.props.hasPermission) {
|
||||
return this._renderNoPermission();
|
||||
}
|
||||
|
||||
if (!this.props.devices.length) {
|
||||
return this._renderNoDevices();
|
||||
}
|
||||
|
||||
const items = this.props.devices.map(this._createSelectItem);
|
||||
const defaultSelected = items.find(item =>
|
||||
item.value === this.props.selectedDeviceId
|
||||
);
|
||||
|
||||
return this._createSelector({
|
||||
defaultSelected,
|
||||
isDisabled: this.props.isDisabled,
|
||||
items,
|
||||
placeholder: 'deviceSelection.selectADevice'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an object in the format expected by Select for an option element.
|
||||
*
|
||||
* @param {MediaDeviceInfo} device - An object with a label and a deviceId.
|
||||
* @private
|
||||
* @returns {Object} The passed in media device description converted to a
|
||||
* format recognized as a valid Select item.
|
||||
*/
|
||||
_createSelectItem(device) {
|
||||
return {
|
||||
content: device.label,
|
||||
value: device.deviceId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Select Component using passed in props and options.
|
||||
*
|
||||
* @param {Object} options - Additional configuration for display Select.
|
||||
* @param {Object} options.defaultSelected - The option that should be set
|
||||
* as currently chosen.
|
||||
* @param {boolean} options.isDisabled - If true Select will not open on
|
||||
* click.
|
||||
* @param {Array} options.items - All the selectable options to display.
|
||||
* @param {string} options.placeholder - The translation key to display when
|
||||
* no selection has been made.
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_createSelector(options) {
|
||||
return (
|
||||
<Select
|
||||
defaultSelected = { options.defaultSelected }
|
||||
isDisabled = { options.isDisabled }
|
||||
isFirstChild = { true }
|
||||
items = { [ { items: options.items || [] } ] }
|
||||
label = { this.props.t(this.props.label) }
|
||||
noMatchesFound
|
||||
= { this.props.t('deviceSelection.noOtherDevices') }
|
||||
onSelected = { this._onSelect }
|
||||
placeholder = { this.props.t(options.placeholder) }
|
||||
shouldFitContainer = { true } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the passed in callback to notify of selection changes.
|
||||
*
|
||||
* @param {Object} selection - Event returned from Select.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelect(selection) {
|
||||
this.props.onSelect(selection.item.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Select Component that is disabled and has a placeholder
|
||||
* indicating there are no devices to select.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderNoDevices() {
|
||||
return this._createSelector({
|
||||
isDisabled: true,
|
||||
placeholder: 'settings.noDevice'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Select Component that is disabled and has a placeholder stating
|
||||
* there is no permission to display the devices.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderNoPermission() {
|
||||
return this._createSelector({
|
||||
isDisabled: true,
|
||||
placeholder: 'settings.noPermission'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(DeviceSelector);
|
|
@ -0,0 +1,203 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
const VIDEO_MUTE_CLASS = 'video-muted';
|
||||
|
||||
/**
|
||||
* React component for displaying video. This component defers to lib-jitsi-meet
|
||||
* logic for rendering the video.
|
||||
*
|
||||
* @extends Component
|
||||
*/
|
||||
class VideoInputPreview extends Component {
|
||||
/**
|
||||
* VideoInputPreview component's property types.
|
||||
*
|
||||
* @static
|
||||
*/
|
||||
static propTypes = {
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: React.PropTypes.func,
|
||||
|
||||
/*
|
||||
* The JitsiLocalTrack to display.
|
||||
*/
|
||||
track: React.PropTypes.object
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new VideoInputPreview instance.
|
||||
*
|
||||
* @param {Object} props - The read-only React Component props with which
|
||||
* the new instance is to be initialized.
|
||||
*/
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._rootElement = null;
|
||||
this._videoElement = null;
|
||||
|
||||
this._setRootElement = this._setRootElement.bind(this);
|
||||
this._setVideoElement = this._setVideoElement.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the library for rendering the video on initial display.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentDidMount() {
|
||||
this._attachTrack(this.props.track);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any existing associations between the current previewed track and
|
||||
* the component's video element.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
componentWillUnmount() {
|
||||
this._detachTrack(this.props.track);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className = 'video-input-preview'
|
||||
ref = { this._setRootElement }>
|
||||
<video
|
||||
autoPlay = { true }
|
||||
className = 'video-input-preview-display flipVideoX'
|
||||
ref = { this._setVideoElement } />
|
||||
<div className = 'video-input-preview-muted'>
|
||||
{ this.props.t('videothumbnail.muted') }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only update when the deviceId has changed. This component is somewhat
|
||||
* black-boxed from React's rendering so lib-jitsi-meet can instead handle
|
||||
* updating of the video preview, which takes browser differences into
|
||||
* consideration. For example, temasys's video object must be visible to
|
||||
* update the displayed track, but React's re-rendering could potentially
|
||||
* remove the video object from the page.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (nextProps.track !== this.props.track) {
|
||||
this._detachTrack(this.props.track);
|
||||
this._attachTrack(nextProps.track);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls into the passed in track to associate the track with the
|
||||
* component's video element and render video. Also sets the instance
|
||||
* variable for the video element as the element the track attached to,
|
||||
* which could be an Object if on a temasys supported browser.
|
||||
*
|
||||
* @param {JitsiLocalTrack} track - The library's track model which will be
|
||||
* displayed.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_attachTrack(track) {
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not attempt to display a preview if the track is muted, as the
|
||||
// library will simply return a falsy value for the element anyway.
|
||||
if (track.isMuted()) {
|
||||
this._showMuteOverlay(true);
|
||||
} else {
|
||||
this._showMuteOverlay(false);
|
||||
|
||||
const updatedVideoElement = track.attach(this._videoElement);
|
||||
|
||||
this._setVideoElement(updatedVideoElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the association to the component's video element from the passed
|
||||
* in JitsiLocalTrack to stop the track from rendering. With temasys, the
|
||||
* video element must still be visible for detaching to complete.
|
||||
*
|
||||
* @param {JitsiLocalTrack} track - The library's track model which needs
|
||||
* to stop previewing in the video element.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_detachTrack(track) {
|
||||
// Detach the video element from the track only if it has already
|
||||
// been attached. This accounts for a special case with temasys
|
||||
// where if detach is being called before attach, the video
|
||||
// element is converted to Object without updating this
|
||||
// component's reference to the video element.
|
||||
if (this._videoElement
|
||||
&& track
|
||||
&& track.containers.includes(this._videoElement)) {
|
||||
track.detach(this._videoElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the component's root element.
|
||||
*
|
||||
* @param {Object} element - The highest DOM element in the component.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRootElement(element) {
|
||||
this._rootElement = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an instance variable for the component's video element so it can be
|
||||
* referenced later for attaching and detaching a JitsiLocalTrack.
|
||||
*
|
||||
* @param {Object} element - DOM element for the component's video display.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_setVideoElement(element) {
|
||||
this._videoElement = element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or removes a class to the component's parent node to indicate mute
|
||||
* status.
|
||||
*
|
||||
* @param {boolean} shouldShow - True if the mute class should be added and
|
||||
* false if the class should be removed.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_showMuteOverlay(shouldShow) {
|
||||
if (shouldShow) {
|
||||
this._rootElement.classList.add(VIDEO_MUTE_CLASS);
|
||||
} else {
|
||||
this._rootElement.classList.remove(VIDEO_MUTE_CLASS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(VideoInputPreview);
|
|
@ -0,0 +1 @@
|
|||
export { default as DeviceSelectionDialog } from './DeviceSelectionDialog';
|
|
@ -0,0 +1 @@
|
|||
export * from './components';
|
Loading…
Reference in New Issue