From 77ee4b13e1aeac2a03dcf34a2b7f2d3accc8864b Mon Sep 17 00:00:00 2001 From: "Tudor D. Pop" Date: Wed, 24 Mar 2021 18:32:45 +0200 Subject: [PATCH] feat(virtual-backgrounds) add ability to upload custom images --- .../_virtual-background.scss | 117 ++++++---- lang/main.json | 5 +- .../JitsiStreamBackgroundEffect.js | 9 +- .../components/VirtualBackgroundDialog.js | 200 +++++++++++++----- .../features/virtual-background/functions.js | 61 +++++- 5 files changed, 301 insertions(+), 91 deletions(-) diff --git a/css/modals/virtual-background/_virtual-background.scss b/css/modals/virtual-background/_virtual-background.scss index 7055c47e2..7ec6aa157 100644 --- a/css/modals/virtual-background/_virtual-background.scss +++ b/css/modals/virtual-background/_virtual-background.scss @@ -1,25 +1,64 @@ -.virtual-background-dialog{ - display: inline-flex; - cursor: pointer; - .thumbnail{ - object-fit: cover; - padding: 5px; - height: 40px; - width: 40px; - } - .thumbnail-selected{ - object-fit: cover; - padding: 5px; - height: 40px; - width: 40px; - border: 2px solid #a4b8d1; - } - .blur-selected{ - border: 2px solid #a4b8d1; - } - .virtual-background-none{ +.virtual-background-dialog { + display: inline-grid; + grid-template-columns: auto auto auto auto auto auto auto; + max-width: 370px; + cursor: pointer; + .thumbnail { + border-radius: 10px; + object-fit: cover; + padding: 5px; + height: 40px; + width: 40px; + } + + .thumbnail:hover ~ .delete-image-icon { + display: block; + } + .thumbnail-selected { + border-radius: 10px; + object-fit: cover; + padding: 5px; + height: 40px; + width: 40px; + border: 2px solid #a4b8d1; + } + .blur-selected { + border-radius: 10px; + border: 2px solid #a4b8d1; + } + .virtual-background-none { + font-weight: bold; + padding: 5px; + height: 34px; + width: 34px; + border-radius: 10px; + border: 1px solid #a4b8d1; + text-align: center; + vertical-align: middle; + line-height: 35px; + margin-right: 5px; + } + .none-selected { + font-weight: bold; + padding: 5px; + height: 34px; + width: 34px; + border-radius: 10px; + border: 2px solid #a4b8d1; + text-align: center; + vertical-align: middle; + line-height: 35px; + margin-right: 5px; + } +} +.file-upload-btn { + display: none; +} +.custom-file-upload { + font-size: x-large; font-weight: bold; - padding: 5px; + display: inline-block; + padding: 4px; height: 35px; width: 35px; border-radius: 10px; @@ -27,18 +66,24 @@ text-align: center; vertical-align: middle; line-height: 35px; - margin-right: 5px; - } - .none-selected{ - font-weight: bold; - padding: 5px; - height: 35px; - width: 35px; - border-radius: 10px; - border: 2px solid #a4b8d1; - text-align: center; - vertical-align: middle; - line-height: 35px; - margin-right: 5px; - } -} \ No newline at end of file + 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; +} diff --git a/lang/main.json b/lang/main.json index 366a0f3c8..07185711a 100644 --- a/lang/main.json +++ b/lang/main.json @@ -339,7 +339,10 @@ "virtualBackground": { "title": "Backgrounds", "enableBlur": "Enable blur", - "removeBackground": "Remove background" + "removeBackground": "Remove background", + "uploadImage": "Upload image", + "pleaseWait": "Please wait...", + "none": "None" }, "feedback": { "average": "Average", diff --git a/react/features/stream-effects/virtual-background/JitsiStreamBackgroundEffect.js b/react/features/stream-effects/virtual-background/JitsiStreamBackgroundEffect.js index e51f9ff4d..ee9e35d50 100644 --- a/react/features/stream-effects/virtual-background/JitsiStreamBackgroundEffect.js +++ b/react/features/stream-effects/virtual-background/JitsiStreamBackgroundEffect.js @@ -5,7 +5,6 @@ import { SET_TIMEOUT, timerWorkerScript } from './TimerWorker'; - const blurValue = '25px'; /** @@ -114,7 +113,13 @@ export default class JitsiStreamBackgroundEffect { this._outputCanvasCtx.globalCompositeOperation = 'destination-over'; 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 { this._outputCanvasCtx.filter = `blur(${blurValue})`; this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0); diff --git a/react/features/virtual-background/components/VirtualBackgroundDialog.js b/react/features/virtual-background/components/VirtualBackgroundDialog.js index 15fee4000..83b30fc69 100644 --- a/react/features/virtual-background/components/VirtualBackgroundDialog.js +++ b/react/features/virtual-background/components/VirtualBackgroundDialog.js @@ -1,36 +1,37 @@ // @flow /* 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 { translate } from '../../base/i18n'; -import { Icon, IconBlurBackground } from '../../base/icons'; +import { Icon, IconBlurBackground, IconCancelSelection } from '../../base/icons'; import { connect } from '../../base/redux'; import { Tooltip } from '../../base/tooltip'; 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 = [ { - tooltip: 'Image 1', - name: 'background-1.jpg', id: 1, src: 'images/virtual-background/background-1.jpg' }, { - tooltip: 'Image 2', - name: 'background-2.jpg', id: 2, src: 'images/virtual-background/background-2.jpg' }, { - tooltip: 'Image 3', - name: 'background-3.jpg', id: 3, src: 'images/virtual-background/background-3.jpg' }, { - tooltip: 'Image 4', - name: 'background-4.jpg', id: 4, src: 'images/virtual-background/background-4.jpg' } @@ -54,23 +55,81 @@ type Props = { * @returns {ReactElement} */ 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 enableBlur = () => { + const enableBlur = async () => { + isloading(true); setSelected('blur'); - dispatch(setVirtualBackground('', false)); - dispatch(toggleBackgroundEffect(true)); + await dispatch(setVirtualBackground('', false)); + await dispatch(toggleBackgroundEffect(true)); + isloading(false); }; - const removeBackground = () => { + const removeBackground = async () => { + isloading(true); setSelected('none'); - dispatch(setVirtualBackground('', false)); - dispatch(toggleBackgroundEffect(false)); + await dispatch(setVirtualBackground('', false)); + await dispatch(toggleBackgroundEffect(false)); + isloading(false); }; - const addImageBackground = image => { + const setUploadedImageBackground = async image => { + isloading(true); setSelected(image.id); - dispatch(setVirtualBackground(image.src, true)); - dispatch(toggleBackgroundEffect(true)); + await dispatch(setVirtualBackground(image.src, 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 ( @@ -79,38 +138,79 @@ function VirtualBackground({ dispatch, t }: Props) { submitDisabled = { false } titleKey = { 'virtualBackground.title' } width = 'small'> -
- -
removeBackground() }> - None + {loading ? ( +
+ {t('virtualBackground.pleaseWait')} + +
+ ) : ( +
+
+ +
+ {t('virtualBackground.none')} +
+
+ + enableBlur() } + size = { 50 } + src = { IconBlurBackground } /> + + {images.map((image, index) => ( + setImageBackground(image) } + onError = { event => event.target.style.display = 'none' } + src = { image.src } /> + ))} + + + uploadImage(e.target.files) } + type = 'file' /> +
- - - enableBlur() } - size = { 50 } - src = { IconBlurBackground } /> - - {images.map((image, index) => ( - - addImageBackground(image) } - onError = { event => event.target.style.display = 'none' } - src = { image.src } /> - - ))} -
+ +
+ {storedImages.map((image, index) => ( +
+ setUploadedImageBackground(image) } + onError = { event => event.target.style.display = 'none' } + src = { image.src } /> + deleteStoredImage(image) } + size = { 15 } + src = { IconCancelSelection } /> +
+ ))} +
+
+ )} ); } diff --git a/react/features/virtual-background/functions.js b/react/features/virtual-background/functions.js index caa786213..b82bc5544 100644 --- a/react/features/virtual-background/functions.js +++ b/react/features/virtual-background/functions.js @@ -1,6 +1,4 @@ // @flow - - let filterSupport; /** @@ -20,3 +18,62 @@ export function checkBlurSupport() { return filterSupport; } + +/** + * Convert blob to base64. + * + * @param {Blob} blob - The link to add info with. + * @returns {Promise} + */ +export const blobToData = (blob: Blob): Promise => 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); +}