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.

SharedVideo.js 21KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. /* global $, APP, YT, interfaceConfig, onPlayerReady, onPlayerStateChange,
  2. onPlayerError */
  3. import Logger from 'jitsi-meet-logger';
  4. import {
  5. createSharedVideoEvent as createEvent,
  6. sendAnalytics
  7. } from '../../../react/features/analytics';
  8. import {
  9. participantJoined,
  10. participantLeft,
  11. pinParticipant
  12. } from '../../../react/features/base/participants';
  13. import { VIDEO_PLAYER_PARTICIPANT_NAME } from '../../../react/features/shared-video/constants';
  14. import { dockToolbox, showToolbox } from '../../../react/features/toolbox/actions.web';
  15. import { getToolboxHeight } from '../../../react/features/toolbox/functions.web';
  16. import UIEvents from '../../../service/UI/UIEvents';
  17. import Filmstrip from '../videolayout/Filmstrip';
  18. import LargeContainer from '../videolayout/LargeContainer';
  19. import VideoLayout from '../videolayout/VideoLayout';
  20. const logger = Logger.getLogger(__filename);
  21. export const SHARED_VIDEO_CONTAINER_TYPE = 'sharedvideo';
  22. /**
  23. * Example shared video link.
  24. * @type {string}
  25. */
  26. const updateInterval = 5000; // milliseconds
  27. /**
  28. * Manager of shared video.
  29. */
  30. export default class SharedVideoManager {
  31. /**
  32. *
  33. */
  34. constructor(emitter) {
  35. this.emitter = emitter;
  36. this.isSharedVideoShown = false;
  37. this.isPlayerAPILoaded = false;
  38. this.mutedWithUserInteraction = false;
  39. }
  40. /**
  41. * Indicates if the player volume is currently on. This will return true if
  42. * we have an available player, which is currently in a PLAYING state,
  43. * which isn't muted and has it's volume greater than 0.
  44. *
  45. * @returns {boolean} indicating if the volume of the shared video is
  46. * currently on.
  47. */
  48. isSharedVideoVolumeOn() {
  49. return this.player
  50. && this.player.getPlayerState() === YT.PlayerState.PLAYING
  51. && !this.player.isMuted()
  52. && this.player.getVolume() > 0;
  53. }
  54. /**
  55. * Indicates if the local user is the owner of the shared video.
  56. * @returns {*|boolean}
  57. */
  58. isSharedVideoOwner() {
  59. return this.from && APP.conference.isLocalId(this.from);
  60. }
  61. /**
  62. * Start shared video event emitter if a video is not shown.
  63. *
  64. * @param url of the video
  65. */
  66. startSharedVideoEmitter(url) {
  67. if (!this.isSharedVideoShown) {
  68. if (url) {
  69. this.emitter.emit(
  70. UIEvents.UPDATE_SHARED_VIDEO, url, 'start');
  71. sendAnalytics(createEvent('started'));
  72. }
  73. logger.log('SHARED VIDEO CANCELED');
  74. sendAnalytics(createEvent('canceled'));
  75. }
  76. }
  77. /**
  78. * Stop shared video event emitter done by the one who shared the video.
  79. */
  80. stopSharedVideoEmitter() {
  81. if (APP.conference.isLocalId(this.from)) {
  82. if (this.intervalId) {
  83. clearInterval(this.intervalId);
  84. this.intervalId = null;
  85. }
  86. this.emitter.emit(
  87. UIEvents.UPDATE_SHARED_VIDEO, this.url, 'stop');
  88. sendAnalytics(createEvent('stopped'));
  89. }
  90. }
  91. /**
  92. * Shows the player component and starts the process that will be sending
  93. * updates, if we are the one shared the video.
  94. *
  95. * @param id the id of the sender of the command
  96. * @param url the video url
  97. * @param attributes
  98. */
  99. onSharedVideoStart(id, url, attributes) {
  100. if (this.isSharedVideoShown) {
  101. return;
  102. }
  103. this.isSharedVideoShown = true;
  104. // the video url
  105. this.url = url;
  106. // the owner of the video
  107. this.from = id;
  108. this.mutedWithUserInteraction = APP.conference.isLocalAudioMuted();
  109. // listen for local audio mute events
  110. this.localAudioMutedListener = this.onLocalAudioMuted.bind(this);
  111. this.emitter.on(UIEvents.AUDIO_MUTED, this.localAudioMutedListener);
  112. // This code loads the IFrame Player API code asynchronously.
  113. const tag = document.createElement('script');
  114. tag.src = 'https://www.youtube.com/iframe_api';
  115. const firstScriptTag = document.getElementsByTagName('script')[0];
  116. firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
  117. // sometimes we receive errors like player not defined
  118. // or player.pauseVideo is not a function
  119. // we need to operate with player after start playing
  120. // self.player will be defined once it start playing
  121. // and will process any initial attributes if any
  122. this.initialAttributes = attributes;
  123. const self = this;
  124. if (self.isPlayerAPILoaded) {
  125. window.onYouTubeIframeAPIReady();
  126. } else {
  127. window.onYouTubeIframeAPIReady = function() {
  128. self.isPlayerAPILoaded = true;
  129. const showControls
  130. = APP.conference.isLocalId(self.from) ? 1 : 0;
  131. const p = new YT.Player('sharedVideoIFrame', {
  132. height: '100%',
  133. width: '100%',
  134. videoId: self.url,
  135. playerVars: {
  136. 'origin': location.origin,
  137. 'fs': '0',
  138. 'autoplay': 0,
  139. 'controls': showControls,
  140. 'rel': 0
  141. },
  142. events: {
  143. 'onReady': onPlayerReady,
  144. 'onStateChange': onPlayerStateChange,
  145. 'onError': onPlayerError
  146. }
  147. });
  148. // add listener for volume changes
  149. p.addEventListener(
  150. 'onVolumeChange', 'onVolumeChange');
  151. if (APP.conference.isLocalId(self.from)) {
  152. // adds progress listener that will be firing events
  153. // while we are paused and we change the progress of the
  154. // video (seeking forward or backward on the video)
  155. p.addEventListener(
  156. 'onVideoProgress', 'onVideoProgress');
  157. }
  158. };
  159. }
  160. /**
  161. * Indicates that a change in state has occurred for the shared video.
  162. * @param event the event notifying us of the change
  163. */
  164. window.onPlayerStateChange = function(event) {
  165. // eslint-disable-next-line eqeqeq
  166. if (event.data == YT.PlayerState.PLAYING) {
  167. self.player = event.target;
  168. if (self.initialAttributes) {
  169. // If a network update has occurred already now is the
  170. // time to process it.
  171. self.processVideoUpdate(
  172. self.player,
  173. self.initialAttributes);
  174. self.initialAttributes = null;
  175. }
  176. self.smartAudioMute();
  177. // eslint-disable-next-line eqeqeq
  178. } else if (event.data == YT.PlayerState.PAUSED) {
  179. self.smartAudioUnmute();
  180. sendAnalytics(createEvent('paused'));
  181. }
  182. // eslint-disable-next-line eqeqeq
  183. self.fireSharedVideoEvent(event.data == YT.PlayerState.PAUSED);
  184. };
  185. /**
  186. * Track player progress while paused.
  187. * @param event
  188. */
  189. window.onVideoProgress = function(event) {
  190. const state = event.target.getPlayerState();
  191. // eslint-disable-next-line eqeqeq
  192. if (state == YT.PlayerState.PAUSED) {
  193. self.fireSharedVideoEvent(true);
  194. }
  195. };
  196. /**
  197. * Gets notified for volume state changed.
  198. * @param event
  199. */
  200. window.onVolumeChange = function(event) {
  201. self.fireSharedVideoEvent();
  202. // let's check, if player is not muted lets mute locally
  203. if (event.data.volume > 0 && !event.data.muted) {
  204. self.smartAudioMute();
  205. } else if (event.data.volume <= 0 || event.data.muted) {
  206. self.smartAudioUnmute();
  207. }
  208. sendAnalytics(createEvent(
  209. 'volume.changed',
  210. {
  211. volume: event.data.volume,
  212. muted: event.data.muted
  213. }));
  214. };
  215. window.onPlayerReady = function(event) {
  216. const player = event.target;
  217. // do not relay on autoplay as it is not sending all of the events
  218. // in onPlayerStateChange
  219. player.playVideo();
  220. const iframe = player.getIframe();
  221. // eslint-disable-next-line no-use-before-define
  222. self.sharedVideo = new SharedVideoContainer(
  223. { url,
  224. iframe,
  225. player });
  226. // prevents pausing participants not sharing the video
  227. // to pause the video
  228. if (!APP.conference.isLocalId(self.from)) {
  229. $('#sharedVideo').css('pointer-events', 'none');
  230. }
  231. VideoLayout.addLargeVideoContainer(
  232. SHARED_VIDEO_CONTAINER_TYPE, self.sharedVideo);
  233. APP.store.dispatch(participantJoined({
  234. // FIXME The cat is out of the bag already or rather _room is
  235. // not private because it is used in multiple other places
  236. // already such as AbstractPageReloadOverlay.
  237. conference: APP.conference._room,
  238. id: self.url,
  239. isFakeParticipant: true,
  240. name: VIDEO_PLAYER_PARTICIPANT_NAME
  241. }));
  242. APP.store.dispatch(pinParticipant(self.url));
  243. // If we are sending the command and we are starting the player
  244. // we need to continuously send the player current time position
  245. if (APP.conference.isLocalId(self.from)) {
  246. self.intervalId = setInterval(
  247. self.fireSharedVideoEvent.bind(self),
  248. updateInterval);
  249. }
  250. };
  251. window.onPlayerError = function(event) {
  252. logger.error('Error in the player:', event.data);
  253. // store the error player, so we can remove it
  254. self.errorInPlayer = event.target;
  255. };
  256. }
  257. /**
  258. * Process attributes, whether player needs to be paused or seek.
  259. * @param player the player to operate over
  260. * @param attributes the attributes with the player state we want
  261. */
  262. processVideoUpdate(player, attributes) {
  263. if (!attributes) {
  264. return;
  265. }
  266. // eslint-disable-next-line eqeqeq
  267. if (attributes.state == 'playing') {
  268. const isPlayerPaused
  269. = this.player.getPlayerState() === YT.PlayerState.PAUSED;
  270. // If our player is currently paused force the seek.
  271. this.processTime(player, attributes, isPlayerPaused);
  272. // Process mute.
  273. const isAttrMuted = attributes.muted === 'true';
  274. if (player.isMuted() !== isAttrMuted) {
  275. this.smartPlayerMute(isAttrMuted, true);
  276. }
  277. // Process volume
  278. if (!isAttrMuted
  279. && attributes.volume !== undefined
  280. // eslint-disable-next-line eqeqeq
  281. && player.getVolume() != attributes.volume) {
  282. player.setVolume(attributes.volume);
  283. logger.info(`Player change of volume:${attributes.volume}`);
  284. }
  285. if (isPlayerPaused) {
  286. player.playVideo();
  287. }
  288. // eslint-disable-next-line eqeqeq
  289. } else if (attributes.state == 'pause') {
  290. // if its not paused, pause it
  291. player.pauseVideo();
  292. this.processTime(player, attributes, true);
  293. }
  294. }
  295. /**
  296. * Check for time in attributes and if needed seek in current player
  297. * @param player the player to operate over
  298. * @param attributes the attributes with the player state we want
  299. * @param forceSeek whether seek should be forced
  300. */
  301. processTime(player, attributes, forceSeek) {
  302. if (forceSeek) {
  303. logger.info('Player seekTo:', attributes.time);
  304. player.seekTo(attributes.time);
  305. return;
  306. }
  307. // check received time and current time
  308. const currentPosition = player.getCurrentTime();
  309. const diff = Math.abs(attributes.time - currentPosition);
  310. // if we drift more than the interval for checking
  311. // sync, the interval is in milliseconds
  312. if (diff > updateInterval / 1000) {
  313. logger.info('Player seekTo:', attributes.time,
  314. ' current time is:', currentPosition, ' diff:', diff);
  315. player.seekTo(attributes.time);
  316. }
  317. }
  318. /**
  319. * Checks current state of the player and fire an event with the values.
  320. */
  321. fireSharedVideoEvent(sendPauseEvent) {
  322. // ignore update checks if we are not the owner of the video
  323. // or there is still no player defined or we are stopped
  324. // (in a process of stopping)
  325. if (!APP.conference.isLocalId(this.from) || !this.player
  326. || !this.isSharedVideoShown) {
  327. return;
  328. }
  329. const state = this.player.getPlayerState();
  330. // if its paused and haven't been pause - send paused
  331. if (state === YT.PlayerState.PAUSED && sendPauseEvent) {
  332. this.emitter.emit(UIEvents.UPDATE_SHARED_VIDEO,
  333. this.url, 'pause', this.player.getCurrentTime());
  334. } else if (state === YT.PlayerState.PLAYING) {
  335. // if its playing and it was paused - send update with time
  336. // if its playing and was playing just send update with time
  337. this.emitter.emit(UIEvents.UPDATE_SHARED_VIDEO,
  338. this.url, 'playing',
  339. this.player.getCurrentTime(),
  340. this.player.isMuted(),
  341. this.player.getVolume());
  342. }
  343. }
  344. /**
  345. * Updates video, if it's not playing and needs starting or if it's playing
  346. * and needs to be paused.
  347. * @param id the id of the sender of the command
  348. * @param url the video url
  349. * @param attributes
  350. */
  351. onSharedVideoUpdate(id, url, attributes) {
  352. // if we are sending the event ignore
  353. if (APP.conference.isLocalId(this.from)) {
  354. return;
  355. }
  356. if (!this.isSharedVideoShown) {
  357. this.onSharedVideoStart(id, url, attributes);
  358. return;
  359. }
  360. // eslint-disable-next-line no-negated-condition
  361. if (!this.player) {
  362. this.initialAttributes = attributes;
  363. } else {
  364. this.processVideoUpdate(this.player, attributes);
  365. }
  366. }
  367. /**
  368. * Stop shared video if it is currently showed. If the user started the
  369. * shared video is the one in the id (called when user
  370. * left and we want to remove video if the user sharing it left).
  371. * @param id the id of the sender of the command
  372. */
  373. onSharedVideoStop(id, attributes) {
  374. if (!this.isSharedVideoShown) {
  375. return;
  376. }
  377. if (this.from !== id) {
  378. return;
  379. }
  380. if (!this.player) {
  381. // if there is no error in the player till now,
  382. // store the initial attributes
  383. if (!this.errorInPlayer) {
  384. this.initialAttributes = attributes;
  385. return;
  386. }
  387. }
  388. this.emitter.removeListener(UIEvents.AUDIO_MUTED,
  389. this.localAudioMutedListener);
  390. this.localAudioMutedListener = null;
  391. APP.store.dispatch(participantLeft(this.url, APP.conference._room));
  392. VideoLayout.showLargeVideoContainer(SHARED_VIDEO_CONTAINER_TYPE, false)
  393. .then(() => {
  394. VideoLayout.removeLargeVideoContainer(
  395. SHARED_VIDEO_CONTAINER_TYPE);
  396. if (this.player) {
  397. this.player.destroy();
  398. this.player = null;
  399. } else if (this.errorInPlayer) {
  400. // if there is an error in player, remove that instance
  401. this.errorInPlayer.destroy();
  402. this.errorInPlayer = null;
  403. }
  404. this.smartAudioUnmute();
  405. // revert to original behavior (prevents pausing
  406. // for participants not sharing the video to pause it)
  407. $('#sharedVideo').css('pointer-events', 'auto');
  408. this.emitter.emit(
  409. UIEvents.UPDATE_SHARED_VIDEO, null, 'removed');
  410. });
  411. this.url = null;
  412. this.isSharedVideoShown = false;
  413. this.initialAttributes = null;
  414. }
  415. /**
  416. * Receives events for local audio mute/unmute by local user.
  417. * @param muted boolena whether it is muted or not.
  418. * @param {boolean} indicates if this mute was a result of user interaction,
  419. * i.e. pressing the mute button or it was programmatically triggered
  420. */
  421. onLocalAudioMuted(muted, userInteraction) {
  422. if (!this.player) {
  423. return;
  424. }
  425. if (muted) {
  426. this.mutedWithUserInteraction = userInteraction;
  427. } else if (this.player.getPlayerState() !== YT.PlayerState.PAUSED) {
  428. this.smartPlayerMute(true, false);
  429. // Check if we need to update other participants
  430. this.fireSharedVideoEvent();
  431. }
  432. }
  433. /**
  434. * Mutes / unmutes the player.
  435. * @param mute true to mute the shared video, false - otherwise.
  436. * @param {boolean} Indicates if this mute is a consequence of a network
  437. * video update or is called locally.
  438. */
  439. smartPlayerMute(mute, isVideoUpdate) {
  440. if (!this.player.isMuted() && mute) {
  441. this.player.mute();
  442. if (isVideoUpdate) {
  443. this.smartAudioUnmute();
  444. }
  445. } else if (this.player.isMuted() && !mute) {
  446. this.player.unMute();
  447. if (isVideoUpdate) {
  448. this.smartAudioMute();
  449. }
  450. }
  451. }
  452. /**
  453. * Smart mike unmute. If the mike is currently muted and it wasn't muted
  454. * by the user via the mike button and the volume of the shared video is on
  455. * we're unmuting the mike automatically.
  456. */
  457. smartAudioUnmute() {
  458. if (APP.conference.isLocalAudioMuted()
  459. && !this.mutedWithUserInteraction
  460. && !this.isSharedVideoVolumeOn()) {
  461. sendAnalytics(createEvent('audio.unmuted'));
  462. logger.log('Shared video: audio unmuted');
  463. this.emitter.emit(UIEvents.AUDIO_MUTED, false, false);
  464. }
  465. }
  466. /**
  467. * Smart mike mute. If the mike isn't currently muted and the shared video
  468. * volume is on we mute the mike.
  469. */
  470. smartAudioMute() {
  471. if (!APP.conference.isLocalAudioMuted()
  472. && this.isSharedVideoVolumeOn()) {
  473. sendAnalytics(createEvent('audio.muted'));
  474. logger.log('Shared video: audio muted');
  475. this.emitter.emit(UIEvents.AUDIO_MUTED, true, false);
  476. }
  477. }
  478. }
  479. /**
  480. * Container for shared video iframe.
  481. */
  482. class SharedVideoContainer extends LargeContainer {
  483. /**
  484. *
  485. */
  486. constructor({ url, iframe, player }) {
  487. super();
  488. this.$iframe = $(iframe);
  489. this.url = url;
  490. this.player = player;
  491. }
  492. /**
  493. *
  494. */
  495. show() {
  496. const self = this;
  497. return new Promise(resolve => {
  498. this.$iframe.fadeIn(300, () => {
  499. self.bodyBackground = document.body.style.background;
  500. document.body.style.background = 'black';
  501. this.$iframe.css({ opacity: 1 });
  502. APP.store.dispatch(dockToolbox(true));
  503. resolve();
  504. });
  505. });
  506. }
  507. /**
  508. *
  509. */
  510. hide() {
  511. const self = this;
  512. APP.store.dispatch(dockToolbox(false));
  513. return new Promise(resolve => {
  514. this.$iframe.fadeOut(300, () => {
  515. document.body.style.background = self.bodyBackground;
  516. this.$iframe.css({ opacity: 0 });
  517. resolve();
  518. });
  519. });
  520. }
  521. /**
  522. *
  523. */
  524. onHoverIn() {
  525. APP.store.dispatch(showToolbox());
  526. }
  527. /**
  528. *
  529. */
  530. get id() {
  531. return this.url;
  532. }
  533. /**
  534. *
  535. */
  536. resize(containerWidth, containerHeight) {
  537. let height, width;
  538. if (interfaceConfig.VERTICAL_FILMSTRIP) {
  539. height = containerHeight - getToolboxHeight();
  540. width = containerWidth - Filmstrip.getVerticalFilmstripWidth();
  541. } else {
  542. height = containerHeight - Filmstrip.getFilmstripHeight();
  543. width = containerWidth;
  544. }
  545. this.$iframe.width(width).height(height);
  546. }
  547. /**
  548. * @return {boolean} do not switch on dominant speaker event if on stage.
  549. */
  550. stayOnStage() {
  551. return false;
  552. }
  553. }