feat(prejoin_page) Add settings buttons

This reverts commit faf24ca7ec.
This commit is contained in:
Vlad Piersec 2020-03-30 17:17:18 +03:00 committed by Saúl Ibarra Corretgé
parent c170970992
commit 1b05d7269c
40 changed files with 2070 additions and 11 deletions

View File

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

View File

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

124
css/_audio-preview.css Normal file
View File

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

33
css/_meter.css Normal file
View File

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

76
css/_settings-button.scss Normal file
View File

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

43
css/_video-preview.css Normal file
View File

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

View File

@ -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 */

View File

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

View File

@ -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.
*

View File

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

View File

@ -0,0 +1,3 @@
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07001 0.248238C8.3471 -0.0596449 8.82132 -0.0846038 9.1292 0.192491C9.43709 0.469585 9.46205 0.943802 9.18495 1.25168L5.65622 5.19348C5.35829 5.52451 4.83922 5.52451 4.54128 5.19348L1.06752 1.25168C0.79043 0.943802 0.81539 0.469585 1.12327 0.192491C1.43115 -0.0846038 1.90537 -0.0596449 2.18247 0.248238L5.09875 3.57062L8.07001 0.248238Z" fill="#5E6D7A"/>
</svg>

After

Width:  |  Height:  |  Size: 509 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 14.6667C4.3181 14.6667 1.33333 11.682 1.33333 8.00007C1.33333 4.31817 4.3181 1.3334 8 1.3334C11.6819 1.3334 14.6667 4.31817 14.6667 8.00007C14.6667 11.682 11.6819 14.6667 8 14.6667ZM7.33333 4.66676C7.33333 4.29857 7.6318 4.00009 8 4.00009C8.36819 4.00009 8.66666 4.29857 8.66666 4.66676V8.00009C8.66666 8.36828 8.36819 8.66676 8 8.66676C7.6318 8.66676 7.33333 8.36828 7.33333 8.00009V4.66676ZM8 10.0001C7.63181 10.0001 7.33333 10.2985 7.33333 10.6667C7.33333 11.0349 7.63181 11.3334 8 11.3334C8.36818 11.3334 8.66666 11.0349 8.66666 10.6667C8.66666 10.2985 8.36818 10.0001 8 10.0001Z" />
</svg>

After

Width:  |  Height:  |  Size: 731 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.33331 8.00004C1.33331 11.6819 4.31808 14.6667 7.99998 14.6667C11.6819 14.6667 14.6666 11.6819 14.6666 8.00004C14.6666 4.31814 11.6819 1.33337 7.99998 1.33337C4.31808 1.33337 1.33331 4.31814 1.33331 8.00004ZM13.3333 8.00005C13.3333 10.9456 10.9455 13.3334 7.99998 13.3334C5.05446 13.3334 2.66665 10.9456 2.66665 8.00005C2.66665 5.05453 5.05446 2.66672 7.99998 2.66672C10.9455 2.66672 13.3333 5.05453 13.3333 8.00005ZM7.33331 4.66673C7.33331 4.29854 7.63179 4.00006 7.99998 4.00006C8.36817 4.00006 8.66665 4.29854 8.66665 4.66673V8.00006C8.66665 8.36825 8.36817 8.66673 7.99998 8.66673C7.63179 8.66673 7.33331 8.36825 7.33331 8.00006V4.66673ZM7.99998 10C7.63179 10 7.33331 10.2985 7.33331 10.6667C7.33331 11.0349 7.63179 11.3334 7.99998 11.3334C8.36817 11.3334 8.66665 11.0349 8.66665 10.6667C8.66665 10.2985 8.36817 10 7.99998 10Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1001 B

View File

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

View File

