Merge pull request #14794 from JoVictorNunes/virtual-background-upload

feat(video): upload of own webcam virtual background
This commit is contained in:
Ramón Souza 2022-06-01 21:28:06 +01:00 committed by GitHub
commit ca762747a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 767 additions and 67 deletions

View File

@ -162,6 +162,7 @@ export default class ButtonBase extends React.Component {
'small',
'full',
'iconRight',
'isVisualEffects',
];
return (

View File

@ -75,7 +75,7 @@ class ConfirmationModal extends Component {
{description}
</Styled.DescriptionText>
{ hasCheckbox ? (
<label htmlFor="confirmationCheckbox" key="confirmation-checkbox">
<Styled.Label htmlFor="confirmationCheckbox" key="confirmation-checkbox">
<Styled.Checkbox
type="checkbox"
id="confirmationCheckbox"
@ -84,7 +84,7 @@ class ConfirmationModal extends Component {
aria-label={intl.formatMessage({ id: checkboxMessageId })}
/>
<span aria-hidden>{intl.formatMessage({ id: checkboxMessageId })}</span>
</label>
</Styled.Label>
) : null }
</Styled.Description>

View File

@ -83,6 +83,10 @@ const ConfirmationButton = styled(Button)`
}
`;
const Label = styled.label`
display: block;
`;
export default {
ConfirmationModal,
Container,
@ -93,4 +97,5 @@ export default {
Checkbox,
Footer,
ConfirmationButton,
Label,
};

View File

@ -3,12 +3,14 @@ import { ChatContextProvider } from '/imports/ui/components/components-data/chat
import { UsersContextProvider } from '/imports/ui/components/components-data/users-context/context';
import { GroupChatContextProvider } from '/imports/ui/components/components-data/group-chat-context/context';
import { LayoutContextProvider } from '/imports/ui/components/layout/context';
import { CustomBackgroundsProvider } from '/imports/ui/components/video-preview/virtual-background/context';
const providersList = [
ChatContextProvider,
GroupChatContextProvider,
UsersContextProvider,
LayoutContextProvider,
CustomBackgroundsProvider,
];
const ContextProvidersComponent = props => providersList.reduce((acc, Component) => (

View File

@ -48,6 +48,10 @@ const defaultProps = {
};
const intlMessages = defineMessages({
webcamEffectsTitle: {
id: 'app.videoPreview.webcamEffectsTitle',
description: 'Title for the video effects modal',
},
webcamSettingsTitle: {
id: 'app.videoPreview.webcamSettingsTitle',
description: 'Title for the video preview modal',
@ -315,9 +319,9 @@ class VideoPreview extends Component {
}
// Resolves into true if the background switch is successful, false otherwise
handleVirtualBgSelected(type, name) {
handleVirtualBgSelected(type, name, customParams) {
if (type !== EFFECT_TYPES.NONE_TYPE) {
return this.startVirtualBackground(this.currentVideoStream, type, name);
return this.startVirtualBackground(this.currentVideoStream, type, name, customParams);
} else {
this.stopVirtualBackground(this.currentVideoStream);
return Promise.resolve(true);
@ -331,12 +335,12 @@ class VideoPreview extends Component {
}
}
startVirtualBackground(bbbVideoStream, type, name) {
startVirtualBackground(bbbVideoStream, type, name, customParams) {
this.setState({ isStartSharingDisabled: true });
if (bbbVideoStream == null) return Promise.resolve(false);
return bbbVideoStream.startVirtualBackground(type, name).then(() => {
return bbbVideoStream.startVirtualBackground(type, name, customParams).then(() => {
this.displayPreview();
return true;
}).catch(error => {
@ -561,6 +565,7 @@ class VideoPreview extends Component {
const {
intl,
sharedDevices,
isVisualEffects,
} = this.props;
const {
@ -571,6 +576,14 @@ class VideoPreview extends Component {
const shared = sharedDevices.includes(webcamDeviceId);
if (isVisualEffects) {
return (
<Styled.Col>
{isVirtualBackgroundsEnabled() && this.renderVirtualBgSelector()}
</Styled.Col>
)
}
return (
<Styled.Col>
<Styled.Label htmlFor="setCam">
@ -642,6 +655,7 @@ class VideoPreview extends Component {
}
renderVirtualBgSelector() {
const { isVisualEffects } = this.props;
const { isStartSharingDisabled, webcamDeviceId } = this.state;
const initialVirtualBgState = this.currentVideoStream ? {
type: this.currentVideoStream.virtualBgType,
@ -654,6 +668,7 @@ class VideoPreview extends Component {
locked={isStartSharingDisabled}
showThumbnails={SHOW_THUMBNAILS}
initialVirtualBgState={initialVirtualBgState}
isVisualEffects={isVisualEffects}
/>
);
}
@ -725,6 +740,7 @@ class VideoPreview extends Component {
hasVideoStream,
forceOpen,
camCapReached,
isVisualEffects,
} = this.props;
const {
@ -741,6 +757,10 @@ class VideoPreview extends Component {
const { isIe } = browserInfo;
const title = isVisualEffects
? intl.formatMessage(intlMessages.webcamEffectsTitle)
: intl.formatMessage(intlMessages.webcamSettingsTitle);
return (
<>
{isIe ? (
@ -756,37 +776,39 @@ class VideoPreview extends Component {
</Styled.BrowserWarning>
) : null}
<Styled.Title>
{intl.formatMessage(intlMessages.webcamSettingsTitle)}
{title}
</Styled.Title>
{this.renderContent()}
<Styled.Footer>
{hasVideoStream && VideoService.isMultipleCamerasEnabled()
? (
<Styled.ExtraActions>
<Button
color="danger"
label={intl.formatMessage(intlMessages.stopSharingAllLabel)}
onClick={this.handleStopSharingAll}
disabled={shouldDisableButtons}
/>
</Styled.ExtraActions>
)
: null
}
<Styled.Actions>
{!shared && camCapReached ? (
<span>{intl.formatMessage(intlMessages.camCapReached)}</span>
) : (<Button
data-test="startSharingWebcam"
color={shared ? 'danger' : 'primary'}
label={intl.formatMessage(shared ? intlMessages.stopSharingLabel : intlMessages.startSharingLabel)}
onClick={shared ? this.handleStopSharing : this.handleStartSharing}
disabled={isStartSharingDisabled || isStartSharingDisabled === null || shouldDisableButtons}
/>)}
</Styled.Actions>
</Styled.Footer>
{!isVisualEffects ? (
<Styled.Footer>
{hasVideoStream && VideoService.isMultipleCamerasEnabled()
? (
<Styled.ExtraActions>
<Button
color="danger"
label={intl.formatMessage(intlMessages.stopSharingAllLabel)}
onClick={this.handleStopSharingAll}
disabled={shouldDisableButtons}
/>
</Styled.ExtraActions>
)
: null
}
<Styled.Actions>
{!shared && camCapReached ? (
<span>{intl.formatMessage(intlMessages.camCapReached)}</span>
) : (<Button
data-test="startSharingWebcam"
color={shared ? 'danger' : 'primary'}
label={intl.formatMessage(shared ? intlMessages.stopSharingLabel : intlMessages.startSharingLabel)}
onClick={shared ? this.handleStopSharing : this.handleStartSharing}
disabled={isStartSharingDisabled || isStartSharingDisabled === null || shouldDisableButtons}
/>)}
</Styled.Actions>
</Styled.Footer>
) : null }
</>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useContext, useEffect } from 'react';
import { findDOMNode } from 'react-dom';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
@ -10,6 +10,8 @@ import {
getVirtualBackgroundThumbnail,
isVirtualBackgroundSupported,
} from '/imports/ui/services/virtual-background/service';
import { CustomVirtualBackgroundsContext } from './context';
import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
const propTypes = {
intl: PropTypes.shape({
@ -37,6 +39,14 @@ const intlMessages = defineMessages({
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',
@ -49,6 +59,10 @@ const intlMessages = defineMessages({
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 {
@ -68,15 +82,31 @@ const VirtualBgSelector = ({
locked,
showThumbnails,
initialVirtualBgState,
isVisualEffects,
}) => {
const [currentVirtualBg, setCurrentVirtualBg] = useState({
...initialVirtualBgState,
});
const inputElementsRef = useRef([]);
const customBgSelectorRef = useRef(null);
const _virtualBgSelected = (type, name, index) =>
handleVirtualBgSelected(type, name)
const {
dispatch,
loaded,
customBackgrounds,
newCustomBackgrounds,
loadFromDB,
} = useContext(CustomVirtualBackgroundsContext);
const { MIME_TYPES_ALLOWED } = VirtualBgService;
useEffect(() => {
if (!loaded && isVisualEffects) loadFromDB();
}, []);
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
@ -128,6 +158,24 @@ const VirtualBgSelector = ({
);
}
const handleCustomBgChange = (event) => {
const file = event.target.files[0];
const { readFile } = VirtualBgService;
readFile(
file,
(background) => {
dispatch({
type: 'new',
background,
});
},
(error) => {
// Add some logging, notification, etc.
}
);
}
const renderThumbnailSelector = () => {
const disabled = locked || !isVirtualBackgroundSupported();
@ -136,6 +184,7 @@ const VirtualBgSelector = ({
<Styled.BgWrapper
role="group"
aria-label={intl.formatMessage(intlMessages.virtualBackgroundSettingsLabel)}
isVisualEffects={isVisualEffects}
>
<>
<Styled.BgNoneButton
@ -147,6 +196,7 @@ const VirtualBgSelector = ({
tabIndex={disabled ? -1 : 0}
disabled={disabled}
onClick={() => _virtualBgSelected(EFFECT_TYPES.NONE_TYPE)}
isVisualEffects={isVisualEffects}
/>
<div aria-hidden className="sr-only" id={`vr-cam-btn-none`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.NONE_TYPE })}
@ -165,6 +215,7 @@ const VirtualBgSelector = ({
disabled={disabled}
ref={ref => { inputElementsRef.current[0] = ref; }}
onClick={() => _virtualBgSelected(EFFECT_TYPES.BLUR_TYPE, 'Blur', 0)}
isVisualEffects={isVisualEffects}
/>
<div aria-hidden className="sr-only" id={`vr-cam-btn-blur`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.BLUR_TYPE })}
@ -191,6 +242,7 @@ const VirtualBgSelector = ({
ref={ref => inputElementsRef.current[index + 1] = ref}
onClick={() => _virtualBgSelected(EFFECT_TYPES.IMAGE_TYPE, imageName, index + 1)}
disabled={disabled}
isVisualEffects={isVisualEffects}
/>
<Styled.Thumbnail onClick={() => {
const node = findDOMNode(inputElementsRef.current[index + 1]);
@ -203,6 +255,95 @@ const VirtualBgSelector = ({
</div>
)
})}
{isVisualEffects && customBackgrounds
.concat(newCustomBackgrounds)
.map(({ filename, data, uniqueId }, index) => {
const imageIndex = index + IMAGE_NAMES.length + 2;
const label = intl.formatMessage(intlMessages.backgroundWithIndex, {
0: imageIndex,
});
return (
<Styled.ThumbnailButtonWrapper key={`${filename}-${index}`}>
<Styled.ThumbnailButton
id={`${filename}-${imageIndex}`}
label={label}
tabIndex={disabled ? -1 : 0}
role="button"
aria-label={label}
aria-describedby={`vr-cam-btn-${imageIndex}`}
aria-pressed={currentVirtualBg?.name?.includes(filename)}
hideLabel
ref={ref => inputElementsRef.current[index + IMAGE_NAMES.length + 1] = ref}
onClick={() => _virtualBgSelected(
EFFECT_TYPES.IMAGE_TYPE,
filename,
imageIndex - 1,
{ file: data },
)}
disabled={disabled}
isVisualEffects={isVisualEffects}
/>
<Styled.Thumbnail onClick={() => {
const node = findDOMNode(inputElementsRef.current[index + IMAGE_NAMES.length + 1]);
node.focus();
node.click();
}} aria-hidden src={data} />
<Styled.ButtonWrapper>
<Styled.ButtonRemove
label={intl.formatMessage(intlMessages.removeLabel)}
aria-label={intl.formatMessage(intlMessages.removeLabel)}
data-test="removeCustomBackground"
icon="close"
size="sm"
color="dark"
circle
hideLabel
onClick={() => {
dispatch({
type: 'delete',
uniqueId,
});
}}
/>
</Styled.ButtonWrapper>
<div aria-hidden className="sr-only" id={`vr-cam-btn-${imageIndex}`}>
{label}
</div>
</Styled.ThumbnailButtonWrapper>
);
})}
{isVisualEffects && (
<>
<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();
}
}}
isVisualEffects={isVisualEffects}
/>
<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.customLabel)}
</div>
</>
)}
</Styled.BgWrapper>
</Styled.VirtualBackgroundRowThumbnail>
);
@ -215,11 +356,13 @@ const VirtualBgSelector = ({
return (
<>
<Styled.Label>
{!isVirtualBackgroundSupported()
? intl.formatMessage(intlMessages.virtualBackgroundSettingsDisabledLabel)
: intl.formatMessage(intlMessages.virtualBackgroundSettingsLabel)}
</Styled.Label>
{!isVisualEffects && (
<Styled.Label>
{!isVirtualBackgroundSupported()
? intl.formatMessage(intlMessages.virtualBackgroundSettingsDisabledLabel)
: intl.formatMessage(intlMessages.virtualBackgroundSettingsLabel)}
</Styled.Label>
)}
{renderSelector()}
</>

View File

@ -0,0 +1,82 @@
import React, { useReducer } from 'react';
import _ from 'lodash';
import Service from './service';
export const CustomVirtualBackgroundsContext = React.createContext();
const reducer = (state, action) => {
const { save, del } = Service;
switch (action.type) {
case 'load': {
return {
...state,
loaded: true,
customBackgrounds: action.backgrounds,
newCustomBackgrounds: [],
};
}
case 'new': {
save(action.background);
return {
...state,
newCustomBackgrounds: [
...state.newCustomBackgrounds,
action.background,
],
};
}
case 'delete': {
const { customBackgrounds, newCustomBackgrounds } = state;
const filterFunc = ({ uniqueId }) => uniqueId !== action.uniqueId;
del(action.uniqueId);
return {
...state,
customBackgrounds: customBackgrounds.filter(filterFunc),
newCustomBackgrounds: newCustomBackgrounds.filter(filterFunc),
};
}
default: {
throw new Error('Unknown custom background action.');
}
}
}
export const CustomBackgroundsProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, {
loaded: false,
customBackgrounds: [],
newCustomBackgrounds: [],
});
const { load } = Service;
const loadFromDB = () => {
const onError = () => dispatch({
type: 'load',
backgrounds: [],
});
const onSuccess = (backgrounds) => dispatch({
type: 'load',
backgrounds,
});
load(onError, onSuccess);
}
return (
<CustomVirtualBackgroundsContext.Provider
value={{
dispatch,
loaded: state.loaded,
customBackgrounds: state.customBackgrounds,
newCustomBackgrounds: state.newCustomBackgrounds,
loadFromDB: _.throttle(loadFromDB, 500, { leading: true, trailing: false }),
}}
>
{children}
</CustomVirtualBackgroundsContext.Provider>
);
}

View File

@ -0,0 +1,139 @@
import _ from 'lodash';
import logger from '/imports/startup/client/logger';
import { notify } from '/imports/ui/services/notification';
const MIME_TYPES_ALLOWED = ['image/png', 'image/jpeg'];
const MAX_FILE_SIZE = 5000; // KBytes
const withObjectStore = ({
onError,
onSuccess,
}) => {
const request = window.indexedDB.open('BBB', 1);
request.onerror = onError;
request.onupgradeneeded = (e) => {
const db = e.target.result;
db.createObjectStore('CustomBackgrounds', { keyPath: 'uniqueId' });
};
request.onsuccess = (e) => {
const db = e.target.result;
const transaction = db.transaction(['CustomBackgrounds'], 'readwrite');
const objectStore = transaction.objectStore('CustomBackgrounds');
onSuccess(objectStore);
};
};
const genericErrorHandlerBuilder = (
code,
errorMessage,
notifyMessage,
callback,
) => (e) => {
notify(notifyMessage, 'error', 'warning');
logger.warn({
logCode: code,
extraInfo: {
errorName: e.name,
errorMessage: e.message,
},
}, `${errorMessage}: ${e.message}`);
if (callback) callback(e);
};
const load = (onError, onSuccess) => {
withObjectStore({
onError: genericErrorHandlerBuilder(
'IndexedDB_access',
'Error on load custom backgrounds to IndexedDB',
'Something wrong while loading custom backgrounds',
onError,
),
onSuccess: (objectStore) => {
const backgrounds = [];
objectStore.openCursor().onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
backgrounds.push(cursor.value);
cursor.continue();
} else {
onSuccess(backgrounds);
}
};
},
});
};
const save = (background) => {
withObjectStore({
onError: genericErrorHandlerBuilder(
'IndexedDB_access',
'Error on save custom background to IndexedDB',
'Something wrong while saving custom background',
),
onSuccess: (objectStore) => {
objectStore.add(background);
},
});
};
const del = (key) => {
withObjectStore({
onError: genericErrorHandlerBuilder(
'IndexedDB_access',
'Error on delete custom background to IndexedDB',
'Something wrong while deleting custom background',
),
onSuccess: (objectStore) => {
objectStore.delete(key);
},
});
};
const parseFilename = (filename = '') => {
const substrings = filename.split('.');
substrings.pop();
const filenameWithoutExtension = substrings.join('');
return filenameWithoutExtension;
};
const readFile = (file, onSuccess, onError) => {
const { name, size, type } = file;
const sizeInKB = size / 1024;
if (sizeInKB > MAX_FILE_SIZE) {
return onError(new Error('Maximum file size exceeded.'));
}
if (!MIME_TYPES_ALLOWED.includes(type)) {
return onError(new Error('File type not allowed.'));
}
const filenameWithoutExtension = parseFilename(name);
const reader = new FileReader();
reader.onload = function (e) {
const background = {
filename: filenameWithoutExtension,
data: e.target.result,
uniqueId: _.uniqueId(),
};
onSuccess(background);
}
reader.readAsDataURL(file);
};
export default {
load,
save,
del,
readFile,
MIME_TYPES_ALLOWED,
MAX_FILE_SIZE,
};

View File

@ -3,6 +3,7 @@ import {
borderSize,
borderSizeLarge,
borderSizeSmall,
smPaddingY,
} from '/imports/ui/stylesheets/styled-components/general';
import {
userThumbnailBorder,
@ -13,18 +14,26 @@ import {
colorPrimary,
colorWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
import { fontSizeSmallest } from '/imports/ui/stylesheets/styled-components/typography';
import Button from '/imports/ui/components/common/button/component';
const VirtualBackgroundRowThumbnail = styled.div`
margin-top: 0.4rem;
`;
const BgWrapper = styled.div`
const BgWrapper = styled(ScrollboxVertical)`
display: flex;
justify-content: flex-start;
overflow-x: auto;
margin: ${borderSizeLarge};
padding: ${borderSizeLarge};
${({ isVisualEffects }) => isVisualEffects && `
height: 15rem;
flex-wrap: wrap;
align-content: flex-start;
`}
`;
const BgNoneButton = styled(Button)`
@ -34,6 +43,10 @@ const BgNoneButton = styled(Button)`
border: ${borderSizeSmall} solid ${userThumbnailBorder};
margin: 0 0.15em;
flex-shrink: 0;
${({ isVisualEffects }) => isVisualEffects && `
margin: 0.15em;
`}
`;
const ThumbnailButton = styled(Button)`
@ -52,6 +65,12 @@ const ThumbnailButton = styled(Button)`
margin: 0 0.15em;
flex-shrink: 0;
${({ isVisualEffects }) => isVisualEffects && `
background-size: cover;
background-position: center;
margin: 0.15em;
`}
& + img {
border-radius: ${borderSizeLarge};
}
@ -112,6 +131,26 @@ const Label = styled.label`
color: ${colorGrayLabel};
`;
const ThumbnailButtonWrapper = styled.div`
position: relative;
`;
const ButtonWrapper = styled.div`
position: absolute;
z-index: 2;
right: 0;
top: 0;
`;
const ButtonRemove = styled(Button)`
span {
font-size: ${fontSizeSmallest};
padding: ${smPaddingY};
}
`;
const BgCustomButton = styled(BgNoneButton)``;
export default {
VirtualBackgroundRowThumbnail,
BgWrapper,
@ -120,4 +159,8 @@ export default {
Thumbnail,
Select,
Label,
ThumbnailButtonWrapper,
ButtonWrapper,
ButtonRemove,
BgCustomButton,
};

View File

@ -147,6 +147,7 @@ class VideoProvider extends Component {
VideoService.getPageChangeDebounceTime(),
{ leading: false, trailing: true },
);
this.startVirtualBackgroundByDrop = this.startVirtualBackgroundByDrop.bind(this);
}
componentDidMount() {
@ -937,6 +938,34 @@ class VideoProvider extends Component {
}
}
startVirtualBackgroundByDrop(stream, type, name, data) {
return new Promise((resolve, reject) => {
const peer = this.webRtcPeers[stream];
const { bbbVideoStream } = peer;
const video = this.getVideoElement(stream);
if (peer && video && peer.attached && video.srcObject) {
bbbVideoStream.startVirtualBackground(type, name, { file: data })
.then(resolve)
.catch(reject);
}
}).catch((error) => {
this.handleVirtualBgErrorByDropping(error, type, name);
});
}
handleVirtualBgErrorByDropping(error, type, name) {
logger.error({
logCode: `video_provider_virtualbg_error`,
extraInfo: {
errorName: error.name,
errorMessage: error.message,
virtualBgType: type,
virtualBgName: name,
},
}, `Failed to start virtual background by dropping image: ${error.message}`);
}
restoreVirtualBackground(stream, type, name) {
return new Promise((resolve, reject) => {
if (type !== EFFECT_TYPES.NONE_TYPE) {
@ -1116,6 +1145,7 @@ class VideoProvider extends Component {
}}
onVideoItemMount={this.createVideoTag}
onVideoItemUnmount={this.destroyVideoTag}
onVirtualBgDrop={this.startVirtualBackgroundByDrop}
/>
);
}

View File

@ -7,10 +7,20 @@ import Styled from './styles';
import { validIOSVersion } from '/imports/ui/components/app/service';
import deviceInfo from '/imports/utils/deviceInfo';
import { debounce } from 'lodash';
import BBBMenu from '/imports/ui/components/common/menu/component';
const ENABLE_WEBCAM_SELECTOR_BUTTON = Meteor.settings.public.app.enableWebcamSelectorButton;
const ENABLE_WEBCAM_BACKGROUND_UPLOAD = Meteor.settings.public.virtualBackgrounds.enableVirtualBackgroundUpload;
const intlMessages = defineMessages({
videoSettings: {
id: 'app.video.videoSettings',
description: 'Open video settings',
},
visualEffects: {
id: 'app.video.visualEffects',
description: 'Visual effects label',
},
joinVideo: {
id: 'app.video.joinVideo',
description: 'Join video button label',
@ -66,6 +76,9 @@ const JoinVideoButton = ({
const isDesktopSharingCamera = hasVideoStream && !isMobile;
const shouldEnableWebcamSelectorButton = ENABLE_WEBCAM_SELECTOR_BUTTON
&& isDesktopSharingCamera;
const shouldEnableWebcamBackgroundUploadButton = ENABLE_WEBCAM_BACKGROUND_UPLOAD
&& hasVideoStream
&& !isMobile;
const exitVideo = () => isDesktopSharingCamera && (!VideoService.isMultipleCamerasEnabled()
|| shouldEnableWebcamSelectorButton);
@ -88,9 +101,8 @@ const JoinVideoButton = ({
}
}, JOIN_VIDEO_DELAY_MILLISECONDS);
const handleOpenAdvancedOptions = (e) => {
e.stopPropagation();
mountVideoPreview(isMobileSharingCamera);
const handleOpenAdvancedOptions = (props) => {
mountVideoPreview(isMobileSharingCamera, props);
};
const getMessageFromStatus = () => {
@ -107,17 +119,44 @@ const JoinVideoButton = ({
const isSharing = hasVideoStream || status === 'videoConnecting';
const renderEmojiButton = () => (
shouldEnableWebcamSelectorButton
&& (
<ButtonEmoji
onClick={handleOpenAdvancedOptions}
emoji="device_list_selector"
hideLabel
label={intl.formatMessage(intlMessages.advancedVideo)}
const renderUserActions = () => {
const actions = [];
if (shouldEnableWebcamSelectorButton) {
actions.push(
{
key: 'advancedVideo',
label: intl.formatMessage(intlMessages.advancedVideo),
onClick: () => handleOpenAdvancedOptions(),
},
);
}
if (shouldEnableWebcamBackgroundUploadButton) {
actions.push(
{
key: 'virtualBgSelection',
label: intl.formatMessage(intlMessages.visualEffects),
onClick: () => handleOpenAdvancedOptions({ isVisualEffects: true }),
},
);
}
if (actions.length === 0) return null;
return (
<BBBMenu
trigger={(
<ButtonEmoji
emoji="device_list_selector"
hideLabel
label={intl.formatMessage(intlMessages.videoSettings)}
/>
)}
actions={actions}
/>
)
);
);
}
return (
<Styled.OffsetBottom>
@ -133,7 +172,7 @@ const JoinVideoButton = ({
circle
disabled={!!disableReason}
/>
{renderEmojiButton()}
{renderUserActions()}
</Styled.OffsetBottom>
);
};

View File

@ -16,7 +16,7 @@ const JoinVideoOptionsContainer = (props) => {
...restProps
} = props;
const mountVideoPreview = (force) => { mountModal(<VideoPreviewContainer forceOpen={force} />); };
const mountVideoPreview = (force, props) => { mountModal(<VideoPreviewContainer forceOpen={force} {...(props || {})} />); };
return (
<JoinVideoButton {...{

View File

@ -291,6 +291,7 @@ class VideoList extends Component {
renderVideoList() {
const {
streams,
onVirtualBgDrop,
onVideoItemMount,
onVideoItemUnmount,
swapLayout,
@ -322,6 +323,7 @@ class VideoList extends Component {
}}
onVideoItemUnmount={onVideoItemUnmount}
swapLayout={swapLayout}
onVirtualBgDrop={(type, name, data) => onVirtualBgDrop(stream, type, name, data)}
/>
</Styled.VideoListItem>
);

View File

@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { injectIntl } from 'react-intl';
import _ from 'lodash';
import PropTypes from 'prop-types';
import ViewActions from '/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component';
import UserActions from '/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component';
import UserStatus from '/imports/ui/components/video-provider/video-list/video-list-item/user-status/component';
import PinArea from '/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component';
import UserAvatarVideo from '/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component';
import ViewActions from '/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component';
import {
isStreamStateUnhealthy,
subscribeToStreamStateChange,
@ -14,13 +15,15 @@ import {
import Settings from '/imports/ui/services/settings';
import VideoService from '/imports/ui/components/video-provider/service';
import Styled from './styles';
import { withDragAndDrop } from './drag-and-drop/component';
const VIDEO_CONTAINER_WIDTH_BOUND = 125;
const VideoListItem = (props) => {
const {
name, voiceUser, isFullscreenContext, layoutContextDispatch, user, onHandleVideoFocus,
cameraId, numOfStreams, focused, onVideoItemMount, onVideoItemUnmount,
cameraId, numOfStreams, focused, onVideoItemMount, onVideoItemUnmount, onVirtualBgDrop,
makeDragOperations,
} = props;
const [videoIsReady, setVideoIsReady] = useState(false);
@ -198,6 +201,7 @@ const VideoListItem = (props) => {
fullscreen={isFullscreenContext}
data-test={talking ? 'webcamItemTalkingUser' : 'webcamItem'}
animations={animations}
{...makeDragOperations(onVirtualBgDrop, user.userId)}
>
<Styled.VideoContainer>
<Styled.Video
@ -224,7 +228,7 @@ const VideoListItem = (props) => {
);
};
export default injectIntl(VideoListItem);
export default withDragAndDrop(injectIntl(VideoListItem));
VideoListItem.defaultProps = {
numOfStreams: 0,

View File

@ -0,0 +1,141 @@
import React, { useContext, useEffect, useState } from 'react';
import { injectIntl, defineMessages } from 'react-intl';
import Auth from '/imports/ui/services/auth';
import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
import { CustomVirtualBackgroundsContext } from '/imports/ui/components/video-preview/virtual-background/context';
import { EFFECT_TYPES } from '/imports/ui/services/virtual-background/service';
import { withModalMounter } from '/imports/ui/components/common/modal/service';
import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
const intlMessages = defineMessages({
confirmationTitle: {
id: 'app.confirmation.virtualBackground.title',
description: 'Confirmation modal title',
},
confirmationDescription: {
id: 'app.confirmation.virtualBackground.description',
description: 'Confirmation modal description',
},
});
const ENABLE_WEBCAM_BACKGROUND_UPLOAD = Meteor.settings.public.virtualBackgrounds.enableVirtualBackgroundUpload;
const DragAndDrop = (props) => {
const { children, mountModal, intl } = props;
const [dragging, setDragging] = useState(false);
const [draggingOver, setDraggingOver] = useState(false);
const { dispatch: dispatchCustomBackground } = useContext(CustomVirtualBackgroundsContext);
const resetEvent = (e) => {
e.preventDefault();
e.stopPropagation();
}
useEffect(() => {
const onDragOver = (e) => {
resetEvent(e);
setDragging(true);
};
const onDragLeave = (e) => {
resetEvent(e);
setDragging(false);
};
const onDrop = (e) => {
resetEvent(e);
setDragging(false);
};
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
};
}, []);
const makeDragOperations = (onAction, userId) => {
if (Auth.userID !== userId || !ENABLE_WEBCAM_BACKGROUND_UPLOAD) return {};
const startAndSaveVirtualBackground = (file) => {
const { readFile } = VirtualBgService;
readFile(
file,
(background) => {
const { filename, data } = background;
onAction(EFFECT_TYPES.IMAGE_TYPE, filename, data).then(() => {
dispatchCustomBackground({
type: 'new',
background,
});
});
},
(error) => {
// Add some logging, notification, etc.
}
);
};
const onDragOverHandler = (e) => {
resetEvent(e);
setDraggingOver(true);
setDragging(false);
}
const onDropHandler = (e) => {
resetEvent(e);
setDraggingOver(false);
setDragging(false);
const { files } = e.dataTransfer;
const file = files[0];
if (Session.get('skipBackgroundDropConfirmation')) {
return startAndSaveVirtualBackground(file);
}
const onConfirm = (confirmParam, checked) => {
startAndSaveVirtualBackground(file);
Session.set('skipBackgroundDropConfirmation', checked);
};
mountModal(
<ConfirmationModal
intl={intl}
onConfirm={onConfirm}
title={intl.formatMessage(intlMessages.confirmationTitle)}
description={intl.formatMessage(intlMessages.confirmationDescription, { 0: file.name })}
checkboxMessageId="app.confirmation.skipConfirm"
/>
);
};
const onDragLeaveHandler = (e) => {
resetEvent(e);
setDragging(false);
setDraggingOver(false);
}
return {
onDragOver: onDragOverHandler,
onDrop: onDropHandler,
onDragLeave: onDragLeaveHandler,
dragging,
draggingOver,
};
}
return React.cloneElement(children, { ...props, makeDragOperations })
};
const Wrapper = (Component) => (props) => (
<DragAndDrop {...props} >
<Component />
</DragAndDrop>
);
export const withDragAndDrop = (Component) => withModalMounter(injectIntl(Wrapper(Component)));

View File

@ -4,6 +4,7 @@ import {
colorBlack,
colorWhite,
webcamBackgroundColor,
colorDanger,
} from '/imports/ui/stylesheets/styled-components/palette';
import { TextElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
@ -16,6 +17,15 @@ const rotate360 = keyframes`
}
`;
const fade = keyframes`
from {
opacity: 0.7;
}
to {
opacity: 0;
}
`;
const Content = styled.div`
position: relative;
display: flex;
@ -41,6 +51,22 @@ const Content = styled.div`
`}
}
${({ dragging, animations }) => dragging && animations && css`
&::after {
animation: ${fade} .5s linear infinite;
animation-direction: alternate;
}
`}
${({ dragging, draggingOver }) => (dragging || draggingOver) && `
&::after {
opacity: 0.7;
border-style: dashed;
border-color: ${colorDanger};
transition: opacity 0s;
}
`}
${({ fullscreen }) => fullscreen && `
position: fixed;
top: 0;

View File

@ -249,7 +249,11 @@ class VirtualBackgroundService {
}
this._virtualImage = document.createElement('img');
this._virtualImage.crossOrigin = 'anonymous';
this._virtualImage.src = virtualBackgroundImagePath + imagesrc;
if (parameters.customParams) {
this._virtualImage.src = parameters.customParams.file;
} else {
this._virtualImage.src = virtualBackgroundImagePath + imagesrc;
}
}
/**
@ -335,7 +339,11 @@ export async function createVirtualBackgroundService(parameters = null) {
parameters.backgroundType = 'blur';
parameters.isVirtualBackground = false;
} else {
parameters.virtualSource = virtualBackgroundImagePath + parameters.backgroundFilename;
if (parameters.customParams) {
parameters.virtualSource = parameters.customParams.file;
} else {
parameters.virtualSource = virtualBackgroundImagePath + parameters.backgroundFilename;
}
}
if (!modelResponse.ok) {

View File

@ -42,11 +42,12 @@ const {
showThumbnails: SHOW_THUMBNAILS = true,
} = Meteor.settings.public.virtualBackgrounds;
const createVirtualBackgroundStream = (type, name, isVirtualBackground, stream) => {
const createVirtualBackgroundStream = (type, name, isVirtualBackground, stream, customParams) => {
const buildParams = {
backgroundType: type,
backgroundFilename: name,
isVirtualBackground,
customParams,
}
return createVirtualBackgroundService(buildParams).then((service) => {

View File

@ -76,12 +76,13 @@ export class BBBVideoStream extends EventEmitter2 {
BBBVideoStream.trackStreamTermination(this.originalStream, notify);
}
_changeVirtualBackground (type, name) {
_changeVirtualBackground (type, name, customParams) {
try {
this.virtualBgService.changeBackgroundImage({
type,
name,
isVirtualBackground: this.isVirtualBackground(type),
customParams,
});
this.virtualBgType = type;
this.virtualBgName = name;
@ -92,14 +93,15 @@ export class BBBVideoStream extends EventEmitter2 {
}
startVirtualBackground (type, name = '') {
if (this.virtualBgService) return this._changeVirtualBackground(type, name);
startVirtualBackground (type, name = '', customParams) {
if (this.virtualBgService) return this._changeVirtualBackground(type, name, customParams);
return createVirtualBackgroundStream(
type,
name,
this.isVirtualBackground(type),
this.mediaStream
this.mediaStream,
customParams,
).then(({ service, effect }) => {
this.virtualBgService = service;
this.virtualBgType = type;

View File

@ -767,6 +767,7 @@ public:
logTag: ''
virtualBackgrounds:
enabled: true
enableVirtualBackgroundUpload: true
storedOnBBB: true
showThumbnails: true
imagesPath: /resources/images/virtual-backgrounds/

View File

@ -76,6 +76,9 @@
"app.captions.speech.start": "Speech recognition started",
"app.captions.speech.stop": "Speech recognition stopped",
"app.captions.speech.error": "Speech recognition stopped due to the browser incompatibility or some time of silence",
"app.confirmation.skipConfirm": "Don't ask again",
"app.confirmation.virtualBackground.title": "Start new virtual background",
"app.confirmation.virtualBackground.description": "{0} will be added as virtual background. Continue?",
"app.textInput.sendLabel": "Send",
"app.title.defaultViewLabel": "Default presentation view",
"app.notes.title": "Shared Notes",
@ -803,6 +806,7 @@
"app.videoPreview.webcamOptionLabel": "Choose webcam",
"app.videoPreview.webcamPreviewLabel": "Webcam preview",
"app.videoPreview.webcamSettingsTitle": "Webcam settings",
"app.videoPreview.webcamEffectsTitle": "Webcam visual effects",
"app.videoPreview.webcamVirtualBackgroundLabel": "Virtual background settings",
"app.videoPreview.webcamVirtualBackgroundDisabledLabel": "This device does not support virtual backgrounds",
"app.videoPreview.webcamNotFoundLabel": "Webcam not found",
@ -810,6 +814,8 @@
"app.video.joinVideo": "Share webcam",
"app.video.connecting": "Webcam sharing is starting ...",
"app.video.leaveVideo": "Stop sharing webcam",
"app.video.videoSettings": "Video settings",
"app.video.visualEffects": "Visual effects",
"app.video.advancedVideo": "Open advanced settings",
"app.video.iceCandidateError": "Error on adding ICE candidate",
"app.video.iceConnectionStateError": "Connection failure (ICE error 1107)",
@ -847,6 +853,9 @@
"app.video.virtualBackground.board": "Board",
"app.video.virtualBackground.coffeeshop": "Coffeeshop",
"app.video.virtualBackground.background": "Background",
"app.video.virtualBackground.backgroundWithIndex": "Background {0}",
"app.video.virtualBackground.custom": "Upload from your computer",
"app.video.virtualBackground.remove": "Remove added image",
"app.video.virtualBackground.genericError": "Failed to apply camera effect. Try again.",
"app.video.virtualBackground.camBgAriaDesc": "Sets webcam virtual background to {0}",
"app.video.camCapReached": "You cannot share more cameras",