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 21KB

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