From 08f55ccb948e49ef6b0162235998e0b423e35fea Mon Sep 17 00:00:00 2001 From: Vlad Piersec Date: Fri, 20 Mar 2020 12:26:42 +0200 Subject: [PATCH] feat(prejoin_page) Add settings buttons --- config.js | 5 + css/_atlaskit_overrides.scss | 16 ++ css/_audio-preview.css | 124 +++++++++ css/_meter.css | 33 +++ css/_settings-button.scss | 76 +++++ css/_video-preview.css | 43 +++ css/main.scss | 4 + lang/main.json | 3 +- react/features/base/devices/actions.js | 12 + react/features/base/devices/functions.js | 54 ++++ react/features/base/icons/svg/arrow_down.svg | 3 + .../base/icons/svg/exclamation-solid.svg | 3 + react/features/base/icons/svg/exclamation.svg | 3 + react/features/base/icons/svg/index.js | 6 + react/features/base/icons/svg/meter.svg | 10 + .../base/icons/svg/microphone-empty.svg | 3 + .../features/base/icons/svg/volume-empty.svg | 3 + react/features/base/settings/functions.web.js | 30 ++ .../components/ToolboxButtonWithIcon.js | 66 +++++ .../features/base/toolbox/components/index.js | 1 + react/features/settings/actionTypes.js | 6 + react/features/settings/actions.js | 57 +++- .../settings/components/web/SettingsDialog.js | 4 +- .../web/audio/AudioSettingsContent.js | 262 ++++++++++++++++++ .../web/audio/AudioSettingsEntry.js | 53 ++++ .../web/audio/AudioSettingsHeader.js | 39 +++ .../web/audio/AudioSettingsPopup.js | 97 +++++++ .../settings/components/web/audio/Meter.js | 45 +++ .../components/web/audio/MicrophoneEntry.js | 172 ++++++++++++ .../components/web/audio/SpeakerEntry.js | 119 ++++++++ .../components/web/audio/TestButton.js | 26 ++ .../features/settings/components/web/index.js | 2 + .../web/video/VideoSettingsContent.js | 220 +++++++++++++++ .../web/video/VideoSettingsPopup.js | 85 ++++++ react/features/settings/functions.js | 71 +++++ react/features/settings/reducer.js | 16 +- .../components/web/AudioSettingsButton.js | 127 +++++++++ .../toolbox/components/web/Toolbox.js | 54 +++- .../components/web/VideoSettingsButton.js | 124 +++++++++ react/features/toolbox/functions.web.js | 4 +- 40 files changed, 2070 insertions(+), 11 deletions(-) create mode 100644 css/_audio-preview.css create mode 100644 css/_meter.css create mode 100644 css/_settings-button.scss create mode 100644 css/_video-preview.css create mode 100644 react/features/base/icons/svg/arrow_down.svg create mode 100644 react/features/base/icons/svg/exclamation-solid.svg create mode 100644 react/features/base/icons/svg/exclamation.svg create mode 100644 react/features/base/icons/svg/meter.svg create mode 100644 react/features/base/icons/svg/microphone-empty.svg create mode 100644 react/features/base/icons/svg/volume-empty.svg create mode 100644 react/features/base/toolbox/components/ToolboxButtonWithIcon.js create mode 100644 react/features/settings/components/web/audio/AudioSettingsContent.js create mode 100644 react/features/settings/components/web/audio/AudioSettingsEntry.js create mode 100644 react/features/settings/components/web/audio/AudioSettingsHeader.js create mode 100644 react/features/settings/components/web/audio/AudioSettingsPopup.js create mode 100644 react/features/settings/components/web/audio/Meter.js create mode 100644 react/features/settings/components/web/audio/MicrophoneEntry.js create mode 100644 react/features/settings/components/web/audio/SpeakerEntry.js create mode 100644 react/features/settings/components/web/audio/TestButton.js create mode 100644 react/features/settings/components/web/video/VideoSettingsContent.js create mode 100644 react/features/settings/components/web/video/VideoSettingsPopup.js create mode 100644 react/features/toolbox/components/web/AudioSettingsButton.js create mode 100644 react/features/toolbox/components/web/VideoSettingsButton.js diff --git a/config.js b/config.js index a5ef067e1125..ea47f3b2f5c1 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 0b110c0a35a7..66e9f4a1f29f 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 000000000000..19f71c81da05 --- /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 000000000000..7d5c4c80aa84 --- /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 000000000000..94009ba48f5b --- /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 000000000000..e2c6ec58a6a0 --- /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 c3941e5bc9dc..e6269786f349 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 477d9e164737..32ed023494b3 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" @@ -647,7 +648,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 b05ea8dd9ec6..e5e3dd175cf2 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 05d4eabef074..7ca0fe0f3d07 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 000000000000..7cd9b05e9e69 --- /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 000000000000..fe052bc44f55 --- /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 000000000000..e276eac24080 --- /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 c8d04e340fca..3b798e3d8a59 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'; @@ -75,3 +80,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 000000000000..59c349cc8703 --- /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 000000000000..8bacff789c02 --- /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 000000000000..858aa7869e5a --- /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 62cfab05e47e..59f8cebe74c4 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 000000000000..ad2b46486d69 --- /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 3f5d2c1d0016..f71e12ebb002 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 9d6c62d71022..58ed9c3c8401 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 04194cd06157..bbb5fa884653 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 ea2d7de11be4..c54d89244a22 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 000000000000..313c799180fd --- /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 000000000000..5331a0e91819 --- /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 000000000000..105853ae95a9 --- /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 ( +
+
+ { } +
+
{text}
+
+ ); +} 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 000000000000..4a7803602f4f --- /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 000000000000..e9ba0e5c823f --- /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 000000000000..5b4f54824800 --- /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 ( +
+ + {children} + + +
+ ); + } +} 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 000000000000..e4fe381777e5 --- /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 ( +
+ + {children} + + +
+ ); + } +} 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 000000000000..57eb50026190 --- /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 c7c6c6e0169a..ca5c753a1032 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 000000000000..c214e3a429ef --- /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 ( +
+
{t(error)}
+
+ ); + } + + 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 000000000000..a2bac89686e4 --- /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 c906364e2700..40fe09af541a 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} + */ +export function createLocalVideoTracks(ids: string[]) { + return Promise.all(ids.map(deviceId => createLocalTrack('video', deviceId) + .then(jitsiTrack => { + return { + jitsiTrack, + deviceId + }; + }) + .catch(() => { + return { + jitsiTrack: null, + deviceId, + error: 'deviceSelection.previewUnavailable' + }; + }))); +} + + +/** + * Returns a promise which resolves with an object containing the corresponding + * the audio jitsiTrack/error. + * + * @param {string} deviceId - The deviceId for the current microphone. + * + * @returns {Promise} + */ +export function createLocalAudioTrack(deviceId: string) { + return createLocalTrack('audio', deviceId) + .then(jitsiTrack => { + return { + hasError: false, + jitsiTrack + }; + }) + .catch(() => { + return { + hasError: true, + jitsiTrack: null + }; + }); +} + +/** + * Returns the visibility state of the audio settings. + * + * @param {Object} state - The state of the application. + * @returns {boolean} + */ +export function getAudioSettingsVisibility(state: Object) { + return state['features/settings'].audioSettingsVisible; +} + +/** + * Returns the visibility state of the video settings. + * + * @param {Object} state - The state of the application. + * @returns {boolean} + */ +export function getVideoSettingsVisibility(state: Object) { + return state['features/settings'].videoSettingsVisible; +} diff --git a/react/features/settings/reducer.js b/react/features/settings/reducer.js index cc790b3e2f13..b769ec8ccac7 100644 --- a/react/features/settings/reducer.js +++ b/react/features/settings/reducer.js @@ -2,7 +2,11 @@ import { ReducerRegistry } from '../base/redux'; -import { SET_SETTINGS_VIEW_VISIBLE } from './actionTypes'; +import { + SET_AUDIO_SETTINGS_VISIBILITY, + SET_SETTINGS_VIEW_VISIBLE, + SET_VIDEO_SETTINGS_VISIBILITY +} from './actionTypes'; ReducerRegistry.register('features/settings', (state = {}, action) => { switch (action.type) { @@ -11,6 +15,16 @@ ReducerRegistry.register('features/settings', (state = {}, action) => { ...state, visible: action.visible }; + case SET_AUDIO_SETTINGS_VISIBILITY: + return { + ...state, + audioSettingsVisible: action.value + }; + case SET_VIDEO_SETTINGS_VISIBILITY: + return { + ...state, + videoSettingsVisible: action.value + }; } return state; diff --git a/react/features/toolbox/components/web/AudioSettingsButton.js b/react/features/toolbox/components/web/AudioSettingsButton.js new file mode 100644 index 000000000000..4c29f804e4f1 --- /dev/null +++ b/react/features/toolbox/components/web/AudioSettingsButton.js @@ -0,0 +1,127 @@ +// @flow + +import React, { Component } from 'react'; + +import AudioMuteButton from '../AudioMuteButton'; +import { hasAvailableDevices } from '../../../base/devices'; +import { IconArrowDown } from '../../../base/icons'; +import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; +import { ToolboxButtonWithIcon } from '../../../base/toolbox'; +import { connect } from '../../../base/redux'; + +import { AudioSettingsPopup, toggleAudioSettings } from '../../../settings'; + +type Props = { + + /** + * Click handler for the small icon. Opens audio options. + */ + onAudioOptionsClick: Function, + + /** + * If the user has audio input or audio output devices. + */ + hasDevices: boolean, + + /** + * Flag controlling the visibility of the button. + */ + visible: boolean, +}; + +type State = { + + /** + * If there are permissions for audio devices. + */ + hasPermissions: boolean, +} + +/** + * Button used for audio & audio settings. + * + * @returns {ReactElement} + */ +class AudioSettingsButton extends Component { + /** + * Initializes a new {@code AudioSettingsButton} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + hasPermissions: false + }; + } + + /** + * Updates device permissions. + * + * @returns {Promise} + */ + async _updatePermissions() { + const hasPermissions = await JitsiMeetJS.mediaDevices.isDevicePermissionGranted( + 'audio', + ); + + this.setState({ + hasPermissions + }); + } + + /** + * Implements React's {@link Component#componentDidMount}. + * + * @inheritdoc + */ + componentDidMount() { + this._updatePermissions(); + } + + /** + * Implements React's {@link Component#render}. + * + * @inheritdoc + */ + render() { + const { hasDevices, onAudioOptionsClick, visible } = this.props; + const settingsDisabled = !this.state.hasPermissions || !hasDevices; + + return visible ? ( + + + + + + ) : null; + } +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @returns {Object} + */ +function mapStateToProps(state) { + return { + hasDevices: + hasAvailableDevices(state, 'audioInput') + || hasAvailableDevices(state, 'audioOutput') + }; +} + +const mapDispatchToProps = { + onAudioOptionsClick: toggleAudioSettings +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(AudioSettingsButton); diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index 4a61854c632e..4b175d223db8 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -72,6 +72,7 @@ import { setToolbarHovered } from '../../actions'; import AudioMuteButton from '../AudioMuteButton'; +import AudioSettingsButton from './AudioSettingsButton'; import DownloadButton from '../DownloadButton'; import { isToolboxVisible } from '../../functions'; import HangupButton from '../HangupButton'; @@ -81,6 +82,7 @@ import OverflowMenuProfileItem from './OverflowMenuProfileItem'; import MuteEveryoneButton from './MuteEveryoneButton'; import ToolbarButton from './ToolbarButton'; import VideoMuteButton from '../VideoMuteButton'; +import VideoSettingsButton from './VideoSettingsButton'; import { ClosedCaptionButton } from '../../../subtitles'; @@ -126,6 +128,11 @@ type Props = { */ _fullScreen: boolean, + /** + * Whether or not the prejoin page is enabled. + */ + _prejoinPageEnabled: boolean, + /** * Whether or not the tile view is enabled. */ @@ -1116,6 +1123,40 @@ class Toolbox extends Component { }); } + /** + * Renders the Audio controlling button. + * + * @returns {ReactElement} + */ + _renderAudioButton() { + return this._shouldShowButton('microphone') + ? this.props._prejoinPageEnabled + ? + : + : null; + } + + /** + * Renders the Video controlling button. + * + * @returns {ReactElement} + */ + _renderVideoButton() { + return this._shouldShowButton('camera') + ? this.props._prejoinPageEnabled + ? + : + : null; + } + /** * Renders the toolbox content. * @@ -1234,12 +1275,10 @@ class Toolbox extends Component { }
- + { this._renderAudioButton() } - + { this._renderVideoButton() }
{ buttonsRight.indexOf('localrecording') !== -1 @@ -1303,7 +1342,9 @@ function _mapStateToProps(state) { let { desktopSharingEnabled } = state['features/base/conference']; const { callStatsID, - iAmRecorder + enableFeaturesBasedOnToken, + iAmRecorder, + prejoinPageEnabled } = state['features/base/config']; const sharedVideoStatus = state['features/shared-video'].status; const { @@ -1318,7 +1359,7 @@ function _mapStateToProps(state) { let desktopSharingDisabledTooltipKey; - if (state['features/base/config'].enableFeaturesBasedOnToken) { + if (enableFeaturesBasedOnToken) { // we enable desktop sharing if any participant already have this // feature enabled desktopSharingEnabled = getParticipants(state) @@ -1354,6 +1395,7 @@ function _mapStateToProps(state) { _localParticipantID: localParticipant.id, _localRecState: localRecordingStates, _overflowMenuVisible: overflowMenuVisible, + _prejoinPageEnabled: prejoinPageEnabled, _raisedHand: localParticipant.raisedHand, _screensharing: localVideo && localVideo.videoType === 'desktop', _sharingVideo: sharedVideoStatus === 'playing' diff --git a/react/features/toolbox/components/web/VideoSettingsButton.js b/react/features/toolbox/components/web/VideoSettingsButton.js new file mode 100644 index 000000000000..b6b4941066fe --- /dev/null +++ b/react/features/toolbox/components/web/VideoSettingsButton.js @@ -0,0 +1,124 @@ +// @flow + +import React, { Component } from 'react'; + +import { toggleVideoSettings, VideoSettingsPopup } from '../../../settings'; +import VideoMuteButton from '../VideoMuteButton'; +import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; +import { hasAvailableDevices } from '../../../base/devices'; +import { IconArrowDown } from '../../../base/icons'; +import { connect } from '../../../base/redux'; +import { ToolboxButtonWithIcon } from '../../../base/toolbox'; + +type Props = { + + /** + * Click handler for the small icon. Opens video options. + */ + onVideoOptionsClick: Function, + + /** + * If the user has any video devices. + */ + hasDevices: boolean, + + /** + * Flag controlling the visibility of the button. + */ + visible: boolean, +}; + +type State = { + + /** + * Whether the app has video permissions or not. + */ + hasPermissions: boolean, +}; + +/** + * Button used for video & video settings. + * + * @returns {ReactElement} + */ +class VideoSettingsButton extends Component { + /** + * Initializes a new {@code VideoSettingsButton} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + hasPermissions: false + }; + } + + /** + * Updates device permissions. + * + * @returns {Promise} + */ + async _updatePermissions() { + const hasPermissions = await JitsiMeetJS.mediaDevices.isDevicePermissionGranted( + 'video', + ); + + this.setState({ + hasPermissions + }); + } + + /** + * Implements React's {@link Component#componentDidMount}. + * + * @inheritdoc + */ + componentDidMount() { + this._updatePermissions(); + } + + /** + * Implements React's {@link Component#render}. + * + * @inheritdoc + */ + render() { + const { hasDevices, onVideoOptionsClick, visible } = this.props; + const iconDisabled = !this.state.hasPermissions || !hasDevices; + + return visible ? ( + + + + + + ) : null; + } +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @returns {Object} + */ +function mapStateToProps(state) { + return { + hasDevices: hasAvailableDevices(state, 'videoInput') + }; +} + +const mapDispatchToProps = { + onVideoOptionsClick: toggleVideoSettings +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(VideoSettingsButton); diff --git a/react/features/toolbox/functions.web.js b/react/features/toolbox/functions.web.js index cbbd855a0c4b..145abbed8102 100644 --- a/react/features/toolbox/functions.web.js +++ b/react/features/toolbox/functions.web.js @@ -40,6 +40,8 @@ export function isToolboxVisible(state: Object) { timeoutID, visible } = state['features/toolbox']; + const { audioSettingsVisible, videoSettingsVisible } = state['features/settings']; - return Boolean(!iAmSipGateway && (timeoutID || visible || alwaysVisible)); + return Boolean(!iAmSipGateway && (timeoutID || visible || alwaysVisible + || audioSettingsVisible || videoSettingsVisible)); }