You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

VirtualBackgroundDialog.js 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. // @flow
  2. import Spinner from '@atlaskit/spinner';
  3. import Bourne from '@hapi/bourne';
  4. import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
  5. import React, { useState, useEffect, useCallback, useRef } from 'react';
  6. import uuid from 'uuid';
  7. import { Dialog, hideDialog } from '../../base/dialog';
  8. import { translate } from '../../base/i18n';
  9. import { Icon, IconCloseSmall, IconPlusCircle, IconShareDesktop } from '../../base/icons';
  10. import { createLocalTrack } from '../../base/lib-jitsi-meet/functions';
  11. import { VIDEO_TYPE } from '../../base/media';
  12. import { connect } from '../../base/redux';
  13. import { Tooltip } from '../../base/tooltip';
  14. import { getLocalVideoTrack } from '../../base/tracks';
  15. import { showErrorNotification } from '../../notifications';
  16. import { toggleBackgroundEffect } from '../actions';
  17. import { VIRTUAL_BACKGROUND_TYPE } from '../constants';
  18. import { resizeImage, toDataURL } from '../functions';
  19. import logger from '../logger';
  20. import VirtualBackgroundPreview from './VirtualBackgroundPreview';
  21. type Image = {
  22. tooltip?: string,
  23. id: string,
  24. src: string
  25. }
  26. // The limit of virtual background uploads is 24. When the number
  27. // of uploads is 25 we trigger the deleteStoredImage function to delete
  28. // the first/oldest uploaded background.
  29. const backgroundsLimit = 25;
  30. const images: Array<Image> = [
  31. {
  32. tooltip: 'image1',
  33. id: '1',
  34. src: 'images/virtual-background/background-1.jpg'
  35. },
  36. {
  37. tooltip: 'image2',
  38. id: '2',
  39. src: 'images/virtual-background/background-2.jpg'
  40. },
  41. {
  42. tooltip: 'image3',
  43. id: '3',
  44. src: 'images/virtual-background/background-3.jpg'
  45. },
  46. {
  47. tooltip: 'image4',
  48. id: '4',
  49. src: 'images/virtual-background/background-4.jpg'
  50. },
  51. {
  52. tooltip: 'image5',
  53. id: '5',
  54. src: 'images/virtual-background/background-5.jpg'
  55. },
  56. {
  57. tooltip: 'image6',
  58. id: '6',
  59. src: 'images/virtual-background/background-6.jpg'
  60. },
  61. {
  62. tooltip: 'image7',
  63. id: '7',
  64. src: 'images/virtual-background/background-7.jpg'
  65. }
  66. ];
  67. type Props = {
  68. /**
  69. * Returns the jitsi track that will have backgraund effect applied.
  70. */
  71. _jitsiTrack: Object,
  72. /**
  73. * Returns the selected thumbnail identifier.
  74. */
  75. _selectedThumbnail: string,
  76. /**
  77. * Returns the selected virtual background object.
  78. */
  79. _virtualBackground: Object,
  80. /**
  81. * The redux {@code dispatch} function.
  82. */
  83. dispatch: Function,
  84. /**
  85. * Invoked to obtain translated strings.
  86. */
  87. t: Function
  88. };
  89. const onError = event => {
  90. event.target.style.display = 'none';
  91. };
  92. /**
  93. * Renders virtual background dialog.
  94. *
  95. * @returns {ReactElement}
  96. */
  97. function VirtualBackground({ _jitsiTrack, _selectedThumbnail, _virtualBackground, dispatch, t }: Props) {
  98. const [ options, setOptions ] = useState({});
  99. const localImages = jitsiLocalStorage.getItem('virtualBackgrounds');
  100. const [ storedImages, setStoredImages ] = useState<Array<Image>>((localImages && Bourne.parse(localImages)) || []);
  101. const [ loading, setLoading ] = useState(false);
  102. const uploadImageButton: Object = useRef(null);
  103. const [ activeDesktopVideo ] = useState(_virtualBackground?.virtualSource?.videoType === VIDEO_TYPE.DESKTOP
  104. ? _virtualBackground.virtualSource
  105. : null);
  106. const [ initialVirtualBackground ] = useState(_virtualBackground);
  107. const deleteStoredImage = useCallback(e => {
  108. const imageId = e.currentTarget.getAttribute('data-imageid');
  109. setStoredImages(storedImages.filter(item => item.id !== imageId));
  110. }, [ storedImages ]);
  111. const deleteStoredImageKeyPress = useCallback(e => {
  112. if (e.key === ' ' || e.key === 'Enter') {
  113. e.preventDefault();
  114. deleteStoredImage(e);
  115. }
  116. }, [ deleteStoredImage ]);
  117. /**
  118. * Updates stored images on local storage.
  119. */
  120. useEffect(() => {
  121. try {
  122. jitsiLocalStorage.setItem('virtualBackgrounds', JSON.stringify(storedImages));
  123. } catch (err) {
  124. // Preventing localStorage QUOTA_EXCEEDED_ERR
  125. err && setStoredImages(storedImages.slice(1));
  126. }
  127. if (storedImages.length === backgroundsLimit) {
  128. setStoredImages(storedImages.slice(1));
  129. }
  130. }, [ storedImages ]);
  131. const enableBlur = useCallback(async () => {
  132. setOptions({
  133. backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
  134. enabled: true,
  135. blurValue: 25,
  136. selectedThumbnail: 'blur'
  137. });
  138. }, []);
  139. const enableBlurKeyPress = useCallback(e => {
  140. if (e.key === ' ' || e.key === 'Enter') {
  141. e.preventDefault();
  142. enableBlur();
  143. }
  144. }, [ enableBlur ]);
  145. const enableSlideBlur = useCallback(async () => {
  146. setOptions({
  147. backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
  148. enabled: true,
  149. blurValue: 8,
  150. selectedThumbnail: 'slight-blur'
  151. });
  152. }, []);
  153. const enableSlideBlurKeyPress = useCallback(e => {
  154. if (e.key === ' ' || e.key === 'Enter') {
  155. e.preventDefault();
  156. enableSlideBlur();
  157. }
  158. }, [ enableSlideBlur ]);
  159. const shareDesktop = useCallback(async () => {
  160. const url = await createLocalTrack('desktop', '');
  161. if (!url) {
  162. dispatch(showErrorNotification({
  163. titleKey: 'virtualBackground.desktopShareError'
  164. }));
  165. logger.error('Could not create desktop share as a virtual background!');
  166. return;
  167. }
  168. setOptions({
  169. backgroundType: VIRTUAL_BACKGROUND_TYPE.DESKTOP_SHARE,
  170. enabled: true,
  171. selectedThumbnail: 'desktop-share',
  172. url
  173. });
  174. }, []);
  175. const shareDesktopKeyPress = useCallback(e => {
  176. if (e.key === ' ' || e.key === 'Enter') {
  177. e.preventDefault();
  178. shareDesktop();
  179. }
  180. }, [ shareDesktop ]);
  181. const removeBackground = useCallback(async () => {
  182. setOptions({
  183. enabled: false,
  184. selectedThumbnail: 'none'
  185. });
  186. }, []);
  187. const removeBackgroundKeyPress = useCallback(e => {
  188. if (e.key === ' ' || e.key === 'Enter') {
  189. e.preventDefault();
  190. removeBackground();
  191. }
  192. }, [ removeBackground ]);
  193. const setUploadedImageBackground = useCallback(async e => {
  194. const imageId = e.currentTarget.getAttribute('data-imageid');
  195. const image = storedImages.find(img => img.id === imageId);
  196. if (image) {
  197. setOptions({
  198. backgroundType: 'image',
  199. enabled: true,
  200. url: image.src,
  201. selectedThumbnail: image.id
  202. });
  203. }
  204. }, [ storedImages ]);
  205. const setImageBackground = useCallback(async e => {
  206. const imageId = e.currentTarget.getAttribute('data-imageid');
  207. const image = images.find(img => img.id === imageId);
  208. if (image) {
  209. const url = await toDataURL(image.src);
  210. setOptions({
  211. backgroundType: 'image',
  212. enabled: true,
  213. url,
  214. selectedThumbnail: image.id
  215. });
  216. setLoading(false);
  217. }
  218. }, []);
  219. const uploadImage = useCallback(async e => {
  220. const reader = new FileReader();
  221. const imageFile = e.target.files;
  222. reader.readAsDataURL(imageFile[0]);
  223. reader.onload = async () => {
  224. const url = await resizeImage(reader.result);
  225. const uuId = uuid.v4();
  226. setStoredImages([
  227. ...storedImages,
  228. {
  229. id: uuId,
  230. src: url
  231. }
  232. ]);
  233. setOptions({
  234. backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
  235. enabled: true,
  236. url,
  237. selectedThumbnail: uuId
  238. });
  239. };
  240. reader.onerror = () => {
  241. setLoading(false);
  242. logger.error('Failed to upload virtual image!');
  243. };
  244. }, [ dispatch, storedImages ]);
  245. const uploadImageKeyPress = useCallback(e => {
  246. if (uploadImageButton.current && (e.key === ' ' || e.key === 'Enter')) {
  247. e.preventDefault();
  248. uploadImageButton.current.click();
  249. }
  250. }, [ uploadImageButton.current ]);
  251. const setImageBackgroundKeyPress = useCallback(e => {
  252. if (e.key === ' ' || e.key === 'Enter') {
  253. e.preventDefault();
  254. setImageBackground(e);
  255. }
  256. }, [ setImageBackground ]);
  257. const setUploadedImageBackgroundKeyPress = useCallback(e => {
  258. if (e.key === ' ' || e.key === 'Enter') {
  259. e.preventDefault();
  260. setUploadedImageBackground(e);
  261. }
  262. }, [ setUploadedImageBackground ]);
  263. const applyVirtualBackground = useCallback(async () => {
  264. if (activeDesktopVideo) {
  265. await activeDesktopVideo.dispose();
  266. }
  267. setLoading(true);
  268. await dispatch(toggleBackgroundEffect(options, _jitsiTrack));
  269. await setLoading(false);
  270. dispatch(hideDialog());
  271. }, [ dispatch, options ]);
  272. // Prevent the selection of a new virtual background if it has not been applied by default
  273. const cancelVirtualBackground = useCallback(async () => {
  274. await setOptions({
  275. backgroundType: initialVirtualBackground.backgroundType,
  276. enabled: initialVirtualBackground.backgroundEffectEnabled,
  277. url: initialVirtualBackground.virtualSource,
  278. selectedThumbnail: initialVirtualBackground.selectedThumbnail,
  279. blurValue: initialVirtualBackground.blurValue
  280. });
  281. dispatch(hideDialog());
  282. });
  283. return (
  284. <Dialog
  285. hideCancelButton = { false }
  286. okKey = { 'virtualBackground.apply' }
  287. onCancel = { cancelVirtualBackground }
  288. onSubmit = { applyVirtualBackground }
  289. submitDisabled = { !options || loading }
  290. titleKey = { 'virtualBackground.title' } >
  291. <VirtualBackgroundPreview options = { options } />
  292. {loading ? (
  293. <div className = 'virtual-background-loading'>
  294. <Spinner
  295. isCompleting = { false }
  296. size = 'medium' />
  297. </div>
  298. ) : (
  299. <div>
  300. <label
  301. aria-label = { t('virtualBackground.uploadImage') }
  302. className = 'file-upload-label'
  303. htmlFor = 'file-upload'
  304. onKeyPress = { uploadImageKeyPress }
  305. tabIndex = { 0 } >
  306. <Icon
  307. className = { 'add-background' }
  308. size = { 20 }
  309. src = { IconPlusCircle } />
  310. {t('virtualBackground.addBackground')}
  311. </label>
  312. <input
  313. accept = 'image/*'
  314. className = 'file-upload-btn'
  315. id = 'file-upload'
  316. onChange = { uploadImage }
  317. ref = { uploadImageButton }
  318. type = 'file' />
  319. <div
  320. className = 'virtual-background-dialog'
  321. role = 'radiogroup'
  322. tabIndex = '-1'>
  323. <Tooltip
  324. content = { t('virtualBackground.removeBackground') }
  325. position = { 'top' }>
  326. <div
  327. aria-checked = { _selectedThumbnail === 'none' }
  328. aria-label = { t('virtualBackground.removeBackground') }
  329. className = { _selectedThumbnail === 'none' ? 'background-option none-selected'
  330. : 'background-option virtual-background-none' }
  331. onClick = { removeBackground }
  332. onKeyPress = { removeBackgroundKeyPress }
  333. role = 'radio'
  334. tabIndex = { 0 } >
  335. {t('virtualBackground.none')}
  336. </div>
  337. </Tooltip>
  338. <Tooltip
  339. content = { t('virtualBackground.slightBlur') }
  340. position = { 'top' }>
  341. <div
  342. aria-checked = { _selectedThumbnail === 'slight-blur' }
  343. aria-label = { t('virtualBackground.slightBlur') }
  344. className = { _selectedThumbnail === 'slight-blur'
  345. ? 'background-option slight-blur-selected' : 'background-option slight-blur' }
  346. onClick = { enableSlideBlur }
  347. onKeyPress = { enableSlideBlurKeyPress }
  348. role = 'radio'
  349. tabIndex = { 0 }>
  350. {t('virtualBackground.slightBlur')}
  351. </div>
  352. </Tooltip>
  353. <Tooltip
  354. content = { t('virtualBackground.blur') }
  355. position = { 'top' }>
  356. <div
  357. aria-checked = { _selectedThumbnail === 'blur' }
  358. aria-label = { t('virtualBackground.blur') }
  359. className = { _selectedThumbnail === 'blur' ? 'background-option blur-selected'
  360. : 'background-option blur' }
  361. onClick = { enableBlur }
  362. onKeyPress = { enableBlurKeyPress }
  363. role = 'radio'
  364. tabIndex = { 0 }>
  365. {t('virtualBackground.blur')}
  366. </div>
  367. </Tooltip>
  368. <Tooltip
  369. content = { t('virtualBackground.desktopShare') }
  370. position = { 'top' }>
  371. <div
  372. aria-checked = { _selectedThumbnail === 'desktop-share' }
  373. aria-label = { t('virtualBackground.desktopShare') }
  374. className = { _selectedThumbnail === 'desktop-share'
  375. ? 'background-option desktop-share-selected'
  376. : 'background-option desktop-share' }
  377. onClick = { shareDesktop }
  378. onKeyPress = { shareDesktopKeyPress }
  379. role = 'radio'
  380. tabIndex = { 0 }>
  381. <Icon
  382. className = 'share-desktop-icon'
  383. size = { 30 }
  384. src = { IconShareDesktop } />
  385. </div>
  386. </Tooltip>
  387. {images.map(image => (
  388. <Tooltip
  389. content = { image.tooltip && t(`virtualBackground.${image.tooltip}`) }
  390. key = { image.id }
  391. position = { 'top' }>
  392. <img
  393. alt = { image.tooltip && t(`virtualBackground.${image.tooltip}`) }
  394. aria-checked = { options.selectedThumbnail === image.id
  395. || _selectedThumbnail === image.id }
  396. className = {
  397. options.selectedThumbnail === image.id || _selectedThumbnail === image.id
  398. ? 'background-option thumbnail-selected' : 'background-option thumbnail' }
  399. data-imageid = { image.id }
  400. onClick = { setImageBackground }
  401. onError = { onError }
  402. onKeyPress = { setImageBackgroundKeyPress }
  403. role = 'radio'
  404. src = { image.src }
  405. tabIndex = { 0 } />
  406. </Tooltip>
  407. ))}
  408. {storedImages.map((image, index) => (
  409. <div
  410. className = { 'thumbnail-container' }
  411. key = { image.id }>
  412. <img
  413. alt = { t('virtualBackground.uploadedImage', { index: index + 1 }) }
  414. aria-checked = { _selectedThumbnail === image.id }
  415. className = { _selectedThumbnail === image.id
  416. ? 'background-option thumbnail-selected' : 'background-option thumbnail' }
  417. data-imageid = { image.id }
  418. onClick = { setUploadedImageBackground }
  419. onError = { onError }
  420. onKeyPress = { setUploadedImageBackgroundKeyPress }
  421. role = 'radio'
  422. src = { image.src }
  423. tabIndex = { 0 } />
  424. <Icon
  425. ariaLabel = { t('virtualBackground.deleteImage') }
  426. className = { 'delete-image-icon' }
  427. data-imageid = { image.id }
  428. onClick = { deleteStoredImage }
  429. onKeyPress = { deleteStoredImageKeyPress }
  430. role = 'button'
  431. size = { 15 }
  432. src = { IconCloseSmall }
  433. tabIndex = { 0 } />
  434. </div>
  435. ))}
  436. </div>
  437. </div>
  438. )}
  439. </Dialog>
  440. );
  441. }
  442. /**
  443. * Maps (parts of) the redux state to the associated props for the
  444. * {@code VirtualBackground} component.
  445. *
  446. * @param {Object} state - The Redux state.
  447. * @private
  448. * @returns {{Props}}
  449. */
  450. function _mapStateToProps(state): Object {
  451. return {
  452. _virtualBackground: state['features/virtual-background'],
  453. _selectedThumbnail: state['features/virtual-background'].selectedThumbnail,
  454. _jitsiTrack: getLocalVideoTrack(state['features/base/tracks'])?.jitsiTrack
  455. };
  456. }
  457. export default translate(connect(_mapStateToProps)(VirtualBackground));