@ -0,0 +1,10 @@
<svg width="38" height="12" viewBox="0 0 38 12" fill="#5E6D7A" xmlns="http://www.w3.org/2000/svg">
<rect width="3" height="12" rx="1"/>
<rect x="5" width="3" height="12" rx="1" />
<rect x="10" width="3" height="12" rx="1" />
<rect x="15" width="3" height="12" rx="1" />
<rect x="20" width="3" height="12" rx="1" />
<rect x="25" width="3" height="12" rx="1" />
<rect x="30" width="3" height="12" rx="1" />
<rect x="35" width="3" height="12" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 6C16 3.79086 14.2091 2 12 2C9.79086 2 8 3.79086 8 6V12C8 13.8666 9.27853 15.4346 11.0076 15.8759C11.0026 15.9166 11 15.958 11 16V17.917C8.16229 17.441 6 14.973 6 12C6 11.4477 5.55228 11 5 11C4.44772 11 4 11.4477 4 12C4 16.0796 7.05369 19.446 11 19.9381V21C11 21.5523 11.4477 22 12 22C12.5523 22 13 21.5523 13 21V19.9381C16.9463 19.446 20 16.0796 20 12C20 11.4477 19.5523 11 19 11C18.4477 11 18 11.4477 18 12C18 14.973 15.8377 17.441 13 17.917V16C13 15.958 12.9974 15.9166 12.9924 15.8759C14.7215 15.4346 16 13.8666 16 12V6ZM12 4C10.8954 4 10 4.89543 10 6V12C10 13.1046 10.8954 14 12 14C13.1046 14 14 13.1046 14 12V6C14 4.89543 13.1046 4 12 4Z" fill="#A4B8D1"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1799 3.68341L6 8H3C2.44772 8 2 8.44771 2 9V15C2 15.5523 2.44772 16 3 16H6L11.1799 20.3166C11.2698 20.3915 11.383 20.4325 11.5 20.4325C11.7761 20.4325 12 20.2086 12 19.9325V4.06752C12 3.95055 11.959 3.83728 11.8841 3.74743C11.7073 3.53529 11.392 3.50662 11.1799 3.68341ZM4 10H6.7241L10 7.27008V16.7299L6.7241 14H4V10ZM14 8C16.2091 8 18 9.79086 18 12C18 14.2091 16.2091 16 14 16V14C15.1046 14 16 13.1046 16 12C16 10.8954 15.1046 10 14 10V8ZM14 4C18.4183 4 22 7.58172 22 12C22 16.4183 18.4183 20 14 20V18C17.3137 18 20 15.3137 20 12C20 8.68629 17.3137 6 14 6V4Z" fill="#A4B8D1"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

View File

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

View File

@ -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 (
<div
className = 'settings-button-container'
styles = { styles }>
{ children }
<Icon
{ ...iconProps }
size = { 9 }
src = { icon } />
</div>
);
}

View File

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

View File

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

View File

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

View File

@ -127,9 +127,11 @@ class SettingsDialog extends Component<Props> {
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

View File

@ -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<Props, State> {
_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 (
<MicrophoneEntry
deviceId = { deviceId }
hasError = { hasError }
isSelected = { isSelected }
jitsiTrack = { jitsiTrack }
key = { key }
onClick = { this._onMicrophoneEntryClick }>
{label}
</MicrophoneEntry>
);
}
/**
* 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 (
<SpeakerEntry
deviceId = { deviceId }
isSelected = { deviceId === this.props.currentOutputDeviceId }
key = { key }
onClick = { this._onSpeakerEntryClick }>
{label}
</SpeakerEntry>
);
}
/**
* Disposes the audio track for a given micData object.
*
* @param {Object} micData - The object holding the track.
* @returns {Promise<void>}
*/
_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 (
<div>
<div className = 'audio-preview-content'>
<AudioSettingsHeader
IconComponent = { IconMicrophoneEmpty }
text = { t('settings.selectMic') } />
{microphoneDevices.map((data, i) =>
this._renderMicrophoneEntry(data, i),
)}
<AudioSettingsHeader
IconComponent = { IconVolumeEmpty }
text = { t('settings.speakers') } />
{outputDevices.map((data, i) =>
this._renderSpeakerEntry(data, i),
)}
</div>
</div>
);
}
}
export default translate(AudioSettingsContent);

View File

@ -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 (
<div className = { className }>
{isSelected && (
<Icon
className = 'audio-preview-icon audio-preview-icon--check'
color = '#1C2025'
size = { 14 }
src = { IconCheck } />
)}
<span className = 'audio-preview-entry-text'>{children}</span>
{hasError && <Icon
className = 'audio-preview-icon audio-preview-icon--exclamation'
size = { 16 }
src = { IconExclamationSolid } />}
</div>
);
}

View File

@ -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 (
<div className = 'audio-preview-header'>
<div className = 'audio-preview-header-icon'>
{ <Icon
color = '#A4B8D1'
size = { 24 }
src = { IconComponent } />}
</div>
<div className = 'audio-preview-header-text'>{text}</div>
</div>
);
}

View File

