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

VirtualBackgrounds.tsx 19KB

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