feat: new device selection modal with previews

The Device Selection modal consists of:
- DeviceSelection, an overly smart component responsible for
  triggering stream creation and cleanup.
- DeviceSelector for selector elements.
- VideoInputPreview for displaying a video preview.
- AudioInputPreview for displaying a volume meter.
- AudioOutputPreview for a test sound output link.

Store changes include is primarily storing the list of
available devices in redux. Other app state has been left
alone for future refactoring.
This commit is contained in:
Leonard Kim 2017-03-29 10:43:30 -07:00
parent 2ffef3bdda
commit 2f994b1227
19 changed files with 1595 additions and 1 deletions

View File

@ -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) {
@ -1756,8 +1777,8 @@ export default {
}
mediaDeviceHelper.setCurrentMediaDevices(devices);
APP.UI.onAvailableDevicesChanged(devices);
APP.store.dispatch(updateDeviceList(devices));
});
this.deviceChangeListener = (devices) =>

View File

@ -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';

View File

@ -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;
}
}
}

View File

@ -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"
}
}

View File

@ -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";
@ -1081,6 +1085,7 @@ UI.onLocalRaiseHandChanged = function (isRaisedHand) {
*/
UI.onAvailableDevicesChanged = function (devices) {
SettingsMenu.changeDevicesList(devices);
APP.store.dispatch(updateDeviceList(devices));
};
/**

View File

@ -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",

View File

@ -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');

View File

@ -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
};
}

View File

@ -0,0 +1,5 @@
export * from './actions';
export * from './actionTypes';
import './middleware';
import './reducer';

View File

@ -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);
});

View File

@ -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')
};
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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);

View File

@ -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));

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1 @@
export { default as DeviceSelectionDialog } from './DeviceSelectionDialog';

View File

@ -0,0 +1 @@
export * from './components';