@ -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 (
<div className = 'audio-preview'>
<InlineDialog
content = { <AudioSettingsContent
currentMicDeviceId = { currentMicDeviceId }
currentOutputDeviceId = { currentOutputDeviceId }
microphoneDevices = { microphoneDevices }
outputDevices = { outputDevices }
setAudioInputDevice = { setAudioInputDevice }
setAudioOutputDevice = { setAudioOutputDevice } /> }
isOpen = { isOpen }
onClose = { onClose }
position = 'top left'>
{children}
</InlineDialog>
</div>
);
}
/**
* 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);

View File

@ -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 (
<Icon
className = { `${ownClassName} ${className}` }
size = { 12 }
src = { IconMeter } />
);
}

View File

@ -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<Props, State> {
/**
* 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 (
<div
className = 'audio-preview-microphone'
onClick = { this._onClick }>
<AudioSettingsEntry
hasError = { hasError }
isSelected = { isSelected }>
{children}
</AudioSettingsEntry>
<Meter
className = 'audio-preview-meter-mic'
isDisabled = { hasError }
level = { this.state.level } />
</div>
);
}
}

View File

@ -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<Props> {
/**
* 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 (
<div
className = 'audio-preview-speaker'
onClick = { this._onClick }>
<AudioSettingsEntry
isSelected = { isSelected }
key = { deviceId }>
{children}
</AudioSettingsEntry>
<TestButton onClick = { this._onTestButtonClick } />
<audio
preload = 'auto'
ref = { this.audioRef }
src = { TEST_SOUND_PATH } />
</div>
);
}
}

View File

@ -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 (
<div
className = 'audio-preview-test-button'
onClick = { onClick }>
Test
</div>
);
}

View File

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

View File

@ -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<Props, State> {
_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<void>}
*/
_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 (
<div
className = { className }
key = { key }>
<div className = 'video-preview-error'>{t(error)}</div>
</div>
);
}
const props: Object = {
className,
key
};
if (isSelected) {
props.className = `${className} video-preview-entry--selected`;
} else {
props.onClick = this._onEntryClick(deviceId);
}
return (
<div { ...props }>
<div className = 'video-preview-overlay' />
<Video
className = { videoClassName }
videoTrack = {{ jitsiTrack }} />
</div>
);
}
/**
* 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 (
<div>
{trackData.map((data, i) => this._renderPreviewEntry(data, i))}
</div>
);
}
}
export default translate(VideoSettingsContent);

View File

@ -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 (
<div className = 'video-preview'>
<InlineDialog
content = { <VideoSettingsContent
currentCameraDeviceId = { currentCameraDeviceId }
setVideoInputDevice = { setVideoInputDevice }
toggleVideoSettings = { onClose }
videoDeviceIds = { videoDeviceIds } /> }
isOpen = { isOpen }
onClose = { onClose }
position = 'top right'>
{ children }
</InlineDialog>
</div>
);
}
/**
* 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);

View File

@ -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<Object[]>}
*/
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<Object>}
*/
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;
}

View File

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

View File

@ -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<Props, State> {
/**
* 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<void>}
*/
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 ? (
<AudioSettingsPopup>
<ToolboxButtonWithIcon
icon = { IconArrowDown }
iconDisabled = { settingsDisabled }
onIconClick = { onAudioOptionsClick }>
<AudioMuteButton />
</ToolboxButtonWithIcon>
</AudioSettingsPopup>
) : 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);

View File

@ -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<Props, State> {
});
}
/**
* Renders the Audio controlling button.
*
* @returns {ReactElement}
*/
_renderAudioButton() {
return this._shouldShowButton('microphone')
? this.props._prejoinPageEnabled
? <AudioSettingsButton
key = 'asb'
visible = { true } />
: <AudioMuteButton
key = 'amb'
visible = { true } />
: null;
}
/**
* Renders the Video controlling button.
*
* @returns {ReactElement}
*/
_renderVideoButton() {
return this._shouldShowButton('camera')
? this.props._prejoinPageEnabled
? <VideoSettingsButton
key = 'vsb'
visible = { true } />
: <VideoMuteButton
key = 'vmb'
visible = { true } />
: null;
}
/**
* Renders the toolbox content.
*
@ -1234,12 +1275,10 @@ class Toolbox extends Component<Props, State> {
}
</div>
<div className = 'button-group-center'>
<AudioMuteButton
visible = { this._shouldShowButton('microphone') } />
{ this._renderAudioButton() }
<HangupButton
visible = { this._shouldShowButton('hangup') } />
<VideoMuteButton
visible = { this._shouldShowButton('camera') } />
{ this._renderVideoButton() }
</div>
<div className = 'button-group-right'>
{ 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'

View File

@ -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<Props, State> {
/**
* 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<void>}
*/
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 ? (
<VideoSettingsPopup>
<ToolboxButtonWithIcon
icon = { IconArrowDown }
iconDisabled = { iconDisabled }
onIconClick = { onVideoOptionsClick }>
<VideoMuteButton />
</ToolboxButtonWithIcon>
</VideoSettingsPopup>
) : 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);

View File

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