Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

VirtualBackgrounds.tsx 19KB

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