import React, { Component } from 'react'; import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component'; import { defineMessages, injectIntl } from 'react-intl'; import ReactPlayer from 'react-player'; import { sendMessage, onMessage, removeAllListeners, getPlayingState, } from './service'; import { isMobile, isTablet, } from '../layout/utils'; import logger from '/imports/startup/client/logger'; import VolumeSlider from './volume-slider/component'; import ReloadButton from '/imports/ui/components/reload-button/component'; import ArcPlayer from '/imports/ui/components/external-video-player/custom-players/arc-player'; import PeerTubePlayer from '/imports/ui/components/external-video-player/custom-players/peertube'; import { styles } from './styles'; const intlMessages = defineMessages({ autoPlayWarning: { id: 'app.externalVideo.autoPlayWarning', description: 'Shown when user needs to interact with player to make it work', }, refreshLabel: { id: 'app.externalVideo.refreshLabel', }, }); const SYNC_INTERVAL_SECONDS = 5; const THROTTLE_INTERVAL_SECONDS = 0.5; const AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS = 5; ReactPlayer.addCustomPlayer(PeerTubePlayer); ReactPlayer.addCustomPlayer(ArcPlayer); class VideoPlayer extends Component { static clearVideoListeners() { removeAllListeners('play'); removeAllListeners('stop'); removeAllListeners('playerUpdate'); removeAllListeners('presenterReady'); } constructor(props) { super(props); const { isPresenter } = props; this.player = null; this.syncInterval = null; this.autoPlayTimeout = null; this.hasPlayedBefore = false; this.playerIsReady = false; this.lastMessage = null; this.lastMessageTimestamp = Date.now(); this.throttleTimeout = null; this.state = { muted: false, playing: false, autoPlayBlocked: false, volume: 1, playbackRate: 1, key: 0, }; this.opts = { // default option for all players, can be overwritten playerOptions: { autoplay: true, playsinline: true, controls: isPresenter, }, file: { attributes: { controls: isPresenter ? 'controls' : '', autoplay: 'autoplay', playsinline: 'playsinline', }, }, dailymotion: { params: { controls: isPresenter, }, }, youtube: { playerVars: { autoplay: 1, modestbranding: 1, autohide: 1, rel: 0, ecver: 2, controls: isPresenter ? 1 : 0, }, }, peertube: { isPresenter, }, twitch: { options: { controls: isPresenter, }, playerId: 'externalVideoPlayerTwitch', }, preload: true, showHoverToolBar: false, }; this.registerVideoListeners = this.registerVideoListeners.bind(this); this.autoPlayBlockDetected = this.autoPlayBlockDetected.bind(this); this.handleFirstPlay = this.handleFirstPlay.bind(this); this.handleReload = this.handleReload.bind(this); this.handleOnProgress = this.handleOnProgress.bind(this); this.handleOnReady = this.handleOnReady.bind(this); this.handleOnPlay = this.handleOnPlay.bind(this); this.handleOnPause = this.handleOnPause.bind(this); this.handleVolumeChanged = this.handleVolumeChanged.bind(this); this.handleOnMuted = this.handleOnMuted.bind(this); this.sendSyncMessage = this.sendSyncMessage.bind(this); this.getCurrentPlaybackRate = this.getCurrentPlaybackRate.bind(this); this.getCurrentTime = this.getCurrentTime.bind(this); this.getCurrentVolume = this.getCurrentVolume.bind(this); this.getMuted = this.getMuted.bind(this); this.setPlaybackRate = this.setPlaybackRate.bind(this); this.onBeforeUnload = this.onBeforeUnload.bind(this); this.isMobile = isMobile() || isTablet(); this.mobileHoverSetTimeout = null; } componentDidMount() { const { getSwapLayout, toggleSwapLayout, layoutContextDispatch, } = this.props; window.addEventListener('beforeunload', this.onBeforeUnload); clearInterval(this.syncInterval); clearTimeout(this.autoPlayTimeout); VideoPlayer.clearVideoListeners(); this.registerVideoListeners(); if (getSwapLayout()) toggleSwapLayout(layoutContextDispatch); } shouldComponentUpdate(nextProps, nextState) { const { isPresenter } = this.props; const { playing } = this.state; // If user is presenter we don't re-render playing state changes // Because he's in control of the play/pause status if (nextProps.isPresenter && isPresenter && nextState.playing !== playing) { return false; } return true; } componentDidUpdate(prevProp) { // Detect presenter change and redo the sync and listeners to reassign video to the new one const { isPresenter } = this.props; if (isPresenter !== prevProp.isPresenter) { VideoPlayer.clearVideoListeners(); clearInterval(this.syncInterval); clearTimeout(this.autoPlayTimeout); this.registerVideoListeners(); } } componentWillUnmount() { window.removeEventListener('beforeunload', this.onBeforeUnload); VideoPlayer.clearVideoListeners(); clearInterval(this.syncInterval); clearTimeout(this.autoPlayTimeout); this.player = null; } handleOnReady() { const { hasPlayedBefore, playerIsReady } = this; if (hasPlayedBefore || playerIsReady) { return; } this.playerIsReady = true; this.autoPlayTimeout = setTimeout( this.autoPlayBlockDetected, AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS * 1000, ); } handleFirstPlay() { const { isPresenter } = this.props; const { hasPlayedBefore } = this; if (!hasPlayedBefore) { this.hasPlayedBefore = true; this.setState({ autoPlayBlocked: false }); clearTimeout(this.autoPlayTimeout); if (isPresenter) { this.sendSyncMessage('presenterReady'); } } } handleOnPlay() { const { isPresenter } = this.props; const { playing } = this.state; const curTime = this.getCurrentTime(); if (isPresenter && !playing) { this.sendSyncMessage('play', { time: curTime }); } this.setState({ playing: true }); this.handleFirstPlay(); if (!isPresenter && !playing) { this.setState({ playing: false }); } } handleOnPause() { const { isPresenter } = this.props; const { playing } = this.state; const curTime = this.getCurrentTime(); if (isPresenter && playing) { this.sendSyncMessage('stop', { time: curTime }); } this.setState({ playing: false }); this.handleFirstPlay(); if (!isPresenter && playing) { this.setState({ playing: true }); } } handleOnProgress() { const volume = this.getCurrentVolume(); const muted = this.getMuted(); this.setState({ volume, muted }); } handleVolumeChanged(volume) { this.setState({ volume }); } handleOnMuted(muted) { this.setState({ muted }); } handleReload() { const { key } = this.state; // increment key and force a re-render of the video component this.setState({ key: key + 1 }); } onBeforeUnload() { const { isPresenter } = this.props; if (isPresenter) { this.sendSyncMessage('stop'); } } static getDerivedStateFromProps(props) { const { inEchoTest } = props; return { mutedByEchoTest: inEchoTest }; } getCurrentTime() { if (this.player && this.player.getCurrentTime) { return Math.round(this.player.getCurrentTime()); } return 0; } getCurrentPlaybackRate() { const intPlayer = this.player && this.player.getInternalPlayer(); return (intPlayer && intPlayer.getPlaybackRate && intPlayer.getPlaybackRate()) || 1; } setPlaybackRate(rate) { const intPlayer = this.player && this.player.getInternalPlayer(); const currentRate = this.getCurrentPlaybackRate(); if (currentRate === rate) { return; } this.setState({ playbackRate: rate }); if (intPlayer && intPlayer.setPlaybackRate) { intPlayer.setPlaybackRate(rate); } } getCurrentVolume() { const { volume } = this.state; const intPlayer = this.player && this.player.getInternalPlayer(); return (intPlayer && intPlayer.getVolume && intPlayer.getVolume() / 100.0) || volume; } getMuted() { const { muted } = this.state; const intPlayer = this.player && this.player.getInternalPlayer(); return (intPlayer && intPlayer.isMuted && intPlayer.isMuted()) || muted; } autoPlayBlockDetected() { this.setState({ autoPlayBlocked: true }); } sendSyncMessage(msg, params) { const timestamp = Date.now(); // If message is just a quick pause/un-pause just send nothing const sinceLastMessage = (timestamp - this.lastMessageTimestamp) / 1000; if (( (msg === 'play' && this.lastMessage === 'stop') || (msg === 'stop' && this.lastMessage === 'play')) && sinceLastMessage < THROTTLE_INTERVAL_SECONDS) { return clearTimeout(this.throttleTimeout); } // Ignore repeat presenter ready messages if (this.lastMessage === msg && msg === 'presenterReady') { logger.debug('Ignoring a repeated presenterReady message'); } else { // Play/pause messages are sent with a delay, to permit cancelling it in case of // quick sucessive play/pauses const messageDelay = (msg === 'play' || msg === 'stop') ? THROTTLE_INTERVAL_SECONDS : 0; this.throttleTimeout = setTimeout(() => { sendMessage(msg, { ...params }); }, messageDelay * 1000); this.lastMessage = msg; this.lastMessageTimestamp = timestamp; } return true; } registerVideoListeners() { const { isPresenter } = this.props; if (isPresenter) { this.syncInterval = setInterval(() => { const { playing } = this.state; const curTime = this.getCurrentTime(); const rate = this.getCurrentPlaybackRate(); // Always pause video if presenter is has not started sharing, e.g., blocked by autoplay const playingState = this.hasPlayedBefore ? playing : false; this.sendSyncMessage('playerUpdate', { rate, time: curTime, state: playingState }); }, SYNC_INTERVAL_SECONDS * 1000); } else { onMessage('play', ({ time }) => { const { hasPlayedBefore, player } = this; if (!player || !hasPlayedBefore) { return; } this.seekTo(time); this.setState({ playing: true }); logger.debug({ logCode: 'external_video_client_play' }, 'Play external video'); }); onMessage('stop', ({ time }) => { const { hasPlayedBefore, player } = this; if (!player || !hasPlayedBefore) { return; } this.seekTo(time); this.setState({ playing: false }); logger.debug({ logCode: 'external_video_client_stop' }, 'Stop external video'); }); onMessage('presenterReady', () => { const { hasPlayedBefore } = this; logger.debug({ logCode: 'external_video_presenter_ready' }, 'Presenter is ready to sync'); if (!hasPlayedBefore) { this.setState({ playing: true }); } }); onMessage('playerUpdate', (data) => { const { hasPlayedBefore, player } = this; const { playing } = this.state; const { time, rate, state } = data; if (!player || !hasPlayedBefore) { return; } if (rate !== this.getCurrentPlaybackRate()) { this.setPlaybackRate(rate); logger.debug({ logCode: 'external_video_client_update_rate', extraInfo: { newRate: rate, }, }, 'Change external video playback rate.'); } this.seekTo(time); const playingState = getPlayingState(state); if (playing !== playingState) { this.setState({ playing: playingState }); } }); } } seekTo(time) { const { player } = this; if (!player) { return logger.error('No player on seek'); } // Seek if viewer has drifted too far away from presenter if (Math.abs(this.getCurrentTime() - time) > SYNC_INTERVAL_SECONDS * 0.75) { player.seekTo(time, true); logger.debug({ logCode: 'external_video_client_update_seek', extraInfo: { time }, }, `Seek external video to: ${time}`); } return true; } render() { const { videoUrl, isPresenter, intl, top, left, right, height, width, } = this.props; const { playing, playbackRate, mutedByEchoTest, autoPlayBlocked, volume, muted, key, showHoverToolBar, } = this.state; const mobileHoverToolBarStyle = showHoverToolBar ? styles.showMobileHoverToolbar : styles.dontShowMobileHoverToolbar; const desktopHoverToolBarStyle = styles.hoverToolbar; const hoverToolbarStyle = this.isMobile ? mobileHoverToolBarStyle : desktopHoverToolBarStyle; return (
{ this.playerParent = ref; }} > { autoPlayBlocked ? (

{intl.formatMessage(intlMessages.autoPlayWarning)}

) : '' } { this.player = ref; }} height="100%" width="100%" /> { !isPresenter ? [ (
), (this.isMobile && playing) && ( { this.overlay = ref; }} onTouchStart={() => { clearTimeout(this.mobileHoverSetTimeout); this.setState({ showHoverToolBar: true }); }} onTouchEnd={() => { this.mobileHoverSetTimeout = setTimeout( () => this.setState({ showHoverToolBar: false }), 5000, ); }} /> ), ] : null }
); } } export default injectIntl(injectWbResizeEvent(VideoPlayer));