bigbluebutton-Github/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/component.jsx
João Victor Nunes fe67566334
[3.0] feat: Accept custom webcamBackgroundURL (#20920)
* [3.0] feat: Accept custom webcamBackgroundURL

* docs: webcamBackgroundURL

* test: Update virtual background thumbnail image

* fix: tweak error logs for the webcamBackgroundURL fetching procedure

* test: Update virtual background thumbnail image

* fix(logging): do not specify null extraInfo object

Co-authored-by: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com>

* fix(logging): extract fields from error object for building extraInfo object

Co-authored-by: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com>

---------

Co-authored-by: Paulo Lanzarin <4529051+prlanzarin@users.noreply.github.com>
2024-08-23 09:04:56 -03:00

521 lines
16 KiB
JavaScript

import React, {
useState, useRef, useContext, useEffect,
} from 'react';
import { findDOMNode } from 'react-dom';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import Styled from './styles';
import {
EFFECT_TYPES,
BLUR_FILENAME,
getImageNames,
getVirtualBackgroundThumbnail,
isVirtualBackgroundSupported,
} from '/imports/ui/services/virtual-background/service';
import { ACTIONS, CustomVirtualBackgroundsContext } from './context';
import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
import logger from '/imports/startup/client/logger';
import withFileReader from '/imports/ui/components/common/file-reader/component';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
import { getSettingsSingletonInstance } from '/imports/ui/services/settings';
const { MIME_TYPES_ALLOWED, MAX_FILE_SIZE } = VirtualBgService;
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
handleVirtualBgSelected: PropTypes.func.isRequired,
locked: PropTypes.bool.isRequired,
showThumbnails: PropTypes.bool,
initialVirtualBgState: PropTypes.shape({
type: PropTypes.string.isRequired,
name: PropTypes.string,
}),
};
const SKELETON_COUNT = 5;
const shouldEnableBackgroundUpload = (isCustomVirtualBackgroundsEnabled) => {
const VIRTUAL_BACKGROUNDS_CONFIG = window.meetingClientSettings.public.virtualBackgrounds;
const ENABLE_UPLOAD = VIRTUAL_BACKGROUNDS_CONFIG.enableVirtualBackgroundUpload;
return ENABLE_UPLOAD && isCustomVirtualBackgroundsEnabled;
};
const defaultInitialVirtualBgState = {
type: EFFECT_TYPES.NONE_TYPE,
};
const VirtualBgSelector = ({
intl,
handleVirtualBgSelected,
locked,
showThumbnails = false,
initialVirtualBgState = defaultInitialVirtualBgState,
readFile,
isCustomVirtualBackgroundsEnabled,
}) => {
const IMAGE_NAMES = getImageNames();
const intlMessages = defineMessages({
virtualBackgroundSettingsLabel: {
id: 'app.videoPreview.webcamVirtualBackgroundLabel',
description: 'Label for the virtual background',
},
virtualBackgroundSettingsDisabledLabel: {
id: 'app.videoPreview.webcamVirtualBackgroundDisabledLabel',
description: 'Label for the unsupported virtual background',
},
noneLabel: {
id: 'app.video.virtualBackground.none',
description: 'Label for no virtual background selected',
},
customLabel: {
id: 'app.video.virtualBackground.custom',
description: 'Label for custom virtual background selected',
},
removeLabel: {
id: 'app.video.virtualBackground.remove',
description: 'Label for remove custom virtual background',
},
blurLabel: {
id: 'app.video.virtualBackground.blur',
description: 'Label for the blurred camera option',
},
camBgAriaDesc: {
id: 'app.video.virtualBackground.camBgAriaDesc',
description: 'Label for virtual background button aria',
},
customDesc: {
id: 'app.video.virtualBackground.button.customDesc',
description: 'Aria description for upload virtual background button',
},
background: {
id: 'app.video.virtualBackground.background',
description: 'Label for the background word',
},
backgroundWithIndex: {
id: 'app.video.virtualBackground.backgroundWithIndex',
description: 'Label for the background word indexed',
},
...IMAGE_NAMES.reduce((prev, imageName) => {
const id = imageName.split('.').shift();
return {
...prev,
[id]: {
id: `app.video.virtualBackground.${id}`,
description: `Label for the ${id} camera option`,
defaultMessage: '{background} {index}',
},
};
}, {}),
});
const [currentVirtualBg, setCurrentVirtualBg] = useState({
...initialVirtualBgState,
});
const inputElementsRef = useRef([]);
const customBgSelectorRef = useRef(null);
const {
dispatch,
loaded,
defaultSetUp,
backgrounds,
loadFromDB,
} = useContext(CustomVirtualBackgroundsContext);
useEffect(() => {
if (shouldEnableBackgroundUpload(isCustomVirtualBackgroundsEnabled)) {
if (!defaultSetUp) {
const defaultBackgrounds = ['Blur', ...IMAGE_NAMES].map((imageName) => ({
uniqueId: imageName,
custom: false,
lastActivityDate: Date.now(),
}));
dispatch({
type: ACTIONS.SET_DEFAULT,
backgrounds: defaultBackgrounds,
});
}
if (!loaded) loadFromDB();
}
}, [isCustomVirtualBackgroundsEnabled]);
const _virtualBgSelected = (
type,
name,
index,
customParams,
) => handleVirtualBgSelected(type, name, customParams)
.then((switched) => {
// Reset to the base NONE_TYPE effect if it failed because the expected
// behaviour from upstream's method is to actually stop/reset the effect
// service if it fails
if (!switched) {
return setCurrentVirtualBg({ type: EFFECT_TYPES.NONE_TYPE });
}
setCurrentVirtualBg({ type, name, uniqueId: customParams?.uniqueId });
if (!index || index < 0) return null;
if (!shouldEnableBackgroundUpload(isCustomVirtualBackgroundsEnabled)) {
// eslint-disable-next-line react/no-find-dom-node
findDOMNode(inputElementsRef.current[index]).focus();
} else {
if (customParams) {
dispatch({
type: ACTIONS.UPDATE,
background: {
uniqueId: customParams.uniqueId,
lastActivityDate: Date.now(),
},
});
} else {
dispatch({
type: ACTIONS.UPDATE,
background: {
uniqueId: name,
lastActivityDate: Date.now(),
},
});
}
// eslint-disable-next-line react/no-find-dom-node
findDOMNode(inputElementsRef.current[0]).focus();
}
return null;
});
const renderDropdownSelector = () => {
const disabled = locked || !isVirtualBackgroundSupported();
return (
<div>
<Styled.Select
value={JSON.stringify(currentVirtualBg)}
disabled={disabled}
onChange={(event) => {
const { type, name } = JSON.parse(event.target.value);
_virtualBgSelected(type, name);
}}
>
<option value={JSON.stringify({ type: EFFECT_TYPES.NONE_TYPE })}>
{intl.formatMessage(intlMessages.noneLabel)}
</option>
<option value={JSON.stringify({ type: EFFECT_TYPES.BLUR_TYPE })}>
{intl.formatMessage(intlMessages.blurLabel)}
</option>
{IMAGE_NAMES.map((imageName, i) => {
const k = `${imageName}-${i}`;
return (
<option
key={k}
value={JSON.stringify({
type: EFFECT_TYPES.IMAGE_TYPE,
name: imageName,
})}
>
{imageName.split('.')[0]}
</option>
);
})}
</Styled.Select>
</div>
);
};
const handleCustomBgChange = (event) => {
const file = event.target.files[0];
const onSuccess = (background) => {
dispatch({
type: ACTIONS.NEW,
background: {
...background,
custom: true,
lastActivityDate: Date.now(),
},
});
const { filename, data, uniqueId } = background;
_virtualBgSelected(
EFFECT_TYPES.IMAGE_TYPE,
filename,
0,
{ file: data, uniqueId },
);
};
const onError = (error) => {
logger.warn({
logCode: 'read_file_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, error.message);
};
readFile(file, onSuccess, onError);
};
const renderThumbnailSelector = () => {
const disabled = locked || !isVirtualBackgroundSupported();
const Settings = getSettingsSingletonInstance();
const { isRTL } = Settings.application;
const renderBlurButton = (index) => (
<Styled.ThumbnailButtonWrapper
key={`blur-${index}`}
>
<Styled.ThumbnailButton
background={getVirtualBackgroundThumbnail(BLUR_FILENAME)}
aria-label={intl.formatMessage(intlMessages.blurLabel)}
label={intl.formatMessage(intlMessages.blurLabel)}
aria-describedby="vr-cam-btn-blur"
tabIndex={disabled ? -1 : 0}
hideLabel
aria-pressed={currentVirtualBg?.name?.includes('blur') || currentVirtualBg?.name?.includes('Blur')}
disabled={disabled}
ref={(ref) => { inputElementsRef.current[index] = ref; }}
onClick={() => _virtualBgSelected(EFFECT_TYPES.BLUR_TYPE, 'Blur', index)}
/>
<div aria-hidden className="sr-only" id="vr-cam-btn-blur">
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.BLUR_TYPE })}
</div>
</Styled.ThumbnailButtonWrapper>
);
const renderDefaultButton = (imageName, index) => {
const label = intl.formatMessage(intlMessages[imageName.split('.').shift()], {
index: index + 1,
background: intl.formatMessage(intlMessages.background),
});
return (
<Styled.ThumbnailButtonWrapper
key={`${imageName}-${index + 1}`}
>
<Styled.ThumbnailButton
id={`${imageName}-${index + 1}`}
label={label}
tabIndex={disabled ? -1 : 0}
role="button"
aria-label={label}
aria-describedby={`vr-cam-btn-${index + 1}`}
aria-pressed={currentVirtualBg?.name?.includes(imageName.split('.').shift())}
hideLabel
ref={(ref) => { inputElementsRef.current[index] = ref; }}
onClick={() => _virtualBgSelected(EFFECT_TYPES.IMAGE_TYPE, imageName, index)}
disabled={disabled}
background={getVirtualBackgroundThumbnail(imageName)}
data-test="selectDefaultBackground"
/>
<div aria-hidden className="sr-only" id={`vr-cam-btn-${index + 1}`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: label })}
</div>
</Styled.ThumbnailButtonWrapper>
);
};
const renderCustomButton = (background, index) => {
const { filename, data, uniqueId } = background;
const label = intl.formatMessage(intlMessages.backgroundWithIndex, {
0: index + 1,
});
return (
<Styled.ThumbnailButtonWrapper
key={`${filename}-${index + 1}`}
>
<Styled.ThumbnailButton
id={`${filename}-${index + 1}`}
label={label}
tabIndex={disabled ? -1 : 0}
role="button"
aria-label={label}
aria-describedby={`vr-cam-btn-${index + 1}`}
aria-pressed={currentVirtualBg?.name?.includes(filename)}
hideLabel
ref={(ref) => { inputElementsRef.current[index] = ref; }}
onClick={() => _virtualBgSelected(
EFFECT_TYPES.IMAGE_TYPE,
filename,
index,
{ file: data, uniqueId },
)}
disabled={disabled}
background={data}
data-test="selectCustomBackground"
/>
<Styled.ButtonWrapper>
<Styled.ButtonRemove
disabled={disabled}
label={intl.formatMessage(intlMessages.removeLabel)}
aria-label={intl.formatMessage(intlMessages.removeLabel)}
aria-describedby={`vr-cam-btn-${index + 1}`}
data-test="removeCustomBackground"
icon="close"
size="sm"
color="dark"
circle
hideLabel
onClick={() => {
dispatch({
type: ACTIONS.DELETE,
uniqueId,
});
_virtualBgSelected(EFFECT_TYPES.NONE_TYPE);
}}
/>
</Styled.ButtonWrapper>
<div aria-hidden className="sr-only" id={`vr-cam-btn-${index + 1}`}>
{label}
</div>
</Styled.ThumbnailButtonWrapper>
);
};
const renderInputButton = () => (
<>
<Styled.BgCustomButton
icon="plus"
label={intl.formatMessage(intlMessages.customLabel)}
aria-describedby="vr-cam-btn-custom"
hideLabel
tabIndex={disabled ? -1 : 0}
disabled={disabled}
onClick={() => {
if (customBgSelectorRef.current) {
customBgSelectorRef.current.click();
}
}}
data-test="inputBackgroundButton"
/>
<input
ref={customBgSelectorRef}
type="file"
id="customBgSelector"
onChange={handleCustomBgChange}
style={{ display: 'none' }}
accept={MIME_TYPES_ALLOWED.join(', ')}
/>
<div aria-hidden className="sr-only" id="vr-cam-btn-custom">
{intl.formatMessage(intlMessages.customDesc)}
</div>
</>
);
const renderNoneButton = () => (
<>
<Styled.BgNoneButton
icon="close"
label={intl.formatMessage(intlMessages.noneLabel)}
aria-pressed={currentVirtualBg?.name === undefined}
aria-describedby="vr-cam-btn-none"
hideLabel
tabIndex={disabled ? -1 : 0}
disabled={disabled}
onClick={() => _virtualBgSelected(EFFECT_TYPES.NONE_TYPE)}
data-test="noneBackgroundButton"
/>
<div aria-hidden className="sr-only" id="vr-cam-btn-none">
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.NONE_TYPE })}
</div>
</>
);
const renderSkeleton = () => (
<SkeletonTheme baseColor="#DCE4EC" direction={isRTL ? 'rtl' : 'ltr'}>
{new Array(SKELETON_COUNT).fill(null).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<Styled.SkeletonWrapper key={`skeleton-${index}`}>
<Skeleton />
</Styled.SkeletonWrapper>
))}
</SkeletonTheme>
);
const ready = loaded && defaultSetUp;
const ENABLE_CAMERA_BRIGHTNESS = window.meetingClientSettings.public.app.enableCameraBrightness;
return (
<Styled.VirtualBackgroundRowThumbnail>
<Styled.BgWrapper
role="group"
aria-label={intl.formatMessage(intlMessages.virtualBackgroundSettingsLabel)}
brightnessEnabled={ENABLE_CAMERA_BRIGHTNESS}
data-test="virtualBackground"
>
{shouldEnableBackgroundUpload(isCustomVirtualBackgroundsEnabled) && (
<>
{!ready && renderSkeleton()}
{ready && (
<>
{renderNoneButton()}
{Object.values(backgrounds)
.sort((a, b) => b.lastActivityDate - a.lastActivityDate)
.map((background, index) => {
if (background.custom !== false) {
return renderCustomButton(background, index);
}
const isBlur = background.uniqueId.includes('Blur');
return isBlur
? renderBlurButton(index)
: renderDefaultButton(background.uniqueId, index);
})}
{renderInputButton()}
</>
)}
</>
)}
{!shouldEnableBackgroundUpload(isCustomVirtualBackgroundsEnabled) && (
<>
{renderNoneButton()}
{renderBlurButton(0)}
{IMAGE_NAMES.map((imageName, index) => {
const actualIndex = index + 1;
return renderDefaultButton(imageName, actualIndex);
})}
</>
)}
</Styled.BgWrapper>
</Styled.VirtualBackgroundRowThumbnail>
);
};
const renderSelector = () => {
if (showThumbnails) return renderThumbnailSelector();
return renderDropdownSelector();
};
return (
<>
<Styled.Label>
{intl.formatMessage(
isVirtualBackgroundSupported()
? intlMessages.virtualBackgroundSettingsLabel
: intlMessages.virtualBackgroundSettingsDisabledLabel,
)}
</Styled.Label>
{renderSelector()}
</>
);
};
VirtualBgSelector.propTypes = propTypes;
export default injectIntl(withFileReader(VirtualBgSelector, MIME_TYPES_ALLOWED, MAX_FILE_SIZE));