diff --git a/config.js b/config.js
index 57cf31d25f..b5cb2cacb9 100644
--- a/config.js
+++ b/config.js
@@ -264,6 +264,11 @@ var config = {
// a call is hangup.
// enableClosePage: false,
+ // Enabling pre join page will add an additional step before starting the meeting,
+ // where the user can configure its devices and choose the way he
+ // joins audio (by phone/or web).
+ // prejoinPageEnabled: false,
+
// Disable hiding of remote thumbnails when in a 1-on-1 conference call.
// disable1On1Mode: false,
diff --git a/css/_atlaskit_overrides.scss b/css/_atlaskit_overrides.scss
index 0b110c0a35..66e9f4a1f2 100644
--- a/css/_atlaskit_overrides.scss
+++ b/css/_atlaskit_overrides.scss
@@ -48,3 +48,19 @@
.toolbox-button-wth-dialog .eYJELv {
max-height: initial;
}
+
+/**
+ * Override @atlaskit/InlineDialog styling for the video preview
+ */
+.video-preview .eYJELv {
+ outline: none;
+ padding: 16px;
+}
+
+/**
+ * Override @atlaskit/InlineDialog styling for the audio preview
+ */
+.audio-preview .eYJELv {
+ outline: none;
+ padding: 0;
+}
diff --git a/css/_audio-preview.css b/css/_audio-preview.css
new file mode 100644
index 0000000000..19f71c81da
--- /dev/null
+++ b/css/_audio-preview.css
@@ -0,0 +1,124 @@
+.audio-preview {
+ &-content {
+ font-size: 15px;
+ line-height: 24px;
+ max-height: 456px;
+ overflow: auto;
+ width: 328px;
+ }
+
+ &-header {
+ color: #fff;
+ display: flex;
+ padding: 16px;
+
+ &-icon {
+ display: inline-block;
+ }
+
+ &-text {
+ font-weight: bold;
+ margin-left: 8px;
+ }
+ }
+
+ &-entry {
+ align-items: center;
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ padding: 12px 0;
+ margin-left: 48px;
+
+ &--selected {
+ background: rgba(28,32,37,0.5);
+ cursor: initial;
+ margin-left: 0;
+ padding-left: 21px;
+ }
+
+ &-text {
+ color: #fff;
+ font-size: 15px;
+ display: inline-block;
+ line-height: 24px;
+ text-overflow: ellipsis;
+ max-width: 213px;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ }
+
+ &-speaker {
+ position: relative;
+
+ &:hover {
+ .audio-preview-entry {
+ background: rgba(255,255,255, 0.2);
+ margin-left: 0;
+ padding-left: 48px;
+
+ &--selected {
+ padding-left: 21px;
+ }
+ }
+
+ .audio-preview-test-button {
+ display: inline-block;
+ }
+ }
+
+ .audio-preview-entry-text {
+ max-width: 256px;
+ }
+ }
+
+ &-microphone {
+ position: relative;
+ }
+
+
+ &-icon {
+ border-radius: 50%;
+ display: inline-block;
+ width: 14px;
+
+ & svg {
+ fill: #1C2025;
+ }
+
+ &--check {
+ background: #31B76A;
+ margin-right: 13px;
+ }
+
+ &--exclamation {
+ margin-left: 6px;
+ & svg {
+ fill: #E54B4B;
+ }
+ }
+ }
+
+ &-test-button {
+ display: none;
+ background: #FFF;
+ border: 1px solid #D1DBE8;
+ border-radius: 3px;
+ color: #1C2025;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 15px;
+ line-height: 24px;
+ padding: 4px 16px;
+ position: absolute;
+ right: 16px;
+ top: 8px;
+ }
+
+ &-meter-mic {
+ position: absolute;
+ right: 16px;
+ top: 18px;
+ }
+}
diff --git a/css/_meter.css b/css/_meter.css
new file mode 100644
index 0000000000..7d5c4c80aa
--- /dev/null
+++ b/css/_meter.css
@@ -0,0 +1,33 @@
+.jitsi-icon {
+ &.metr {
+ display: inline-block;
+
+ & > svg {
+ fill: #76CF9C;
+ width: 38px;
+ }
+ }
+
+ &.metr--disabled {
+ & > svg {
+ fill: #5E6D7A;
+ }
+ }
+}
+
+.metr-l-0 {
+ rect:first-child {
+ fill: #279255;
+ }
+}
+
+@for $i from 1 through 7 {
+ .metr-l-#{$i} {
+ rect:nth-child(-n+#{$i+1}) {
+ fill: #31B76A;
+ }
+ rect:first-child {
+ fill: #279255;
+ }
+ }
+}
diff --git a/css/_settings-button.scss b/css/_settings-button.scss
new file mode 100644
index 0000000000..94009ba48f
--- /dev/null
+++ b/css/_settings-button.scss
@@ -0,0 +1,76 @@
+.settings-button {
+ &-container {
+ position: relative;
+
+ .toolbox-icon {
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ background-color: #fff;
+ border-radius: 50%;
+ border: 1px solid #d1dbe8;
+ justify-content: center;
+ width: 38px;
+ height: 38px;
+
+ &:hover {
+ background-color: #daebfa;
+ border: 1px solid #daebfa;
+ }
+
+ &.toggled {
+ background: #2a3a4b;
+ border: 1px solid #5e6d7a;
+
+ svg {
+ fill: #fff;
+ }
+
+ &:hover {
+ background-color: #5e6d7a;
+ }
+ }
+
+ &.disabled, .disabled & {
+ cursor: initial;
+ color: #fff;
+ background-color: #a4b8d1;
+ }
+
+ svg {
+ fill: #5e6d7a;
+ }
+ }
+ }
+
+ &-small-icon {
+ background: #FFF;
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ border-radius: 50%;
+ bottom: 0;
+ box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.25);
+ cursor: pointer;
+ height: 18px;
+ position: absolute;
+ text-align: center;
+ right: 2px;
+ width: 18px;
+
+ &:hover {
+ background-color: #daebfa;
+ }
+
+ &> svg {
+ margin-top: 5px;
+ }
+
+ &--disabled {
+ background-color: #a4b8d1;
+ cursor: default;
+
+ &:hover {
+ background-color: #a4b8d1;
+ }
+ }
+ }
+}
diff --git a/css/_video-preview.css b/css/_video-preview.css
new file mode 100644
index 0000000000..e2c6ec58a6
--- /dev/null
+++ b/css/_video-preview.css
@@ -0,0 +1,43 @@
+.video-preview {
+ &-entry {
+ cursor: pointer;
+ height: 135px;
+ margin-bottom: 16px;
+ position: relative;
+ width: 240px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ &--selected {
+ border: 3px solid #31B76A;
+ cursor: default;
+ height: 129px;
+ width: 234px;
+ }
+ }
+
+ &-video {
+ height: 100%;
+ object-fit: cover;
+ width: 100%;
+ }
+
+ &-overlay {
+ background: rgba(42, 58, 75, 0.6);
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ z-index: 1;
+ }
+
+ &-error {
+ align-items: center;
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ position: absolute;
+ width: 100%;
+ }
+}
diff --git a/css/main.scss b/css/main.scss
index c3941e5bc9..e6269786f3 100644
--- a/css/main.scss
+++ b/css/main.scss
@@ -86,5 +86,9 @@ $flagsImagePath: "../images/";
@import 'avatar';
@import 'promotional-footer';
@import 'chrome-extension-banner';
+@import 'settings-button';
+@import 'meter';
+@import 'audio-preview';
+@import 'video-preview';
/* Modules END */
diff --git a/lang/main.json b/lang/main.json
index bd6d17b7cb..9fbe339479 100644
--- a/lang/main.json
+++ b/lang/main.json
@@ -532,6 +532,7 @@
"selectAudioOutput": "Audio output",
"selectCamera": "Camera",
"selectMic": "Microphone",
+ "speakers": "Speakers",
"startAudioMuted": "Everyone starts muted",
"startVideoMuted": "Everyone starts hidden",
"title": "Settings"
@@ -648,7 +649,7 @@
"noAudioSignalDesc": "If you did not purposely mute it from system settings or hardware, consider switching the device.",
"noAudioSignalDescSuggestion": "If you did not purposely mute it from system settings or hardware, consider switching to the suggested device.",
"noAudioSignalDialInDesc": "You can also dial-in using:",
- "noAudioSignalDialInLinkDesc" : "Dial-in numbers",
+ "noAudioSignalDialInLinkDesc": "Dial-in numbers",
"noisyAudioInputTitle": "Your microphone appears to be noisy!",
"noisyAudioInputDesc": "It sounds like your microphone is making noise, please consider muting or changing the device.",
"openChat": "Open chat",
diff --git a/react/features/base/devices/actions.js b/react/features/base/devices/actions.js
index b05ea8dd9e..e5e3dd175c 100644
--- a/react/features/base/devices/actions.js
+++ b/react/features/base/devices/actions.js
@@ -216,6 +216,18 @@ export function setAudioInputDevice(deviceId) {
};
}
+/**
+ * Updates the output device id.
+ *
+ * @param {string} deviceId - The id of the new output device.
+ * @returns {Function}
+ */
+export function setAudioOutputDevice(deviceId) {
+ return function(dispatch) {
+ return setAudioOutputDeviceId(deviceId, dispatch);
+ };
+}
+
/**
* Signals to update the currently used video input device.
*
diff --git a/react/features/base/devices/functions.js b/react/features/base/devices/functions.js
index 05d4eabef0..7ca0fe0f3d 100644
--- a/react/features/base/devices/functions.js
+++ b/react/features/base/devices/functions.js
@@ -174,6 +174,60 @@ export function formatDeviceLabel(label: string) {
return formattedLabel;
}
+/**
+ * Returns a list of objects containing all the microphone device ids and labels.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {Object[]}
+ */
+export function getAudioInputDeviceData(state: Object) {
+ return state['features/base/devices'].availableDevices.audioInput.map(
+ ({ deviceId, label }) => {
+ return {
+ deviceId,
+ label
+ };
+ });
+}
+
+/**
+ * Returns a list of objectes containing all the output device ids and labels.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {Object[]}
+ */
+export function getAudioOutputDeviceData(state: Object) {
+ return state['features/base/devices'].availableDevices.audioOutput.map(
+ ({ deviceId, label }) => {
+ return {
+ deviceId,
+ label
+ };
+ });
+}
+
+/**
+ * Returns a list of all the camera device ids.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {string[]}
+ */
+export function getVideoDeviceIds(state: Object) {
+ return state['features/base/devices'].availableDevices.videoInput.map(({ deviceId }) => deviceId);
+}
+
+/**
+ * Returns true if there are devices of a specific type.
+ *
+ * @param {Object} state - The state of the application.
+ * @param {string} type - The type of device: VideoOutput | audioOutput | audioInput.
+ *
+ * @returns {boolean}
+ */
+export function hasAvailableDevices(state: Object, type: string) {
+ return state['features/base/devices'].availableDevices[type].length > 0;
+}
+
/**
* Set device id of the audio output device which is currently in use.
* Empty string stands for default device.
diff --git a/react/features/base/icons/svg/arrow_down.svg b/react/features/base/icons/svg/arrow_down.svg
new file mode 100644
index 0000000000..7cd9b05e9e
--- /dev/null
+++ b/react/features/base/icons/svg/arrow_down.svg
@@ -0,0 +1,3 @@
+
diff --git a/react/features/base/icons/svg/exclamation-solid.svg b/react/features/base/icons/svg/exclamation-solid.svg
new file mode 100644
index 0000000000..fe052bc44f
--- /dev/null
+++ b/react/features/base/icons/svg/exclamation-solid.svg
@@ -0,0 +1,3 @@
+
diff --git a/react/features/base/icons/svg/exclamation.svg b/react/features/base/icons/svg/exclamation.svg
new file mode 100644
index 0000000000..e276eac240
--- /dev/null
+++ b/react/features/base/icons/svg/exclamation.svg
@@ -0,0 +1,3 @@
+
diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js
index a96e515c36..13c363198b 100644
--- a/react/features/base/icons/svg/index.js
+++ b/react/features/base/icons/svg/index.js
@@ -3,6 +3,7 @@
export { default as IconAdd } from './add.svg';
export { default as IconAddPeople } from './link.svg';
export { default as IconArrowBack } from './arrow_back.svg';
+export { default as IconArrowDown } from './arrow_down.svg';
export { default as IconAudioOnly } from './visibility.svg';
export { default as IconAudioOnlyOff } from './visibility-off.svg';
export { default as IconAudioRoute } from './volume.svg';
@@ -27,6 +28,8 @@ export { default as IconDominantSpeaker } from './dominant-speaker.svg';
export { default as IconDownload } from './download.svg';
export { default as IconDragHandle } from './drag-handle.svg';
export { default as IconEventNote } from './event_note.svg';
+export { default as IconExclamation } from './exclamation.svg';
+export { default as IconExclamationSolid } from './exclamation-solid.svg';
export { default as IconExitFullScreen } from './exit-full-screen.svg';
export { default as IconFeedback } from './feedback.svg';
export { default as IconFullScreen } from './full-screen.svg';
@@ -41,8 +44,10 @@ export { default as IconMenuDown } from './menu-down.svg';
export { default as IconMenuThumb } from './thumb-menu.svg';
export { default as IconMenuUp } from './menu-up.svg';
export { default as IconMessage } from './message.svg';
+export { default as IconMeter } from './meter.svg';
export { default as IconMicDisabled } from './mic-disabled.svg';
export { default as IconMicrophone } from './microphone.svg';
+export { default as IconMicrophoneEmpty } from './microphone-empty.svg';
export { default as IconModerator } from './star.svg';
export { default as IconMuteEveryone } from './mute-everyone.svg';
export { default as IconMuteEveryoneElse } from './mute-everyone-else.svg';
@@ -76,3 +81,4 @@ export { default as IconVideoQualityHD } from './HD.svg';
export { default as IconVideoQualityLD } from './LD.svg';
export { default as IconVideoQualitySD } from './SD.svg';
export { default as IconVolume } from './volume.svg';
+export { default as IconVolumeEmpty } from './volume-empty.svg';
diff --git a/react/features/base/icons/svg/meter.svg b/react/features/base/icons/svg/meter.svg
new file mode 100644
index 0000000000..59c349cc87
--- /dev/null
+++ b/react/features/base/icons/svg/meter.svg
@@ -0,0 +1,10 @@
+
diff --git a/react/features/base/icons/svg/microphone-empty.svg b/react/features/base/icons/svg/microphone-empty.svg
new file mode 100644
index 0000000000..8bacff789c
--- /dev/null
+++ b/react/features/base/icons/svg/microphone-empty.svg
@@ -0,0 +1,3 @@
+
diff --git a/react/features/base/icons/svg/volume-empty.svg b/react/features/base/icons/svg/volume-empty.svg
new file mode 100644
index 0000000000..858aa7869e
--- /dev/null
+++ b/react/features/base/icons/svg/volume-empty.svg
@@ -0,0 +1,3 @@
+
diff --git a/react/features/base/settings/functions.web.js b/react/features/base/settings/functions.web.js
index 62cfab05e4..59f8cebe74 100644
--- a/react/features/base/settings/functions.web.js
+++ b/react/features/base/settings/functions.web.js
@@ -2,6 +2,36 @@
export * from './functions.any';
+/**
+ * Returns the deviceId for the currently used camera.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {void}
+ */
+export function getCurrentCameraDeviceId(state: Object) {
+ return state['features/base/settings'].cameraDeviceId;
+}
+
+/**
+ * Returns the deviceId for the currently used microphone.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {void}
+ */
+export function getCurrentMicDeviceId(state: Object) {
+ return state['features/base/settings'].micDeviceId;
+}
+
+/**
+ * Returns the deviceId for the currently used speaker.
+ *
+ * @param {Object} state - The state of the application.
+ * @returns {void}
+ */
+export function getCurrentOutputDeviceId(state: Object) {
+ return state['features/base/settings'].audioOutputDeviceId;
+}
+
/**
* Handles changes to the `disableCallIntegration` setting.
* Noop on web.
diff --git a/react/features/base/toolbox/components/ToolboxButtonWithIcon.js b/react/features/base/toolbox/components/ToolboxButtonWithIcon.js
new file mode 100644
index 0000000000..ad2b46486d
--- /dev/null
+++ b/react/features/base/toolbox/components/ToolboxButtonWithIcon.js
@@ -0,0 +1,66 @@
+// @flow
+
+import React from 'react';
+import { Icon } from '../../icons';
+
+type Props = {
+
+ /**
+ * The decorated component (ToolboxButton).
+ */
+ children: React$Node,
+
+ /**
+ * Icon of the button.
+ */
+ icon: Function,
+
+ /**
+ * Flag used for disabling the small icon.
+ */
+ iconDisabled: boolean,
+
+ /**
+ * Click handler for the small icon.
+ */
+ onIconClick: Function,
+
+ /**
+ * Additional styles.
+ */
+ styles?: Object,
+}
+
+/**
+ * Displayes the `ToolboxButtonWithIcon` component.
+ *
+ * @returns {ReactElement}
+ */
+export default function ToolboxButtonWithIcon({
+ children,
+ icon,
+ iconDisabled,
+ onIconClick,
+ styles
+}: Props) {
+ const iconProps = {};
+
+ if (iconDisabled) {
+ iconProps.className = 'settings-button-small-icon settings-button-small-icon--disabled';
+ } else {
+ iconProps.className = 'settings-button-small-icon';
+ iconProps.onClick = onIconClick;
+ }
+
+ return (
+
+ { children }
+
+
+ );
+}
diff --git a/react/features/base/toolbox/components/index.js b/react/features/base/toolbox/components/index.js
index 3f5d2c1d00..f71e12ebb0 100644
--- a/react/features/base/toolbox/components/index.js
+++ b/react/features/base/toolbox/components/index.js
@@ -7,3 +7,4 @@ export { default as AbstractHangupButton } from './AbstractHangupButton';
export { default as AbstractVideoMuteButton } from './AbstractVideoMuteButton';
export { default as BetaTag } from './BetaTag';
export { default as OverflowMenuItem } from './OverflowMenuItem';
+export { default as ToolboxButtonWithIcon } from './ToolboxButtonWithIcon';
diff --git a/react/features/settings/actionTypes.js b/react/features/settings/actionTypes.js
index 9d6c62d710..58ed9c3c84 100644
--- a/react/features/settings/actionTypes.js
+++ b/react/features/settings/actionTypes.js
@@ -1,3 +1,6 @@
+// The type of (redux) action which sets the visibility of the audio settings popup.
+export const SET_AUDIO_SETTINGS_VISIBILITY = 'SET_AUDIO_SETTINGS_VISIBILITY';
+
/**
* The type of (redux) action which sets the visibility of the view/UI rendering
* the app's settings.
@@ -8,3 +11,6 @@
* }
*/
export const SET_SETTINGS_VIEW_VISIBLE = 'SET_SETTINGS_VIEW_VISIBLE';
+
+// The type of (redux) action which sets the visibility of the video settings popup.
+export const SET_VIDEO_SETTINGS_VISIBILITY = 'SET_VIDEO_SETTINGS_VISIBILITY';
diff --git a/react/features/settings/actions.js b/react/features/settings/actions.js
index 04194cd061..bbb5fa8846 100644
--- a/react/features/settings/actions.js
+++ b/react/features/settings/actions.js
@@ -4,7 +4,11 @@ import { setFollowMe, setStartMutedPolicy } from '../base/conference';
import { openDialog } from '../base/dialog';
import { i18next } from '../base/i18n';
-import { SET_SETTINGS_VIEW_VISIBLE } from './actionTypes';
+import {
+ SET_AUDIO_SETTINGS_VISIBILITY,
+ SET_SETTINGS_VIEW_VISIBLE,
+ SET_VIDEO_SETTINGS_VISIBILITY
+} from './actionTypes';
import { SettingsDialog } from './components';
import { getMoreTabProps, getProfileTabProps } from './functions';
@@ -38,6 +42,31 @@ export function openSettingsDialog(defaultTab: string) {
return openDialog(SettingsDialog, { defaultTab });
}
+/**
+ * Sets the visiblity of the audio settings.
+ *
+ * @param {boolean} value - The new value.
+ * @returns {Function}
+ */
+function setAudioSettingsVisibility(value: boolean) {
+ return {
+ type: SET_AUDIO_SETTINGS_VISIBILITY,
+ value
+ };
+}
+
+/**
+ * Sets the visiblity of the video settings.
+ *
+ * @param {boolean} value - The new value.
+ * @returns {Function}
+ */
+function setVideoSettingsVisibility(value: boolean) {
+ return {
+ type: SET_VIDEO_SETTINGS_VISIBILITY,
+ value
+ };
+}
/**
* Submits the settings from the "More" tab of the settings dialog.
@@ -84,3 +113,29 @@ export function submitProfileTab(newState: Object): Function {
}
};
}
+
+/**
+ * Toggles the visiblity of the audio settings.
+ *
+ * @returns {void}
+ */
+export function toggleAudioSettings() {
+ return (dispatch: Function, getState: Function) => {
+ const value = getState()['features/settings'].audioSettingsVisible;
+
+ dispatch(setAudioSettingsVisibility(!value));
+ };
+}
+
+/**
+ * Toggles the visiblity of the video settings.
+ *
+ * @returns {void}
+ */
+export function toggleVideoSettings() {
+ return (dispatch: Function, getState: Function) => {
+ const value = getState()['features/settings'].videoSettingsVisible;
+
+ dispatch(setVideoSettingsVisibility(!value));
+ };
+}
diff --git a/react/features/settings/components/web/SettingsDialog.js b/react/features/settings/components/web/SettingsDialog.js
index ea2d7de11b..c54d89244a 100644
--- a/react/features/settings/components/web/SettingsDialog.js
+++ b/react/features/settings/components/web/SettingsDialog.js
@@ -127,9 +127,11 @@ class SettingsDialog extends Component {
function _mapStateToProps(state) {
const configuredTabs = interfaceConfig.SETTINGS_SECTIONS || [];
const jwt = state['features/base/jwt'];
+ const { prejoinPageEnabled } = state['features/base/config'];
// The settings sections to display.
- const showDeviceSettings = configuredTabs.includes('devices');
+ const showDeviceSettings = !prejoinPageEnabled
+ && configuredTabs.includes('devices');
const moreTabProps = getMoreTabProps(state);
const { showModeratorSettings, showLanguageSettings } = moreTabProps;
const showProfileSettings
diff --git a/react/features/settings/components/web/audio/AudioSettingsContent.js b/react/features/settings/components/web/audio/AudioSettingsContent.js
new file mode 100644
index 0000000000..313c799180
--- /dev/null
+++ b/react/features/settings/components/web/audio/AudioSettingsContent.js
@@ -0,0 +1,262 @@
+// @flow
+
+import React, { Component } from 'react';
+
+import AudioSettingsHeader from './AudioSettingsHeader';
+import { translate } from '../../../../base/i18n';
+import { IconMicrophoneEmpty, IconVolumeEmpty } from '../../../../base/icons';
+import { createLocalAudioTrack } from '../../../functions';
+import MicrophoneEntry from './MicrophoneEntry';
+import SpeakerEntry from './SpeakerEntry';
+
+export type Props = {
+
+ /**
+ * The deviceId of the microphone in use.
+ */
+ currentMicDeviceId: string,
+
+ /**
+ * The deviceId of the output device in use.
+ */
+ currentOutputDeviceId: string,
+
+ /**
+ * Used to set a new microphone as the current one.
+ */
+ setAudioInputDevice: Function,
+
+ /**
+ * Used to set a new output device as the current one.
+ */
+ setAudioOutputDevice: Function,
+
+ /**
+ * A list of objects containing the labels and deviceIds
+ * of all the output devices.
+ */
+ outputDevices: Object[],
+
+ /**
+ * A list with objects containing the labels and deviceIds
+ * of all the input devices.
+ */
+ microphoneDevices: Object[],
+
+ /**
+ * Invoked to obtain translated strings.
+ */
+ t: Function
+};
+
+type State = {
+
+ /**
+ * An object containing the jitsiTrack and the error (if the case)
+ * for the microphone that is in use.
+ */
+ currentMicData: Object
+}
+
+/**
+ * Implements a React {@link Component} which displayes a list of all
+ * the audio input & output devices to choose from.
+ *
+ * @extends Component
+ */
+class AudioSettingsContent extends Component {
+ _componentWasUnmounted: boolean;
+
+ /**
+ * Initializes a new {@code AudioSettingsContent} instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props) {
+ super(props);
+
+ this._onMicrophoneEntryClick = this._onMicrophoneEntryClick.bind(this);
+ this._onSpeakerEntryClick = this._onSpeakerEntryClick.bind(this);
+
+ this.state = {
+ currentMicData: {
+ error: false,
+ jitsiTrack: null
+ }
+ };
+ }
+
+ _onMicrophoneEntryClick: (string) => void;
+
+ /**
+ * Click handler for the microphone entries.
+ *
+ * @param {string} deviceId - The deviceId for the clicked microphone.
+ * @returns {void}
+ */
+ _onMicrophoneEntryClick(deviceId) {
+ this.props.setAudioInputDevice(deviceId);
+ }
+
+ _onSpeakerEntryClick: (string) => void;
+
+ /**
+ * Click handler for the speaker entries.
+ *
+ * @param {string} deviceId - The deviceId for the clicked speaker.
+ * @returns {void}
+ */
+ _onSpeakerEntryClick(deviceId) {
+ this.props.setAudioOutputDevice(deviceId);
+ }
+
+ /**
+ * Renders a single microphone entry.
+ *
+ * @param {Object} data - An object with the deviceId and label of the microphone.
+ * @param {number} index - The index of the element, used for creating a key.
+ * @returns {React$Node}
+ */
+ _renderMicrophoneEntry(data, index) {
+ const { deviceId, label } = data;
+ const key = `me-${index}`;
+ const isSelected = deviceId === this.props.currentMicDeviceId;
+ let jitsiTrack = null;
+ let hasError = false;
+
+ if (isSelected) {
+ ({ jitsiTrack, hasError } = this.state.currentMicData);
+ }
+
+ return (
+
+ {label}
+
+ );
+ }
+
+ /**
+ * Renders a single speaker entry.
+ *
+ * @param {Object} data - An object with the deviceId and label of the speaker.
+ * @param {number} index - The index of the element, used for creating a key.
+ * @returns {React$Node}
+ */
+ _renderSpeakerEntry(data, index) {
+ const { deviceId, label } = data;
+ const key = `se-${index}`;
+
+ return (
+
+ {label}
+
+ );
+ }
+
+ /**
+ * Disposes the audio track for a given micData object.
+ *
+ * @param {Object} micData - The object holding the track.
+ * @returns {Promise}
+ */
+ _disposeTrack(micData) {
+ const { jitsiTrack } = micData;
+
+ return jitsiTrack ? jitsiTrack.dispose() : Promise.resolve();
+ }
+
+ /**
+ * Updates the current microphone data.
+ * Disposes previously created track and creates a new one.
+ *
+ * @returns {void}
+ */
+ async _updateCurrentMicData() {
+ await this._disposeTrack(this.state.currentMicData);
+
+ const currentMicData = await createLocalAudioTrack(
+ this.props.currentMicDeviceId,
+ );
+
+ // In case the component gets unmounted before the track is created
+ // avoid a leak by not setting the state
+ if (this._componentWasUnmounted) {
+ this._disposeTrack(currentMicData);
+ } else {
+ this.setState({
+ currentMicData
+ });
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidUpdate}.
+ *
+ * @inheritdoc
+ */
+ componentDidUpdate(prevProps) {
+ if (prevProps.currentMicDeviceId !== this.props.currentMicDeviceId) {
+ this._updateCurrentMicData();
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidMount}.
+ *
+ * @inheritdoc
+ */
+ componentDidMount() {
+ this._updateCurrentMicData();
+ }
+
+ /**
+ * Implements React's {@link Component#componentWillUnmount}.
+ *
+ * @inheritdoc
+ */
+ componentWillUnmount() {
+ this._componentWasUnmounted = true;
+ this._disposeTrack(this.state.currentMicData);
+ }
+
+ /**
+ * Implements React's {@link Component#render}.
+ *
+ * @inheritdoc
+ */
+ render() {
+ const { microphoneDevices, outputDevices, t } = this.props;
+
+ return (
+
+
+
+ {microphoneDevices.map((data, i) =>
+ this._renderMicrophoneEntry(data, i),
+ )}
+
+ {outputDevices.map((data, i) =>
+ this._renderSpeakerEntry(data, i),
+ )}
+
+
+ );
+ }
+}
+
+export default translate(AudioSettingsContent);
diff --git a/react/features/settings/components/web/audio/AudioSettingsEntry.js b/react/features/settings/components/web/audio/AudioSettingsEntry.js
new file mode 100644
index 0000000000..5331a0e918
--- /dev/null
+++ b/react/features/settings/components/web/audio/AudioSettingsEntry.js
@@ -0,0 +1,53 @@
+// @flow
+
+import React from 'react';
+
+import { Icon, IconCheck, IconExclamationSolid } from '../../../../base/icons';
+
+/**
+ * The type of the React {@code Component} props of {@link AudioSettingsEntry}.
+ */
+export type Props = {
+
+ /**
+ * The text for this component.
+ */
+ children: React$Node,
+
+ /**
+ * Flag indicating an error.
+ */
+ hasError?: boolean,
+
+ /**
+ * Flag indicating the selection state.
+ */
+ isSelected: boolean,
+};
+
+/**
+ * React {@code Component} representing an entry for the audio settings.
+ *
+ * @returns { ReactElement}
+ */
+export default function AudioSettingsEntry({ children, hasError, isSelected }: Props) {
+ const className = `audio-preview-entry ${isSelected
+ ? 'audio-preview-entry--selected' : ''}`;
+
+ return (
+
+ {isSelected && (
+
+ )}
+ {children}
+ {hasError && }
+
+ );
+}
diff --git a/react/features/settings/components/web/audio/AudioSettingsHeader.js b/react/features/settings/components/web/audio/AudioSettingsHeader.js
new file mode 100644
index 0000000000..105853ae95
--- /dev/null
+++ b/react/features/settings/components/web/audio/AudioSettingsHeader.js
@@ -0,0 +1,39 @@
+// @flow
+
+import React from 'react';
+import { Icon } from '../../../../base/icons';
+
+/**
+ * The type of the React {@code Component} props of {@link AudioSettingsHeader}.
+ */
+type Props = {
+
+ /**
+ * The Icon used for the Header.
+ */
+ IconComponent: Function,
+
+ /**
+ * The text of the Header.
+ */
+ text: string,
+};
+
+/**
+ * React {@code Component} representing the Header of an audio option group.
+ *
+ * @returns { ReactElement}
+ */
+export default function AudioSettingsHeader({ IconComponent, text }: Props) {
+ return (
+
+ );
+}
diff --git a/react/features/settings/components/web/audio/AudioSettingsPopup.js b/react/features/settings/components/web/audio/AudioSettingsPopup.js
new file mode 100644
index 0000000000..4a7803602f
--- /dev/null
+++ b/react/features/settings/components/web/audio/AudioSettingsPopup.js
@@ -0,0 +1,97 @@
+// @flow
+
+import React from 'react';
+import InlineDialog from '@atlaskit/inline-dialog';
+
+import AudioSettingsContent, { type Props as AudioSettingsContentProps } from './AudioSettingsContent';
+import { toggleAudioSettings } from '../../../actions';
+import {
+ getAudioInputDeviceData,
+ getAudioOutputDeviceData,
+ setAudioInputDevice as setAudioInputDeviceAction,
+ setAudioOutputDevice as setAudioOutputDeviceAction
+} from '../../../../base/devices';
+import { connect } from '../../../../base/redux';
+import { getAudioSettingsVisibility } from '../../../functions';
+import {
+ getCurrentMicDeviceId,
+ getCurrentOutputDeviceId
+} from '../../../../base/settings';
+
+
+type Props = AudioSettingsContentProps & {
+
+ /**
+ * Component's children (the audio button).
+ */
+ children: React$Node,
+
+ /**
+ * Flag controlling the visibility of the popup.
+ */
+ isOpen: boolean,
+
+ /**
+ * Callback executed when the popup closes.
+ */
+ onClose: Function,
+}
+
+/**
+ * Popup with audio settings.
+ *
+ * @returns {ReactElement}
+ */
+function AudioSettingsPopup({
+ children,
+ currentMicDeviceId,
+ currentOutputDeviceId,
+ isOpen,
+ microphoneDevices,
+ setAudioInputDevice,
+ setAudioOutputDevice,
+ onClose,
+ outputDevices
+}: Props) {
+ return (
+
+ }
+ isOpen = { isOpen }
+ onClose = { onClose }
+ position = 'top left'>
+ {children}
+
+
+ );
+}
+
+/**
+ * Function that maps parts of Redux state tree into component props.
+ *
+ * @param {Object} state - Redux state.
+ * @returns {Object}
+ */
+function mapStateToProps(state) {
+ return {
+ currentMicDeviceId: getCurrentMicDeviceId(state),
+ currentOutputDeviceId: getCurrentOutputDeviceId(state),
+ isOpen: getAudioSettingsVisibility(state),
+ microphoneDevices: getAudioInputDeviceData(state),
+ outputDevices: getAudioOutputDeviceData(state)
+ };
+}
+
+const mapDispatchToProps = {
+ onClose: toggleAudioSettings,
+ setAudioInputDevice: setAudioInputDeviceAction,
+ setAudioOutputDevice: setAudioOutputDeviceAction
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(AudioSettingsPopup);
diff --git a/react/features/settings/components/web/audio/Meter.js b/react/features/settings/components/web/audio/Meter.js
new file mode 100644
index 0000000000..e9ba0e5c82
--- /dev/null
+++ b/react/features/settings/components/web/audio/Meter.js
@@ -0,0 +1,45 @@
+// @flow
+
+import React from 'react';
+import { Icon, IconMeter } from '../../../../base/icons';
+
+type Props = {
+
+ /**
+ * Own class name for the component.
+ */
+ className: string,
+
+ /**
+ * Flag indicating whether the component is greyed out/disabled.
+ */
+ isDisabled?: boolean,
+
+ /**
+ * The level of the meter.
+ * Should be between 0 and 7 as per the used SVG.
+ */
+ level: number,
+};
+
+/**
+ * React {@code Component} representing an audio level meter.
+ *
+ * @returns { ReactElement}
+ */
+export default function({ className, isDisabled, level }: Props) {
+ let ownClassName;
+
+ if (level > -1) {
+ ownClassName = `metr metr-l-${level}`;
+ } else {
+ ownClassName = `metr ${isDisabled ? 'metr--disabled' : ''}`;
+ }
+
+ return (
+
+ );
+}
diff --git a/react/features/settings/components/web/audio/MicrophoneEntry.js b/react/features/settings/components/web/audio/MicrophoneEntry.js
new file mode 100644
index 0000000000..5b4f548248
--- /dev/null
+++ b/react/features/settings/components/web/audio/MicrophoneEntry.js
@@ -0,0 +1,172 @@
+// @flow
+
+import React, { Component } from 'react';
+
+import AudioSettingsEntry, { type Props as AudioSettingsEntryProps } from './AudioSettingsEntry';
+import JitsiMeetJS from '../../../../base/lib-jitsi-meet/_';
+import Meter from './Meter';
+
+const JitsiTrackEvents = JitsiMeetJS.events.track;
+
+type Props = AudioSettingsEntryProps & {
+
+ /**
+ * The deviceId of the microphone.
+ */
+ deviceId: string,
+
+ /**
+ * Flag indicating if there is a problem with the device.
+ */
+ hasError?: boolean,
+
+ /**
+ * The audio track for the current entry.
+ */
+ jitsiTrack: Object,
+
+ /**
+ * Click handler for component.
+ */
+ onClick: Function,
+}
+
+type State = {
+
+ /**
+ * The audio level.
+ */
+ level: number,
+}
+
+/**
+ * React {@code Component} representing an entry for the microphone audio settings.
+ *
+ * @param {Props} props - The props of the component.
+ * @returns { ReactElement}
+ */
+export default class MicrophoneEntry extends Component {
+ /**
+ * Initializes a new {@code MicrophoneEntry} instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ level: -1
+ };
+ this._onClick = this._onClick.bind(this);
+ this._updateLevel = this._updateLevel.bind(this);
+ }
+
+ _onClick: () => void;
+
+ /**
+ * Click handler for the entry.
+ *
+ * @returns {void}
+ */
+ _onClick() {
+ this.props.onClick(this.props.deviceId);
+ }
+
+ _updateLevel: (number) => void;
+
+ /**
+ * Updates the level of the meter.
+ *
+ * @param {number} num - The audio level provided by the jitsiTrack.
+ * @returns {void}
+ */
+ _updateLevel(num) {
+ this.setState({
+ level: Math.floor(num / 0.125)
+ });
+ }
+
+ /**
+ * Subscribes to audio level chanages comming from the jitsiTrack.
+ *
+ * @returns {void}
+ */
+ _startListening() {
+ const { jitsiTrack } = this.props;
+
+ jitsiTrack && jitsiTrack.on(
+ JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
+ this._updateLevel);
+ }
+
+ /**
+ * Unsubscribes from chanages comming from the jitsiTrack.
+ *
+ * @param {Object} jitsiTrack - The jitsiTrack to unsubscribe from.
+ * @returns {void}
+ */
+ _stopListening(jitsiTrack) {
+ jitsiTrack && jitsiTrack.off(
+ JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
+ this._updateLevel);
+ this.setState({
+ level: -1
+ });
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidUpdate}.
+ *
+ * @inheritdoc
+ */
+ componentDidUpdate(prevProps: Props) {
+ if (prevProps.jitsiTrack !== this.props.jitsiTrack) {
+ this._stopListening(prevProps.jitsiTrack);
+ this._startListening();
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidMount}.
+ *
+ * @inheritdoc
+ */
+ componentDidMount() {
+ this._startListening();
+ }
+
+ /**
+ * Implements React's {@link Component#componentWillUnmount}.
+ *
+ * @inheritdoc
+ */
+ compmonentWillUnmount() {
+ this._stopListening(this.props.jitsiTrack);
+ }
+
+ /**
+ * Implements React's {@link Component#render}.
+ *
+ * @inheritdoc
+ */
+ render() {
+ const { children, hasError, isSelected } = this.props;
+
+ return (
+
+ );
+ }
+}
diff --git a/react/features/settings/components/web/audio/SpeakerEntry.js b/react/features/settings/components/web/audio/SpeakerEntry.js
new file mode 100644
index 0000000000..e4fe381777
--- /dev/null
+++ b/react/features/settings/components/web/audio/SpeakerEntry.js
@@ -0,0 +1,119 @@
+// @flow
+
+import React, { Component } from 'react';
+
+import AudioSettingsEntry from './AudioSettingsEntry';
+import logger from '../../../logger';
+import TestButton from './TestButton';
+
+const TEST_SOUND_PATH = 'sounds/ring.wav';
+
+/**
+ * The type of the React {@code Component} props of {@link SpeakerEntry}.
+ */
+type Props = {
+
+ /**
+ * The text label for the entry.
+ */
+ children: React$Node,
+
+ /**
+ * Flag controlling the selection state of the entry.
+ */
+ isSelected: boolean,
+
+ /**
+ * The deviceId of the speaker.
+ */
+ deviceId: string,
+
+ /**
+ * Click handler for the component.
+ */
+ onClick: Function,
+};
+
+/**
+ * Implements a React {@link Component} which displays an audio
+ * output settings entry. The user can click and play a test sound.
+ *
+ * @extends Component
+ */
+export default class SpeakerEntry extends Component {
+ /**
+ * A React ref to the HTML element containing the {@code audio} instance.
+ */
+ audioRef: Object;
+
+ /**
+ * Initializes a new {@code SpeakerEntry} instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props: Props) {
+ super(props);
+
+ this.audioRef = React.createRef();
+ this._onTestButtonClick = this._onTestButtonClick.bind(this);
+ this._onClick = this._onClick.bind(this);
+ }
+
+ _onClick: () => void;
+
+ /**
+ * Click handler for the entry.
+ *
+ * @returns {void}
+ */
+ _onClick() {
+ this.props.onClick(this.props.deviceId);
+ }
+
+ _onTestButtonClick: Object => void;
+
+ /**
+ * Click handler for Test button.
+ * Sets the current audio output id and plays a sound.
+ *
+ * @param {Object} e - The sythetic event.
+ * @returns {void}
+ */
+ async _onTestButtonClick(e) {
+ e.stopPropagation();
+
+ try {
+ await this.audioRef.current.setSinkId(this.props.deviceId);
+ this.audioRef.current.play();
+ } catch (err) {
+ logger.log('Could not set sink id', err);
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#render}.
+ *
+ * @inheritdoc
+ */
+ render() {
+ const { children, isSelected, deviceId } = this.props;
+
+ return (
+
+ );
+ }
+}
diff --git a/react/features/settings/components/web/audio/TestButton.js b/react/features/settings/components/web/audio/TestButton.js
new file mode 100644
index 0000000000..57eb500261
--- /dev/null
+++ b/react/features/settings/components/web/audio/TestButton.js
@@ -0,0 +1,26 @@
+// @flow
+
+import React from 'react';
+
+type Props = {
+
+ /**
+ * Click handler for the button.
+ */
+ onClick: Function,
+};
+
+/**
+ * React {@code Component} representing an button used for testing output sound.
+ *
+ * @returns { ReactElement}
+ */
+export default function TestButton({ onClick }: Props) {
+ return (
+
+ Test
+
+ );
+}
diff --git a/react/features/settings/components/web/index.js b/react/features/settings/components/web/index.js
index c7c6c6e016..ca5c753a10 100644
--- a/react/features/settings/components/web/index.js
+++ b/react/features/settings/components/web/index.js
@@ -1,2 +1,4 @@
export { default as SettingsButton } from './SettingsButton';
export { default as SettingsDialog } from './SettingsDialog';
+export { default as AudioSettingsPopup } from './audio/AudioSettingsPopup';
+export { default as VideoSettingsPopup } from './video/VideoSettingsPopup';
diff --git a/react/features/settings/components/web/video/VideoSettingsContent.js b/react/features/settings/components/web/video/VideoSettingsContent.js
new file mode 100644
index 0000000000..c214e3a429
--- /dev/null
+++ b/react/features/settings/components/web/video/VideoSettingsContent.js
@@ -0,0 +1,220 @@
+// @flow
+
+import React, { Component } from 'react';
+
+import { translate } from '../../../../base/i18n';
+import { equals } from '../../../../base/redux';
+import Video from '../../../../base/media/components/Video';
+import { createLocalVideoTracks } from '../../../functions';
+
+
+const videoClassName = 'video-preview-video flipVideoX';
+
+/**
+ * The type of the React {@code Component} props of {@link VideoSettingsContent}.
+ */
+export type Props = {
+
+ /**
+ * The deviceId of the camera device currently being used.
+ */
+ currentCameraDeviceId: string,
+
+ /**
+ * Callback invoked to change current camera.
+ */
+ setVideoInputDevice: Function,
+
+ /**
+ * Invoked to obtain translated strings.
+ */
+ t: Function,
+
+ /**
+ * Callback invoked to toggle the settings popup visibility.
+ */
+ toggleVideoSettings: Function,
+
+ /**
+ * All the camera device ids currently connected.
+ */
+ videoDeviceIds: string[],
+};
+
+/**
+ * The type of the React {@code Component} state of {@link VideoSettingsContent}.
+ */
+type State = {
+
+ /**
+ * An array of all the jitsiTracks and eventual errors.
+ */
+ trackData: Object[],
+};
+
+/**
+ * Implements a React {@link Component} which displays a list of video
+ * previews to choose from.
+ *
+ * @extends Component
+ */
+class VideoSettingsContent extends Component {
+ _componentWasUnmounted: boolean;
+
+ /**
+ * Initializes a new {@code VideoSettingsContent} instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ trackData: new Array(props.videoDeviceIds.length).fill({
+ jitsiTrack: null
+ })
+ };
+ }
+
+ /**
+ * Creates and updates the track data.
+ *
+ * @returns {void}
+ */
+ async _setTracks() {
+ this._disposeTracks(this.state.trackData);
+
+ const trackData = await createLocalVideoTracks(
+ this.props.videoDeviceIds,
+ );
+
+ // In case the component gets unmounted before the tracks are created
+ // avoid a leak by not setting the state
+ if (this._componentWasUnmounted) {
+ this._disposeTracks(trackData);
+ } else {
+ this.setState({
+ trackData
+ });
+ }
+ }
+
+ /**
+ * Destroys all the tracks from trackData object.
+ *
+ * @param {Object[]} trackData - An array of tracks that are to be disposed.
+ * @returns {Promise}
+ */
+ _disposeTracks(trackData) {
+ trackData.forEach(({ jitsiTrack }) => {
+ jitsiTrack && jitsiTrack.dispose();
+ });
+ }
+
+ /**
+ * Returns the click handler used when selecting the video preview.
+ *
+ * @param {string} deviceId - The id of the camera device.
+ * @returns {Function}
+ */
+ _onEntryClick(deviceId) {
+ return () => {
+ this.props.setVideoInputDevice(deviceId);
+ this.props.toggleVideoSettings();
+ };
+ }
+
+ /**
+ * Renders a preview entry.
+ *
+ * @param {Object} data - The track data.
+ * @param {number} index - The index of the entry.
+ * @returns {React$Node}
+ */
+ _renderPreviewEntry(data, index) {
+ const { error, jitsiTrack, deviceId } = data;
+ const { currentCameraDeviceId, t } = this.props;
+ const isSelected = deviceId === currentCameraDeviceId;
+ const key = `vp-${index}`;
+ const className = 'video-preview-entry';
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ const props: Object = {
+ className,
+ key
+ };
+
+ if (isSelected) {
+ props.className = `${className} video-preview-entry--selected`;
+ } else {
+ props.onClick = this._onEntryClick(deviceId);
+ }
+
+ return (
+
+ );
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidMount}.
+ *
+ * @inheritdoc
+ */
+ componentDidMount() {
+ this._setTracks();
+ }
+
+ /**
+ * Implements React's {@link Component#componentWillUnmount}.
+ *
+ * @inheritdoc
+ */
+ componentWillUnmount() {
+ this._componentWasUnmounted = true;
+ this._disposeTracks(this.state.trackData);
+ }
+
+ /**
+ * Implements React's {@link Component#componentDidUpdate}.
+ *
+ * @inheritdoc
+ */
+ componentDidUpdate(prevProps) {
+ if (!equals(this.props.videoDeviceIds, prevProps.videoDeviceIds)) {
+ this._setTracks();
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#render}.
+ *
+ * @inheritdoc
+ */
+ render() {
+ const { trackData } = this.state;
+
+ return (
+
+ {trackData.map((data, i) => this._renderPreviewEntry(data, i))}
+
+ );
+ }
+}
+
+
+export default translate(VideoSettingsContent);
diff --git a/react/features/settings/components/web/video/VideoSettingsPopup.js b/react/features/settings/components/web/video/VideoSettingsPopup.js
new file mode 100644
index 0000000000..a2bac89686
--- /dev/null
+++ b/react/features/settings/components/web/video/VideoSettingsPopup.js
@@ -0,0 +1,85 @@
+// @flow
+
+import React from 'react';
+import InlineDialog from '@atlaskit/inline-dialog';
+
+import { toggleVideoSettings } from '../../../actions';
+import {
+ getVideoDeviceIds,
+ setVideoInputDevice as setVideoInputDeviceAction
+} from '../../../../base/devices';
+import { getVideoSettingsVisibility } from '../../../functions';
+import { connect } from '../../../../base/redux';
+import { getCurrentCameraDeviceId } from '../../../../base/settings';
+import VideoSettingsContent, { type Props as VideoSettingsProps } from './VideoSettingsContent';
+
+
+type Props = VideoSettingsProps & {
+
+ /**
+ * Component children (the Video button).
+ */
+ children: React$Node,
+
+ /**
+ * Flag controlling the visibility of the popup.
+ */
+ isOpen: boolean,
+
+ /**
+ * Callback executed when the popup closes.
+ */
+ onClose: Function,
+}
+
+/**
+ * Popup with a preview of all the video devices.
+ *
+ * @returns {ReactElement}
+ */
+function VideoSettingsPopup({
+ currentCameraDeviceId,
+ children,
+ isOpen,
+ onClose,
+ setVideoInputDevice,
+ videoDeviceIds
+}: Props) {
+ return (
+
+ }
+ isOpen = { isOpen }
+ onClose = { onClose }
+ position = 'top right'>
+ { children }
+
+
+ );
+}
+
+/**
+ * Maps (parts of) the redux state to the associated {@code VideoSettingsPopup}'s
+ * props.
+ *
+ * @param {Object} state - Redux state.
+ * @returns {Object}
+ */
+function mapStateToProps(state) {
+ return {
+ currentCameraDeviceId: getCurrentCameraDeviceId(state),
+ isOpen: getVideoSettingsVisibility(state),
+ videoDeviceIds: getVideoDeviceIds(state)
+ };
+}
+
+const mapDispatchToProps = {
+ onClose: toggleVideoSettings,
+ setVideoInputDevice: setVideoInputDeviceAction
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(VideoSettingsPopup);
diff --git a/react/features/settings/functions.js b/react/features/settings/functions.js
index c906364e27..40fe09af54 100644
--- a/react/features/settings/functions.js
+++ b/react/features/settings/functions.js
@@ -2,6 +2,7 @@
import { toState } from '../base/redux';
import { parseStandardURIString } from '../base/util';
import { i18next, DEFAULT_LANGUAGE, LANGUAGES } from '../base/i18n';
+import { createLocalTrack } from '../base/lib-jitsi-meet/functions';
import {
getLocalParticipant,
isLocalParticipantModerator
@@ -130,3 +131,73 @@ export function getProfileTabProps(stateful: Object | Function) {
email: localParticipant.email
};
}
+
+/**
+ * Returns a promise which resolves with a list of objects containing
+ * all the video jitsiTracks and appropriate errors for the given device ids.
+ *
+ * @param {string[]} ids - The list of the camera ids for wich to create tracks.
+ *
+ * @returns {Promise