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

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