feat(virtual-backgrounds) add ability to upload custom images
This commit is contained in:
parent
a3a2ce3875
commit
77ee4b13e1
|
@ -1,13 +1,21 @@
|
||||||
.virtual-background-dialog {
|
.virtual-background-dialog {
|
||||||
display: inline-flex;
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto auto auto auto auto auto auto;
|
||||||
|
max-width: 370px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
.thumbnail {
|
.thumbnail {
|
||||||
|
border-radius: 10px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnail:hover ~ .delete-image-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
.thumbnail-selected {
|
.thumbnail-selected {
|
||||||
|
border-radius: 10px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
@ -15,13 +23,14 @@
|
||||||
border: 2px solid #a4b8d1;
|
border: 2px solid #a4b8d1;
|
||||||
}
|
}
|
||||||
.blur-selected {
|
.blur-selected {
|
||||||
|
border-radius: 10px;
|
||||||
border: 2px solid #a4b8d1;
|
border: 2px solid #a4b8d1;
|
||||||
}
|
}
|
||||||
.virtual-background-none {
|
.virtual-background-none {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
height: 35px;
|
height: 34px;
|
||||||
width: 35px;
|
width: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid #a4b8d1;
|
border: 1px solid #a4b8d1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -32,8 +41,8 @@
|
||||||
.none-selected {
|
.none-selected {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
height: 35px;
|
height: 34px;
|
||||||
width: 35px;
|
width: 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 2px solid #a4b8d1;
|
border: 2px solid #a4b8d1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -42,3 +51,39 @@
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.file-upload-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.custom-file-upload {
|
||||||
|
font-size: x-large;
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px;
|
||||||
|
height: 35px;
|
||||||
|
width: 35px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #a4b8d1;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
line-height: 35px;
|
||||||
|
margin-left: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-image-icon {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
left: 36;
|
||||||
|
bottom: 36;
|
||||||
|
}
|
||||||
|
.delete-image-icon:hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content-text{
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
|
@ -339,7 +339,10 @@
|
||||||
"virtualBackground": {
|
"virtualBackground": {
|
||||||
"title": "Backgrounds",
|
"title": "Backgrounds",
|
||||||
"enableBlur": "Enable blur",
|
"enableBlur": "Enable blur",
|
||||||
"removeBackground": "Remove background"
|
"removeBackground": "Remove background",
|
||||||
|
"uploadImage": "Upload image",
|
||||||
|
"pleaseWait": "Please wait...",
|
||||||
|
"none": "None"
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"average": "Average",
|
"average": "Average",
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
SET_TIMEOUT,
|
SET_TIMEOUT,
|
||||||
timerWorkerScript
|
timerWorkerScript
|
||||||
} from './TimerWorker';
|
} from './TimerWorker';
|
||||||
|
|
||||||
const blurValue = '25px';
|
const blurValue = '25px';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -114,7 +113,13 @@ export default class JitsiStreamBackgroundEffect {
|
||||||
|
|
||||||
this._outputCanvasCtx.globalCompositeOperation = 'destination-over';
|
this._outputCanvasCtx.globalCompositeOperation = 'destination-over';
|
||||||
if (this._options.virtualBackground.isVirtualBackground) {
|
if (this._options.virtualBackground.isVirtualBackground) {
|
||||||
this._outputCanvasCtx.drawImage(this._virtualImage, 0, 0);
|
this._outputCanvasCtx.drawImage(
|
||||||
|
this._virtualImage,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
this._inputVideoElement.width,
|
||||||
|
this._inputVideoElement.height
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this._outputCanvasCtx.filter = `blur(${blurValue})`;
|
this._outputCanvasCtx.filter = `blur(${blurValue})`;
|
||||||
this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0);
|
this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0);
|
||||||
|
|
|
@ -1,36 +1,37 @@
|
||||||
// @flow
|
// @flow
|
||||||
/* eslint-disable react/jsx-no-bind, no-return-assign */
|
/* eslint-disable react/jsx-no-bind, no-return-assign */
|
||||||
import React, { useState } from 'react';
|
import Spinner from '@atlaskit/spinner';
|
||||||
|
import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import uuid from 'uuid';
|
||||||
|
|
||||||
import { Dialog } from '../../base/dialog';
|
import { Dialog } from '../../base/dialog';
|
||||||
import { translate } from '../../base/i18n';
|
import { translate } from '../../base/i18n';
|
||||||
import { Icon, IconBlurBackground } from '../../base/icons';
|
import { Icon, IconBlurBackground, IconCancelSelection } from '../../base/icons';
|
||||||
import { connect } from '../../base/redux';
|
import { connect } from '../../base/redux';
|
||||||
import { Tooltip } from '../../base/tooltip';
|
import { Tooltip } from '../../base/tooltip';
|
||||||
import { toggleBackgroundEffect, setVirtualBackground } from '../actions';
|
import { toggleBackgroundEffect, setVirtualBackground } from '../actions';
|
||||||
|
import { resizeImage, toDataURL } from '../functions';
|
||||||
|
import logger from '../logger';
|
||||||
|
|
||||||
|
// The limit of virtual background uploads is 21. When the number
|
||||||
|
// of uploads is 22 we trigger the deleteStoredImage function to delete
|
||||||
|
// the first/oldest uploaded background.
|
||||||
|
const backgroundsLimit = 22;
|
||||||
const images = [
|
const images = [
|
||||||
{
|
{
|
||||||
tooltip: 'Image 1',
|
|
||||||
name: 'background-1.jpg',
|
|
||||||
id: 1,
|
id: 1,
|
||||||
src: 'images/virtual-background/background-1.jpg'
|
src: 'images/virtual-background/background-1.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tooltip: 'Image 2',
|
|
||||||
name: 'background-2.jpg',
|
|
||||||
id: 2,
|
id: 2,
|
||||||
src: 'images/virtual-background/background-2.jpg'
|
src: 'images/virtual-background/background-2.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tooltip: 'Image 3',
|
|
||||||
name: 'background-3.jpg',
|
|
||||||
id: 3,
|
id: 3,
|
||||||
src: 'images/virtual-background/background-3.jpg'
|
src: 'images/virtual-background/background-3.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tooltip: 'Image 4',
|
|
||||||
name: 'background-4.jpg',
|
|
||||||
id: 4,
|
id: 4,
|
||||||
src: 'images/virtual-background/background-4.jpg'
|
src: 'images/virtual-background/background-4.jpg'
|
||||||
}
|
}
|
||||||
|
@ -54,23 +55,81 @@ type Props = {
|
||||||
* @returns {ReactElement}
|
* @returns {ReactElement}
|
||||||
*/
|
*/
|
||||||
function VirtualBackground({ dispatch, t }: Props) {
|
function VirtualBackground({ dispatch, t }: Props) {
|
||||||
|
const localImages = jitsiLocalStorage.getItem('virtualBackgrounds');
|
||||||
|
const [ storedImages, setStoredImages ] = useState((localImages && JSON.parse(localImages)) || []);
|
||||||
|
const [ loading, isloading ] = useState(false);
|
||||||
|
|
||||||
|
const deleteStoredImage = image => {
|
||||||
|
setStoredImages(storedImages.filter(item => item !== image));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates stored images on local storage.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
jitsiLocalStorage.setItem('virtualBackgrounds', JSON.stringify(storedImages));
|
||||||
|
if (storedImages.length === backgroundsLimit) {
|
||||||
|
deleteStoredImage(storedImages[0]);
|
||||||
|
}
|
||||||
|
}, [ storedImages ]);
|
||||||
|
|
||||||
const [ selected, setSelected ] = useState('');
|
const [ selected, setSelected ] = useState('');
|
||||||
const enableBlur = () => {
|
const enableBlur = async () => {
|
||||||
|
isloading(true);
|
||||||
setSelected('blur');
|
setSelected('blur');
|
||||||
dispatch(setVirtualBackground('', false));
|
await dispatch(setVirtualBackground('', false));
|
||||||
dispatch(toggleBackgroundEffect(true));
|
await dispatch(toggleBackgroundEffect(true));
|
||||||
|
isloading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeBackground = () => {
|
const removeBackground = async () => {
|
||||||
|
isloading(true);
|
||||||
setSelected('none');
|
setSelected('none');
|
||||||
dispatch(setVirtualBackground('', false));
|
await dispatch(setVirtualBackground('', false));
|
||||||
dispatch(toggleBackgroundEffect(false));
|
await dispatch(toggleBackgroundEffect(false));
|
||||||
|
isloading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addImageBackground = image => {
|
const setUploadedImageBackground = async image => {
|
||||||
|
isloading(true);
|
||||||
setSelected(image.id);
|
setSelected(image.id);
|
||||||
dispatch(setVirtualBackground(image.src, true));
|
await dispatch(setVirtualBackground(image.src, true));
|
||||||
dispatch(toggleBackgroundEffect(true));
|
await dispatch(toggleBackgroundEffect(true));
|
||||||
|
isloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setImageBackground = async image => {
|
||||||
|
isloading(true);
|
||||||
|
setSelected(image.id);
|
||||||
|
await dispatch(setVirtualBackground(await toDataURL(image.src), true));
|
||||||
|
await dispatch(toggleBackgroundEffect(true));
|
||||||
|
isloading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadImage = async imageFile => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.readAsDataURL(imageFile[0]);
|
||||||
|
reader.onload = async () => {
|
||||||
|
const resizedImage = await resizeImage(reader.result);
|
||||||
|
|
||||||
|
isloading(true);
|
||||||
|
setStoredImages([
|
||||||
|
...storedImages,
|
||||||
|
{
|
||||||
|
id: uuid.v4(),
|
||||||
|
src: resizedImage
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dispatch(setVirtualBackground(resizedImage, true));
|
||||||
|
await dispatch(toggleBackgroundEffect(true));
|
||||||
|
isloading(false);
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
isloading(false);
|
||||||
|
logger.error('Failed to upload virtual image!');
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -79,14 +138,23 @@ function VirtualBackground({ dispatch, t }: Props) {
|
||||||
submitDisabled = { false }
|
submitDisabled = { false }
|
||||||
titleKey = { 'virtualBackground.title' }
|
titleKey = { 'virtualBackground.title' }
|
||||||
width = 'small'>
|
width = 'small'>
|
||||||
|
{loading ? (
|
||||||
|
<div>
|
||||||
|
<span className = 'loading-content-text'>{t('virtualBackground.pleaseWait')}</span>
|
||||||
|
<Spinner
|
||||||
|
isCompleting = { false }
|
||||||
|
size = 'medium' />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
<div className = 'virtual-background-dialog'>
|
<div className = 'virtual-background-dialog'>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content = { t('virtualBackground.removeBackground') }
|
content = { t('virtualBackground.removeBackground') }
|
||||||
position = { 'top' }>
|
position = { 'top' }>
|
||||||
<div
|
<div
|
||||||
className = { selected === 'none' ? 'none-selected' : 'virtual-background-none' }
|
className = { selected === 'none' ? 'none-selected' : 'virtual-background-none' }
|
||||||
onClick = { () => removeBackground() }>
|
onClick = { removeBackground }>
|
||||||
None
|
{t('virtualBackground.none')}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
@ -99,18 +167,50 @@ function VirtualBackground({ dispatch, t }: Props) {
|
||||||
src = { IconBlurBackground } />
|
src = { IconBlurBackground } />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<Tooltip
|
|
||||||
content = { image.tooltip }
|
|
||||||
key = { index }
|
|
||||||
position = { 'top' }>
|
|
||||||
<img
|
<img
|
||||||
className = { selected === image.id ? 'thumbnail-selected' : 'thumbnail' }
|
className = { selected === image.id ? 'thumbnail-selected' : 'thumbnail' }
|
||||||
onClick = { () => addImageBackground(image) }
|
key = { index }
|
||||||
|
onClick = { () => setImageBackground(image) }
|
||||||
onError = { event => event.target.style.display = 'none' }
|
onError = { event => event.target.style.display = 'none' }
|
||||||
src = { image.src } />
|
src = { image.src } />
|
||||||
|
))}
|
||||||
|
<Tooltip
|
||||||
|
content = { t('virtualBackground.uploadImage') }
|
||||||
|
position = { 'top' }>
|
||||||
|
<label
|
||||||
|
className = 'custom-file-upload'
|
||||||
|
htmlFor = 'file-upload'>
|
||||||
|
+
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept = 'image/*'
|
||||||
|
className = 'file-upload-btn'
|
||||||
|
id = 'file-upload'
|
||||||
|
onChange = { e => uploadImage(e.target.files) }
|
||||||
|
type = 'file' />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className = 'virtual-background-dialog'>
|
||||||
|
{storedImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
className = { 'thumbnail-container' }
|
||||||
|
key = { index }>
|
||||||
|
<img
|
||||||
|
className = { selected === image.id ? 'thumbnail-selected' : 'thumbnail' }
|
||||||
|
onClick = { () => setUploadedImageBackground(image) }
|
||||||
|
onError = { event => event.target.style.display = 'none' }
|
||||||
|
src = { image.src } />
|
||||||
|
<Icon
|
||||||
|
className = { 'delete-image-icon' }
|
||||||
|
onClick = { () => deleteStoredImage(image) }
|
||||||
|
size = { 15 }
|
||||||
|
src = { IconCancelSelection } />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
|
|
||||||
let filterSupport;
|
let filterSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -20,3 +18,62 @@ export function checkBlurSupport() {
|
||||||
|
|
||||||
return filterSupport;
|
return filterSupport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert blob to base64.
|
||||||
|
*
|
||||||
|
* @param {Blob} blob - The link to add info with.
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export const blobToData = (blob: Blob): Promise<string> => new Promise(resolve => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onloadend = () => resolve(reader.result.toString());
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert blob to base64.
|
||||||
|
*
|
||||||
|
* @param {string} url - The image url.
|
||||||
|
* @returns {Object} - Returns the converted blob to base64.
|
||||||
|
*/
|
||||||
|
export const toDataURL = async (url: string) => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const resData = await blobToData(blob);
|
||||||
|
|
||||||
|
return resData;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize image and adjust original aspect ratio.
|
||||||
|
*
|
||||||
|
* @param {Object} base64image - Base64 image extraction.
|
||||||
|
* @param {number} width - Value for resizing the image width.
|
||||||
|
* @param {number} height - Value for resizing the image height.
|
||||||
|
* @returns {Object} Returns the canvas output.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export async function resizeImage(base64image: any, width: number = 1920, height: number = 1080) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
|
||||||
|
img.src = base64image;
|
||||||
|
/* eslint-disable no-empty-function */
|
||||||
|
img.onload = await function() {};
|
||||||
|
|
||||||
|
// Create an off-screen canvas.
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Set its dimension to target size.
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Draw source image into the off-screen canvas.
|
||||||
|
// TODO: keep aspect ratio and implement object-fit: cover.
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Encode image to data-uri with base64 version of compressed image.
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.5);
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue