Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

VirtualBackgrounds.tsx 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. // @ts-ignore
  2. import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
  3. // eslint-disable-next-line lines-around-comment
  4. // @ts-ignore
  5. import { safeJsonParse } from '@jitsi/js-utils/json';
  6. import React, { useCallback, useEffect, useState } from 'react';
  7. import { WithTranslation } from 'react-i18next';
  8. import { connect } from 'react-redux';
  9. import { makeStyles } from 'tss-react/mui';
  10. import { IReduxState, IStore } from '../../app/types';
  11. import { translate } from '../../base/i18n/functions';
  12. import Icon from '../../base/icons/components/Icon';
  13. import { IconCloseLarge } from '../../base/icons/svg';
  14. import { withPixelLineHeight } from '../../base/styles/functions.web';
  15. import Tooltip from '../../base/tooltip/components/Tooltip';
  16. import Spinner from '../../base/ui/components/web/Spinner';
  17. import { BACKGROUNDS_LIMIT, IMAGES, type Image, VIRTUAL_BACKGROUND_TYPE } from '../constants';
  18. import { toDataURL } from '../functions';
  19. import logger from '../logger';
  20. import { IVirtualBackground } from '../reducer';
  21. import UploadImageButton from './UploadImageButton';
  22. import VirtualBackgroundPreview from './VirtualBackgroundPreview';
  23. /* eslint-enable lines-around-comment */
  24. interface IProps extends WithTranslation {
  25. /**
  26. * The list of Images to choose from.
  27. */
  28. _images: Array<Image>;
  29. /**
  30. * If the upload button should be displayed or not.
  31. */
  32. _showUploadButton: boolean;
  33. /**
  34. * The redux {@code dispatch} function.
  35. */
  36. dispatch: IStore['dispatch'];
  37. /**
  38. * Options change handler.
  39. */
  40. onOptionsChange: Function;
  41. /**
  42. * Virtual background options.
  43. */
  44. options: IVirtualBackground;
  45. /**
  46. * Returns the selected thumbnail identifier.
  47. */
  48. selectedThumbnail: string;
  49. /**
  50. * The id of the selected video device.
  51. */
  52. selectedVideoInputId: string;
  53. }
  54. const onError = (event: any) => {
  55. event.target.style.display = 'none';
  56. };
  57. const useStyles = makeStyles()(theme => {
  58. return {
  59. virtualBackgroundLoading: {
  60. width: '100%',
  61. display: 'flex',
  62. alignItems: 'center',
  63. justifyContent: 'center',
  64. height: '50px'
  65. },
  66. container: {
  67. width: '100%',
  68. display: 'flex',
  69. flexDirection: 'column'
  70. },
  71. thumbnailContainer: {
  72. width: '100%',
  73. display: 'inline-grid',
  74. gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
  75. gap: theme.spacing(1),
  76. '@media (min-width: 608px) and (max-width: 712px)': {
  77. gridTemplateColumns: '1fr 1fr 1fr 1fr'
  78. },
  79. '@media (max-width: 607px)': {
  80. gridTemplateColumns: '1fr 1fr 1fr',
  81. gap: theme.spacing(2)
  82. }
  83. },
  84. thumbnail: {
  85. height: '54px',
  86. width: '100%',
  87. borderRadius: '4px',
  88. boxSizing: 'border-box',
  89. display: 'flex',
  90. alignItems: 'center',
  91. justifyContent: 'center',
  92. textAlign: 'center',
  93. ...withPixelLineHeight(theme.typography.labelBold),
  94. color: theme.palette.text01,
  95. objectFit: 'cover',
  96. [[ '&:hover', '&:focus' ] as any]: {
  97. opacity: 0.5,
  98. cursor: 'pointer',
  99. '& ~ .delete-image-icon': {
  100. display: 'block'
  101. }
  102. },
  103. '@media (max-width: 607px)': {
  104. height: '70px'
  105. }
  106. },
  107. selectedThumbnail: {
  108. border: `2px solid ${theme.palette.action01Hover}`
  109. },
  110. noneThumbnail: {
  111. backgroundColor: theme.palette.ui04
  112. },
  113. slightBlur: {
  114. boxShadow: 'inset 0 0 12px #000000',
  115. background: '#a4a4a4'
  116. },
  117. blur: {
  118. boxShadow: 'inset 0 0 12px #000000',
  119. background: '#7e8287'
  120. },
  121. storedImageContainer: {
  122. position: 'relative',
  123. display: 'flex',
  124. flexDirection: 'column',
  125. '&:focus-within .delete-image-container': {
  126. display: 'block'
  127. }
  128. },
  129. deleteImageIcon: {
  130. position: 'absolute',
  131. top: '3px',
  132. right: '3px',
  133. background: theme.palette.ui03,
  134. borderRadius: '3px',
  135. cursor: 'pointer',
  136. display: 'none',
  137. '@media (max-width: 607px)': {
  138. display: 'block',
  139. padding: '3px'
  140. },
  141. [[ '&:hover', '&:focus' ] as any]: {
  142. display: 'block'
  143. }
  144. }
  145. };
  146. });
  147. /**
  148. * Renders virtual background dialog.
  149. *
  150. * @returns {ReactElement}
  151. */
  152. function VirtualBackgrounds({
  153. _images,
  154. _showUploadButton,
  155. onOptionsChange,
  156. options,
  157. selectedVideoInputId,
  158. t
  159. }: IProps) {
  160. const { classes, cx } = useStyles();
  161. const [ previewIsLoaded, setPreviewIsLoaded ] = useState(false);
  162. const localImages = jitsiLocalStorage.getItem('virtualBackgrounds');
  163. const [ storedImages, setStoredImages ] = useState<Array<Image>>((localImages && safeJsonParse(localImages)) || []);
  164. const [ loading, setLoading ] = useState(false);
  165. const deleteStoredImage = useCallback(e => {
  166. const imageId = e.currentTarget.getAttribute('data-imageid');
  167. setStoredImages(storedImages.filter(item => item.id !== imageId));
  168. }, [ storedImages ]);
  169. const deleteStoredImageKeyPress = useCallback(e => {
  170. if (e.key === ' ' || e.key === 'Enter') {
  171. e.preventDefault();
  172. deleteStoredImage(e);
  173. }
  174. }, [ deleteStoredImage ]);
  175. /**
  176. * Updates stored images on local storage.
  177. */
  178. useEffect(() => {
  179. try {
  180. jitsiLocalStorage.setItem('virtualBackgrounds', JSON.stringify(storedImages));
  181. } catch (err) {
  182. // Preventing localStorage QUOTA_EXCEEDED_ERR
  183. err && setStoredImages(storedImages.slice(1));
  184. }
  185. if (storedImages.length === BACKGROUNDS_LIMIT) {
  186. setStoredImages(storedImages.slice(1));
  187. }
  188. }, [ storedImages ]);
  189. const enableBlur = useCallback(async () => {
  190. onOptionsChange({
  191. backgroundEffectEnabled: true,
  192. backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
  193. blurValue: 25,
  194. selectedThumbnail: 'blur'
  195. });
  196. logger.info('"Blur" option set for virtual background preview!');
  197. }, []);
  198. const enableBlurKeyPress = useCallback(e => {
  199. if (e.key === ' ' || e.key === 'Enter') {
  200. e.preventDefault();
  201. enableBlur();
  202. }
  203. }, [ enableBlur ]);
  204. const enableSlideBlur = useCallback(async () => {
  205. onOptionsChange({
  206. backgroundEffectEnabled: true,
  207. backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
  208. blurValue: 8,
  209. selectedThumbnail: 'slight-blur'
  210. });
  211. logger.info('"Slight-blur" option set for virtual background preview!');
  212. }, []);
  213. const enableSlideBlurKeyPress = useCallback(e => {
  214. if (e.key === ' ' || e.key === 'Enter') {
  215. e.preventDefault();
  216. enableSlideBlur();
  217. }
  218. }, [ enableSlideBlur ]);
  219. const removeBackground = useCallback(async () => {
  220. onOptionsChange({
  221. backgroundEffectEnabled: false,
  222. selectedThumbnail: 'none'
  223. });
  224. logger.info('"None" option set for virtual background preview!');
  225. }, []);
  226. const removeBackgroundKeyPress = useCallback(e => {
  227. if (e.key === ' ' || e.key === 'Enter') {
  228. e.preventDefault();
  229. removeBackground();
  230. }
  231. }, [ removeBackground ]);
  232. const setUploadedImageBackground = useCallback(async e => {
  233. const imageId = e.currentTarget.getAttribute('data-imageid');
  234. const image = storedImages.find(img => img.id === imageId);
  235. if (image) {
  236. onOptionsChange({
  237. backgroundEffectEnabled: true,
  238. backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
  239. selectedThumbnail: image.id,
  240. virtualSource: image.src
  241. });
  242. logger.info('Uploaded image set for virtual background preview!');
  243. }
  244. }, [ storedImages ]);
  245. const setImageBackground = useCallback(async e => {
  246. const imageId = e.currentTarget.getAttribute('data-imageid');
  247. const image = _images.find(img => img.id === imageId);
  248. if (image) {
  249. try {
  250. const url = await toDataURL(image.src);
  251. onOptionsChange({
  252. backgroundEffectEnabled: true,
  253. backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
  254. selectedThumbnail: image.id,
  255. virtualSource: url
  256. });
  257. logger.info('Image set for virtual background preview!');
  258. } catch (err) {
  259. logger.error('Could not fetch virtual background image:', err);
  260. }
  261. setLoading(false);
  262. }
  263. }, []);
  264. const setImageBackgroundKeyPress = useCallback(e => {
  265. if (e.key === ' ' || e.key === 'Enter') {
  266. e.preventDefault();
  267. setImageBackground(e);
  268. }
  269. }, [ setImageBackground ]);
  270. const setUploadedImageBackgroundKeyPress = useCallback(e => {
  271. if (e.key === ' ' || e.key === 'Enter') {
  272. e.preventDefault();
  273. setUploadedImageBackground(e);
  274. }
  275. }, [ setUploadedImageBackground ]);
  276. const loadedPreviewState = useCallback(async loaded => {
  277. await setPreviewIsLoaded(loaded);
  278. }, []);
  279. // create a full list of {backgroundId: backgroundLabel} to easily retrieve label of selected background
  280. const labelsMap: Record<string, string> = {
  281. none: t('virtualBackground.none'),
  282. 'slight-blur': t('virtualBackground.slightBlur'),
  283. blur: t('virtualBackground.blur'),
  284. ..._images.reduce<Record<string, string>>((acc, image) => {
  285. acc[image.id] = image.tooltip ? t(`virtualBackground.${image.tooltip}`) : '';
  286. return acc;
  287. }, {}),
  288. ...storedImages.reduce<Record<string, string>>((acc, image, index) => {
  289. acc[image.id] = t('virtualBackground.uploadedImage', { index: index + 1 });
  290. return acc;
  291. }, {})
  292. };
  293. const currentBackgroundLabel = options?.selectedThumbnail ? labelsMap[options.selectedThumbnail] : labelsMap.none;
  294. const isThumbnailSelected = useCallback(thumbnail => options?.selectedThumbnail === thumbnail, [ options ]);
  295. const getSelectedThumbnailClass = useCallback(
  296. thumbnail => isThumbnailSelected(thumbnail) && classes.selectedThumbnail, [ isThumbnailSelected, options ]
  297. );
  298. return (
  299. <>
  300. <VirtualBackgroundPreview
  301. loadedPreview = { loadedPreviewState }
  302. options = { options }
  303. selectedVideoInputId = { selectedVideoInputId } />
  304. {loading ? (
  305. <div className = { classes.virtualBackgroundLoading }>
  306. <Spinner />
  307. </div>
  308. ) : (
  309. <div className = { classes.container }>
  310. <span
  311. className = 'sr-only'
  312. id = 'virtual-background-current-info'>
  313. { t('virtualBackground.accessibilityLabel.currentBackground', {
  314. background: currentBackgroundLabel
  315. }) }
  316. </span>
  317. {_showUploadButton
  318. && <UploadImageButton
  319. setLoading = { setLoading }
  320. setOptions = { onOptionsChange }
  321. setStoredImages = { setStoredImages }
  322. showLabel = { previewIsLoaded }
  323. storedImages = { storedImages } />}
  324. <div
  325. aria-describedby = 'virtual-background-current-info'
  326. aria-label = { t('virtualBackground.accessibilityLabel.selectBackground') }
  327. className = { classes.thumbnailContainer }
  328. role = 'radiogroup'
  329. tabIndex = { -1 }>
  330. <Tooltip
  331. content = { t('virtualBackground.removeBackground') }
  332. position = { 'top' }>
  333. <div
  334. aria-checked = { isThumbnailSelected('none') }
  335. aria-label = { t('virtualBackground.removeBackground') }
  336. className = { cx(classes.thumbnail, classes.noneThumbnail,
  337. getSelectedThumbnailClass('none')) }
  338. onClick = { removeBackground }
  339. onKeyPress = { removeBackgroundKeyPress }
  340. role = 'radio'
  341. tabIndex = { 0 } >
  342. {t('virtualBackground.none')}
  343. </div>
  344. </Tooltip>
  345. <Tooltip
  346. content = { t('virtualBackground.slightBlur') }
  347. position = { 'top' }>
  348. <div
  349. aria-checked = { isThumbnailSelected('slight-blur') }
  350. aria-label = { t('virtualBackground.slightBlur') }
  351. className = { cx(classes.thumbnail, classes.slightBlur,
  352. getSelectedThumbnailClass('slight-blur')) }
  353. onClick = { enableSlideBlur }
  354. onKeyPress = { enableSlideBlurKeyPress }
  355. role = 'radio'
  356. tabIndex = { 0 }>
  357. {t('virtualBackground.slightBlur')}
  358. </div>
  359. </Tooltip>
  360. <Tooltip
  361. content = { t('virtualBackground.blur') }
  362. position = { 'top' }>
  363. <div
  364. aria-checked = { isThumbnailSelected('blur') }
  365. aria-label = { t('virtualBackground.blur') }
  366. className = { cx(classes.thumbnail, classes.blur,
  367. getSelectedThumbnailClass('blur')) }
  368. onClick = { enableBlur }
  369. onKeyPress = { enableBlurKeyPress }
  370. role = 'radio'
  371. tabIndex = { 0 }>
  372. {t('virtualBackground.blur')}
  373. </div>
  374. </Tooltip>
  375. {_images.map(image => (
  376. <Tooltip
  377. content = { (image.tooltip && t(`virtualBackground.${image.tooltip}`)) ?? '' }
  378. key = { image.id }
  379. position = { 'top' }>
  380. <img
  381. alt = { image.tooltip && t(`virtualBackground.${image.tooltip}`) }
  382. aria-checked = { isThumbnailSelected(image.id) }
  383. className = { cx(classes.thumbnail,
  384. getSelectedThumbnailClass(image.id)) }
  385. data-imageid = { image.id }
  386. onClick = { setImageBackground }
  387. onError = { onError }
  388. onKeyPress = { setImageBackgroundKeyPress }
  389. role = 'radio'
  390. src = { image.src }
  391. tabIndex = { 0 } />
  392. </Tooltip>
  393. ))}
  394. {storedImages.map((image, index) => (
  395. <div
  396. className = { classes.storedImageContainer }
  397. key = { image.id }>
  398. <img
  399. alt = { t('virtualBackground.uploadedImage', { index: index + 1 }) }
  400. aria-checked = { isThumbnailSelected(image.id) }
  401. className = { cx(classes.thumbnail,
  402. getSelectedThumbnailClass(image.id)) }
  403. data-imageid = { image.id }
  404. onClick = { setUploadedImageBackground }
  405. onError = { onError }
  406. onKeyPress = { setUploadedImageBackgroundKeyPress }
  407. role = 'radio'
  408. src = { image.src }
  409. tabIndex = { 0 } />
  410. <Icon
  411. ariaLabel = { t('virtualBackground.deleteImage') }
  412. className = { cx(classes.deleteImageIcon, 'delete-image-icon') }
  413. data-imageid = { image.id }
  414. onClick = { deleteStoredImage }
  415. onKeyPress = { deleteStoredImageKeyPress }
  416. role = 'button'
  417. size = { 16 }
  418. src = { IconCloseLarge }
  419. tabIndex = { 0 } />
  420. </div>
  421. ))}
  422. </div>
  423. </div>
  424. )}
  425. </>
  426. );
  427. }
  428. /**
  429. * Maps (parts of) the redux state to the associated props for the
  430. * {@code VirtualBackground} component.
  431. *
  432. * @param {Object} state - The Redux state.
  433. * @private
  434. * @returns {{Props}}
  435. */
  436. function _mapStateToProps(state: IReduxState) {
  437. const dynamicBrandingImages = state['features/dynamic-branding'].virtualBackgrounds;
  438. const hasBrandingImages = Boolean(dynamicBrandingImages.length);
  439. return {
  440. _images: (hasBrandingImages && dynamicBrandingImages) || IMAGES,
  441. _showUploadButton: !state['features/base/config'].disableAddingBackgroundImages
  442. };
  443. }
  444. export default connect(_mapStateToProps)(translate(VirtualBackgrounds));