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.

LargeVideoManager.js 24KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. /* global $, APP */
  2. /* eslint-disable no-unused-vars */
  3. import Logger from '@jitsi/logger';
  4. import React from 'react';
  5. import ReactDOM from 'react-dom';
  6. import { I18nextProvider } from 'react-i18next';
  7. import { Provider } from 'react-redux';
  8. import { createScreenSharingIssueEvent, sendAnalytics } from '../../../react/features/analytics';
  9. import { Avatar } from '../../../react/features/base/avatar';
  10. import theme from '../../../react/features/base/components/themes/participantsPaneTheme.json';
  11. import { getSourceNameSignalingFeatureFlag } from '../../../react/features/base/config';
  12. import { i18next } from '../../../react/features/base/i18n';
  13. import { VIDEO_TYPE } from '../../../react/features/base/media';
  14. import {
  15. getParticipantById,
  16. getParticipantDisplayName
  17. } from '../../../react/features/base/participants';
  18. import {
  19. getVideoTrackByParticipant
  20. } from '../../../react/features/base/tracks';
  21. import { CHAT_SIZE } from '../../../react/features/chat';
  22. import {
  23. isParticipantConnectionStatusActive,
  24. isParticipantConnectionStatusInactive,
  25. isParticipantConnectionStatusInterrupted,
  26. isTrackStreamingStatusActive,
  27. isTrackStreamingStatusInactive,
  28. isTrackStreamingStatusInterrupted
  29. } from '../../../react/features/connection-indicator/functions';
  30. import { FILMSTRIP_BREAKPOINT, isFilmstripResizable, getVerticalViewMaxWidth } from '../../../react/features/filmstrip';
  31. import {
  32. updateKnownLargeVideoResolution
  33. } from '../../../react/features/large-video/actions';
  34. import { getParticipantsPaneOpen } from '../../../react/features/participants-pane/functions';
  35. import { PresenceLabel } from '../../../react/features/presence-status';
  36. import { shouldDisplayTileView } from '../../../react/features/video-layout';
  37. /* eslint-enable no-unused-vars */
  38. import { createDeferred } from '../../util/helpers';
  39. import AudioLevels from '../audio_levels/AudioLevels';
  40. import { VideoContainer, VIDEO_CONTAINER_TYPE } from './VideoContainer';
  41. const logger = Logger.getLogger(__filename);
  42. const DESKTOP_CONTAINER_TYPE = 'desktop';
  43. /**
  44. * Manager for all Large containers.
  45. */
  46. export default class LargeVideoManager {
  47. /**
  48. * Checks whether given container is a {@link VIDEO_CONTAINER_TYPE}.
  49. * FIXME currently this is a workaround for the problem where video type is
  50. * mixed up with container type.
  51. * @param {string} containerType
  52. * @return {boolean}
  53. */
  54. static isVideoContainer(containerType) {
  55. return containerType === VIDEO_CONTAINER_TYPE
  56. || containerType === DESKTOP_CONTAINER_TYPE;
  57. }
  58. /**
  59. *
  60. */
  61. constructor() {
  62. /**
  63. * The map of <tt>LargeContainer</tt>s where the key is the video
  64. * container type.
  65. * @type {Object.<string, LargeContainer>}
  66. */
  67. this.containers = {};
  68. this.state = VIDEO_CONTAINER_TYPE;
  69. // FIXME: We are passing resizeContainer as parameter which is calling
  70. // Container.resize. Probably there's better way to implement this.
  71. this.videoContainer = new VideoContainer(() => this.resizeContainer(VIDEO_CONTAINER_TYPE));
  72. this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer);
  73. // use the same video container to handle desktop tracks
  74. this.addContainer(DESKTOP_CONTAINER_TYPE, this.videoContainer);
  75. /**
  76. * The preferred width passed as an argument to {@link updateContainerSize}.
  77. *
  78. * @type {number|undefined}
  79. */
  80. this.preferredWidth = undefined;
  81. /**
  82. * The preferred height passed as an argument to {@link updateContainerSize}.
  83. *
  84. * @type {number|undefined}
  85. */
  86. this.preferredHeight = undefined;
  87. /**
  88. * The calculated width that will be used for the large video.
  89. * @type {number}
  90. */
  91. this.width = 0;
  92. /**
  93. * The calculated height that will be used for the large video.
  94. * @type {number}
  95. */
  96. this.height = 0;
  97. /**
  98. * Cache the aspect ratio of the video displayed to detect changes to
  99. * the aspect ratio on video resize events.
  100. *
  101. * @type {number}
  102. */
  103. this._videoAspectRatio = 0;
  104. this.$container = $('#largeVideoContainer');
  105. this.$container.css({
  106. display: 'inline-block'
  107. });
  108. this.$container.hover(
  109. e => this.onHoverIn(e),
  110. e => this.onHoverOut(e)
  111. );
  112. // Bind event handler so it is only bound once for every instance.
  113. this._onVideoResolutionUpdate
  114. = this._onVideoResolutionUpdate.bind(this);
  115. this.videoContainer.addResizeListener(this._onVideoResolutionUpdate);
  116. this._dominantSpeakerAvatarContainer
  117. = document.getElementById('dominantSpeakerAvatarContainer');
  118. }
  119. /**
  120. * Removes any listeners registered on child components, including
  121. * React Components.
  122. *
  123. * @returns {void}
  124. */
  125. destroy() {
  126. this.videoContainer.removeResizeListener(
  127. this._onVideoResolutionUpdate);
  128. this.removePresenceLabel();
  129. ReactDOM.unmountComponentAtNode(this._dominantSpeakerAvatarContainer);
  130. this.$container.css({ display: 'none' });
  131. }
  132. /**
  133. *
  134. */
  135. onHoverIn(e) {
  136. if (!this.state) {
  137. return;
  138. }
  139. const container = this.getCurrentContainer();
  140. container.onHoverIn(e);
  141. }
  142. /**
  143. *
  144. */
  145. onHoverOut(e) {
  146. if (!this.state) {
  147. return;
  148. }
  149. const container = this.getCurrentContainer();
  150. container.onHoverOut(e);
  151. }
  152. /**
  153. *
  154. */
  155. get id() {
  156. const container = this.getCurrentContainer();
  157. // If a user switch for large video is in progress then provide what
  158. // will be the end result of the update.
  159. if (this.updateInProcess
  160. && this.newStreamData
  161. && this.newStreamData.id !== container.id) {
  162. return this.newStreamData.id;
  163. }
  164. return container.id;
  165. }
  166. /**
  167. *
  168. */
  169. scheduleLargeVideoUpdate() {
  170. if (this.updateInProcess || !this.newStreamData) {
  171. return;
  172. }
  173. this.updateInProcess = true;
  174. // Include hide()/fadeOut only if we're switching between users
  175. // eslint-disable-next-line eqeqeq
  176. const container = this.getCurrentContainer();
  177. const isUserSwitch = this.newStreamData.id !== container.id;
  178. const preUpdate = isUserSwitch ? container.hide() : Promise.resolve();
  179. preUpdate.then(() => {
  180. const { id, stream, videoType, resolve } = this.newStreamData;
  181. // FIXME this does not really make sense, because the videoType
  182. // (camera or desktop) is a completely different thing than
  183. // the video container type (Etherpad, SharedVideo, VideoContainer).
  184. const isVideoContainer = LargeVideoManager.isVideoContainer(videoType);
  185. this.newStreamData = null;
  186. logger.info(`hover in ${id}`);
  187. this.state = videoType;
  188. // eslint-disable-next-line no-shadow
  189. const container = this.getCurrentContainer();
  190. container.setStream(id, stream, videoType);
  191. // change the avatar url on large
  192. this.updateAvatar();
  193. const isVideoMuted = !stream || stream.isMuted();
  194. const state = APP.store.getState();
  195. const participant = getParticipantById(state, id);
  196. const connectionStatus = participant?.connectionStatus;
  197. let isVideoRenderable;
  198. if (getSourceNameSignalingFeatureFlag(state)) {
  199. const tracks = state['features/base/tracks'];
  200. const videoTrack = getVideoTrackByParticipant(tracks, participant);
  201. isVideoRenderable = !isVideoMuted && (
  202. APP.conference.isLocalId(id)
  203. || participant?.isLocalScreenShare
  204. || isTrackStreamingStatusActive(videoTrack)
  205. );
  206. } else {
  207. isVideoRenderable = !isVideoMuted
  208. && (APP.conference.isLocalId(id) || isParticipantConnectionStatusActive(participant));
  209. }
  210. const isAudioOnly = APP.conference.isAudioOnly();
  211. const showAvatar
  212. = isVideoContainer
  213. && ((isAudioOnly && videoType !== VIDEO_TYPE.DESKTOP) || !isVideoRenderable);
  214. let promise;
  215. // do not show stream if video is muted
  216. // but we still should show watermark
  217. if (showAvatar) {
  218. this.showWatermark(true);
  219. // If the intention of this switch is to show the avatar
  220. // we need to make sure that the video is hidden
  221. promise = container.hide();
  222. if ((!shouldDisplayTileView(state) || participant?.pinned) // In theory the tile view may not be
  223. // enabled yet when we auto pin the participant.
  224. && participant && !participant.local && !participant.isFakeParticipant) {
  225. // remote participant only
  226. const tracks = state['features/base/tracks'];
  227. const track = getVideoTrackByParticipant(tracks, participant);
  228. const isScreenSharing = track?.videoType === 'desktop';
  229. if (isScreenSharing) {
  230. // send the event
  231. sendAnalytics(createScreenSharingIssueEvent({
  232. source: 'large-video',
  233. connectionStatus,
  234. isVideoMuted,
  235. isAudioOnly,
  236. isVideoContainer,
  237. videoType
  238. }));
  239. }
  240. }
  241. } else {
  242. promise = container.show();
  243. }
  244. // show the avatar on large if needed
  245. container.showAvatar(showAvatar);
  246. // Clean up audio level after previous speaker.
  247. if (showAvatar) {
  248. this.updateLargeVideoAudioLevel(0);
  249. }
  250. let messageKey;
  251. if (getSourceNameSignalingFeatureFlag(state)) {
  252. const tracks = state['features/base/tracks'];
  253. const videoTrack = getVideoTrackByParticipant(tracks, participant);
  254. messageKey = isTrackStreamingStatusInactive(videoTrack) ? 'connection.LOW_BANDWIDTH' : null;
  255. } else {
  256. messageKey = isParticipantConnectionStatusInactive(participant) ? 'connection.LOW_BANDWIDTH' : null;
  257. }
  258. // Do not show connection status message in the audio only mode,
  259. // because it's based on the video playback status.
  260. const overrideAndHide = APP.conference.isAudioOnly();
  261. this.updateParticipantConnStatusIndication(
  262. id,
  263. !overrideAndHide && messageKey);
  264. // Change the participant id the presence label is listening to.
  265. this.updatePresenceLabel(id);
  266. this.videoContainer.positionRemoteStatusMessages();
  267. // resolve updateLargeVideo promise after everything is done
  268. promise.then(resolve);
  269. return promise;
  270. }).then(() => {
  271. // after everything is done check again if there are any pending
  272. // new streams.
  273. this.updateInProcess = false;
  274. this.scheduleLargeVideoUpdate();
  275. });
  276. }
  277. /**
  278. * Shows/hides notification about participant's connectivity issues to be
  279. * shown on the large video area.
  280. *
  281. * @param {string} id the id of remote participant(MUC nickname)
  282. * @param {string|null} messageKey the i18n key of the message which will be
  283. * displayed on the large video or <tt>null</tt> to hide it.
  284. *
  285. * @private
  286. */
  287. updateParticipantConnStatusIndication(id, messageKey) {
  288. const state = APP.store.getState();
  289. if (messageKey) {
  290. // Get user's display name
  291. const displayName
  292. = getParticipantDisplayName(state, id);
  293. this._setRemoteConnectionMessage(
  294. messageKey,
  295. { displayName });
  296. // Show it now only if the VideoContainer is on top
  297. this.showRemoteConnectionMessage(
  298. LargeVideoManager.isVideoContainer(this.state));
  299. } else {
  300. // Hide the message
  301. this.showRemoteConnectionMessage(false);
  302. }
  303. }
  304. /**
  305. * Update large video.
  306. * Switches to large video even if previously other container was visible.
  307. * @param userID the userID of the participant associated with the stream
  308. * @param {JitsiTrack?} stream new stream
  309. * @param {string?} videoType new video type
  310. * @returns {Promise}
  311. */
  312. updateLargeVideo(userID, stream, videoType) {
  313. if (this.newStreamData) {
  314. this.newStreamData.reject();
  315. }
  316. this.newStreamData = createDeferred();
  317. this.newStreamData.id = userID;
  318. this.newStreamData.stream = stream;
  319. this.newStreamData.videoType = videoType;
  320. this.scheduleLargeVideoUpdate();
  321. return this.newStreamData.promise;
  322. }
  323. /**
  324. * Update container size.
  325. */
  326. updateContainerSize(width, height) {
  327. if (typeof width === 'number') {
  328. this.preferredWidth = width;
  329. }
  330. if (typeof height === 'number') {
  331. this.preferredHeight = height;
  332. }
  333. let widthToUse = this.preferredWidth || window.innerWidth;
  334. const state = APP.store.getState();
  335. const { isOpen } = state['features/chat'];
  336. const { width: filmstripWidth, visible } = state['features/filmstrip'];
  337. const isParticipantsPaneOpen = getParticipantsPaneOpen(state);
  338. const resizableFilmstrip = isFilmstripResizable(state);
  339. if (isParticipantsPaneOpen) {
  340. widthToUse -= theme.participantsPaneWidth;
  341. }
  342. if (isOpen && window.innerWidth > 580) {
  343. /**
  344. * If chat state is open, we re-compute the container width
  345. * by subtracting the default width of the chat.
  346. */
  347. widthToUse -= CHAT_SIZE;
  348. }
  349. if (resizableFilmstrip && visible && filmstripWidth.current >= FILMSTRIP_BREAKPOINT) {
  350. widthToUse -= getVerticalViewMaxWidth(state);
  351. }
  352. this.width = widthToUse;
  353. this.height = this.preferredHeight || window.innerHeight;
  354. }
  355. /**
  356. * Resize Large container of specified type.
  357. * @param {string} type type of container which should be resized.
  358. * @param {boolean} [animate=false] if resize process should be animated.
  359. */
  360. resizeContainer(type, animate = false) {
  361. const container = this.getContainer(type);
  362. container.resize(this.width, this.height, animate);
  363. }
  364. /**
  365. * Resize all Large containers.
  366. * @param {boolean} animate if resize process should be animated.
  367. */
  368. resize(animate) {
  369. // resize all containers
  370. Object.keys(this.containers)
  371. .forEach(type => this.resizeContainer(type, animate));
  372. }
  373. /**
  374. * Updates the src of the dominant speaker avatar
  375. */
  376. updateAvatar() {
  377. ReactDOM.render(
  378. <Provider store = { APP.store }>
  379. <Avatar
  380. id = "dominantSpeakerAvatar"
  381. participantId = { this.id }
  382. size = { 200 } />
  383. </Provider>,
  384. this._dominantSpeakerAvatarContainer
  385. );
  386. }
  387. /**
  388. * Updates the audio level indicator of the large video.
  389. *
  390. * @param lvl the new audio level to set
  391. */
  392. updateLargeVideoAudioLevel(lvl) {
  393. AudioLevels.updateLargeVideoAudioLevel('dominantSpeaker', lvl);
  394. }
  395. /**
  396. * Displays a message of the passed in participant id's presence status. The
  397. * message will not display if the remote connection message is displayed.
  398. *
  399. * @param {string} id - The participant ID whose associated user's presence
  400. * status should be displayed.
  401. * @returns {void}
  402. */
  403. updatePresenceLabel(id) {
  404. const isConnectionMessageVisible
  405. = $('#remoteConnectionMessage').is(':visible');
  406. if (isConnectionMessageVisible) {
  407. this.removePresenceLabel();
  408. return;
  409. }
  410. const presenceLabelContainer = $('#remotePresenceMessage');
  411. if (presenceLabelContainer.length) {
  412. ReactDOM.render(
  413. <Provider store = { APP.store }>
  414. <I18nextProvider i18n = { i18next }>
  415. <PresenceLabel
  416. participantID = { id }
  417. className = 'presence-label' />
  418. </I18nextProvider>
  419. </Provider>,
  420. presenceLabelContainer.get(0));
  421. }
  422. }
  423. /**
  424. * Removes the messages about the displayed participant's presence status.
  425. *
  426. * @returns {void}
  427. */
  428. removePresenceLabel() {
  429. const presenceLabelContainer = $('#remotePresenceMessage');
  430. if (presenceLabelContainer.length) {
  431. ReactDOM.unmountComponentAtNode(presenceLabelContainer.get(0));
  432. }
  433. }
  434. /**
  435. * Show or hide watermark.
  436. * @param {boolean} show
  437. */
  438. showWatermark(show) {
  439. $('.watermark').css('visibility', show ? 'visible' : 'hidden');
  440. }
  441. /**
  442. * Shows hides the "avatar" message which is to be displayed either in
  443. * the middle of the screen or below the avatar image.
  444. *
  445. * @param {boolean|undefined} [show=undefined] <tt>true</tt> to show
  446. * the avatar message or <tt>false</tt> to hide it. If not provided then
  447. * the connection status of the user currently on the large video will be
  448. * obtained form "APP.conference" and the message will be displayed if
  449. * the user's connection is either interrupted or inactive.
  450. */
  451. showRemoteConnectionMessage(show) {
  452. if (typeof show !== 'boolean') {
  453. const participant = getParticipantById(APP.store.getState(), this.id);
  454. const state = APP.store.getState();
  455. if (getSourceNameSignalingFeatureFlag(state)) {
  456. const tracks = state['features/base/tracks'];
  457. const videoTrack = getVideoTrackByParticipant(tracks, participant);
  458. // eslint-disable-next-line no-param-reassign
  459. show = !APP.conference.isLocalId(this.id)
  460. && (isTrackStreamingStatusInterrupted(videoTrack)
  461. || isTrackStreamingStatusInactive(videoTrack));
  462. } else {
  463. // eslint-disable-next-line no-param-reassign
  464. show = !APP.conference.isLocalId(this.id)
  465. && (isParticipantConnectionStatusInterrupted(participant)
  466. || isParticipantConnectionStatusInactive(participant));
  467. }
  468. }
  469. if (show) {
  470. $('#remoteConnectionMessage').css({ display: 'block' });
  471. } else {
  472. $('#remoteConnectionMessage').hide();
  473. }
  474. }
  475. /**
  476. * Updates the text which describes that the remote user is having
  477. * connectivity issues.
  478. *
  479. * @param {string} msgKey the translation key which will be used to get
  480. * the message text.
  481. * @param {object} msgOptions translation options object.
  482. *
  483. * @private
  484. */
  485. _setRemoteConnectionMessage(msgKey, msgOptions) {
  486. if (msgKey) {
  487. $('#remoteConnectionMessage')
  488. .attr('data-i18n', msgKey)
  489. .attr('data-i18n-options', JSON.stringify(msgOptions));
  490. APP.translation.translateElement(
  491. $('#remoteConnectionMessage'), msgOptions);
  492. }
  493. }
  494. /**
  495. * Add container of specified type.
  496. * @param {string} type container type
  497. * @param {LargeContainer} container container to add.
  498. */
  499. addContainer(type, container) {
  500. if (this.containers[type]) {
  501. throw new Error(`container of type ${type} already exist`);
  502. }
  503. this.containers[type] = container;
  504. this.resizeContainer(type);
  505. }
  506. /**
  507. * Get Large container of specified type.
  508. * @param {string} type container type.
  509. * @returns {LargeContainer}
  510. */
  511. getContainer(type) {
  512. const container = this.containers[type];
  513. if (!container) {
  514. throw new Error(`container of type ${type} doesn't exist`);
  515. }
  516. return container;
  517. }
  518. /**
  519. * Returns {@link LargeContainer} for the current {@link state}
  520. *
  521. * @return {LargeContainer}
  522. *
  523. * @throws an <tt>Error</tt> if there is no container for the current
  524. * {@link state}.
  525. */
  526. getCurrentContainer() {
  527. return this.getContainer(this.state);
  528. }
  529. /**
  530. * Returns type of the current {@link LargeContainer}
  531. * @return {string}
  532. */
  533. getCurrentContainerType() {
  534. return this.state;
  535. }
  536. /**
  537. * Remove Large container of specified type.
  538. * @param {string} type container type.
  539. */
  540. removeContainer(type) {
  541. if (!this.containers[type]) {
  542. throw new Error(`container of type ${type} doesn't exist`);
  543. }
  544. delete this.containers[type];
  545. }
  546. /**
  547. * Show Large container of specified type.
  548. * Does nothing if such container is already visible.
  549. * @param {string} type container type.
  550. * @returns {Promise}
  551. */
  552. showContainer(type) {
  553. if (this.state === type) {
  554. return Promise.resolve();
  555. }
  556. const oldContainer = this.containers[this.state];
  557. // FIXME when video is being replaced with other content we need to hide
  558. // companion icons/messages. It would be best if the container would
  559. // be taking care of it by itself, but that is a bigger refactoring
  560. if (LargeVideoManager.isVideoContainer(this.state)) {
  561. this.showWatermark(false);
  562. this.showRemoteConnectionMessage(false);
  563. }
  564. oldContainer.hide();
  565. this.state = type;
  566. const container = this.getContainer(type);
  567. return container.show().then(() => {
  568. if (LargeVideoManager.isVideoContainer(type)) {
  569. // FIXME when video appears on top of other content we need to
  570. // show companion icons/messages. It would be best if
  571. // the container would be taking care of it by itself, but that
  572. // is a bigger refactoring
  573. this.showWatermark(true);
  574. // "avatar" and "video connection" can not be displayed both
  575. // at the same time, but the latter is of higher priority and it
  576. // will hide the avatar one if will be displayed.
  577. this.showRemoteConnectionMessage(/* fetch the current state */);
  578. }
  579. });
  580. }
  581. /**
  582. * Changes the flipX state of the local video.
  583. * @param val {boolean} true if flipped.
  584. */
  585. onLocalFlipXChange(val) {
  586. this.videoContainer.setLocalFlipX(val);
  587. }
  588. /**
  589. * Dispatches an action to update the known resolution state of the large video and adjusts container sizes when the
  590. * resolution changes.
  591. *
  592. * @private
  593. * @returns {void}
  594. */
  595. _onVideoResolutionUpdate() {
  596. const { height, width } = this.videoContainer.getStreamSize();
  597. const { resolution } = APP.store.getState()['features/large-video'];
  598. if (height !== resolution) {
  599. APP.store.dispatch(updateKnownLargeVideoResolution(height));
  600. }
  601. const currentAspectRatio = height === 0 ? 0 : width / height;
  602. if (this._videoAspectRatio !== currentAspectRatio) {
  603. this._videoAspectRatio = currentAspectRatio;
  604. this.resize();
  605. }
  606. }
  607. }