/* global $, APP, interfaceConfig */ /* eslint-disable no-unused-vars */ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import { I18nextProvider } from 'react-i18next'; import { AtlasKitThemeProvider } from '@atlaskit/theme'; import { i18next } from '../../../react/features/base/i18n'; import { JitsiParticipantConnectionStatus } from '../../../react/features/base/lib-jitsi-meet'; import { PresenceLabel } from '../../../react/features/presence-status'; import { REMOTE_CONTROL_MENU_STATES, RemoteVideoMenuTriggerButton } from '../../../react/features/remote-video-menu'; /* eslint-enable no-unused-vars */ const logger = require('jitsi-meet-logger').getLogger(__filename); import SmallVideo from './SmallVideo'; import UIUtils from '../util/UIUtil'; /** * Creates new instance of the RemoteVideo. * @param user {JitsiParticipant} the user for whom remote video instance will * be created. * @param {VideoLayout} VideoLayout the video layout instance. * @param {EventEmitter} emitter the event emitter which will be used by * the new instance to emit events. * @constructor */ function RemoteVideo(user, VideoLayout, emitter) { this.user = user; this.id = user.getId(); this.emitter = emitter; this.videoSpanId = `participant_${this.id}`; SmallVideo.call(this, VideoLayout); this._audioStreamElement = null; this._supportsRemoteControl = false; this.statsPopoverLocation = interfaceConfig.VERTICAL_FILMSTRIP ? 'left bottom' : 'top center'; this.addRemoteVideoContainer(); this.updateIndicators(); this.setDisplayName(); this.bindHoverHandler(); this.flipX = false; this.isLocal = false; this.popupMenuIsHovered = false; this._isRemoteControlSessionActive = false; /** * The flag is set to true after the 'onplay' event has been * triggered on the current video element. It goes back to false * when the stream is removed. It is used to determine whether the video * playback has ever started. * @type {boolean} */ this.wasVideoPlayed = false; /** * The flag is set to true if remote participant's video gets muted * during his media connection disruption. This is to prevent black video * being render on the thumbnail, because even though once the video has * been played the image usually remains on the video element it seems that * after longer period of the video element being hidden this image can be * lost. * @type {boolean} */ this.mutedWhileDisconnected = false; // Bind event handlers so they are only bound once for every instance. // TODO The event handlers should be turned into actions so changes can be // handled through reducers and middleware. this._requestRemoteControlPermissions = this._requestRemoteControlPermissions.bind(this); this._setAudioVolume = this._setAudioVolume.bind(this); this._stopRemoteControl = this._stopRemoteControl.bind(this); } RemoteVideo.prototype = Object.create(SmallVideo.prototype); RemoteVideo.prototype.constructor = RemoteVideo; RemoteVideo.prototype.addRemoteVideoContainer = function() { this.container = RemoteVideo.createContainer(this.videoSpanId); this.$container = $(this.container); this.initBrowserSpecificProperties(); this.addRemoteVideoMenu(); this.VideoLayout.resizeThumbnails(false, true); this.addAudioLevelIndicator(); this.addPresenceLabel(); return this.container; }; /** * Checks whether current video is considered hovered. Currently it is hovered * if the mouse is over the video, or if the connection indicator or the popup * menu is shown(hovered). * @private * NOTE: extends SmallVideo's method */ RemoteVideo.prototype._isHovered = function() { const isHovered = SmallVideo.prototype._isHovered.call(this) || this.popupMenuIsHovered; return isHovered; }; /** * Generates the popup menu content. * * @returns {Element|*} the constructed element, containing popup menu items * @private */ RemoteVideo.prototype._generatePopupContent = function() { if (interfaceConfig.filmStripOnly) { return; } const remoteVideoMenuContainer = this.container.querySelector('.remotevideomenu'); if (!remoteVideoMenuContainer) { return; } const { controller } = APP.remoteControl; let remoteControlState = null; let onRemoteControlToggle; if (this._supportsRemoteControl && ((!APP.remoteControl.active && !this._isRemoteControlSessionActive) || APP.remoteControl.controller.activeParticipant === this.id)) { if (controller.getRequestedParticipant() === this.id) { remoteControlState = REMOTE_CONTROL_MENU_STATES.REQUESTING; } else if (controller.isStarted()) { onRemoteControlToggle = this._stopRemoteControl; remoteControlState = REMOTE_CONTROL_MENU_STATES.STARTED; } else { onRemoteControlToggle = this._requestRemoteControlPermissions; remoteControlState = REMOTE_CONTROL_MENU_STATES.NOT_STARTED; } } let initialVolumeValue, onVolumeChange; // Feature check for volume setting as temasys objects cannot adjust volume. if (this._canSetAudioVolume()) { initialVolumeValue = this._getAudioElement().volume; onVolumeChange = this._setAudioVolume; } const { isModerator } = APP.conference; const participantID = this.id; ReactDOM.render( , remoteVideoMenuContainer); }; RemoteVideo.prototype._onRemoteVideoMenuDisplay = function() { this.updateRemoteVideoMenu(); }; /** * Sets the remote control active status for the remote video. * * @param {boolean} isActive - The new remote control active status. * @returns {void} */ RemoteVideo.prototype.setRemoteControlActiveStatus = function(isActive) { this._isRemoteControlSessionActive = isActive; this.updateRemoteVideoMenu(); }; /** * Sets the remote control supported value and initializes or updates the menu * depending on the remote control is supported or not. * @param {boolean} isSupported */ RemoteVideo.prototype.setRemoteControlSupport = function(isSupported = false) { if (this._supportsRemoteControl === isSupported) { return; } this._supportsRemoteControl = isSupported; this.updateRemoteVideoMenu(); }; /** * Requests permissions for remote control session. */ RemoteVideo.prototype._requestRemoteControlPermissions = function() { APP.remoteControl.controller.requestPermissions( this.id, this.VideoLayout.getLargeVideoWrapper()).then(result => { if (result === null) { return; } this.updateRemoteVideoMenu(); APP.UI.messageHandler.notify( 'dialog.remoteControlTitle', result === false ? 'dialog.remoteControlDeniedMessage' : 'dialog.remoteControlAllowedMessage', { user: this.user.getDisplayName() || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME } ); if (result === true) { // the remote control permissions has been granted // pin the controlled participant const pinnedId = this.VideoLayout.getPinnedId(); if (pinnedId !== this.id) { this.VideoLayout.handleVideoThumbClicked(this.id); } } }, error => { logger.error(error); this.updateRemoteVideoMenu(); APP.UI.messageHandler.notify( 'dialog.remoteControlTitle', 'dialog.remoteControlErrorMessage', { user: this.user.getDisplayName() || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME } ); }); this.updateRemoteVideoMenu(); }; /** * Stops remote control session. */ RemoteVideo.prototype._stopRemoteControl = function() { // send message about stopping APP.remoteControl.controller.stop(); this.updateRemoteVideoMenu(); }; /** * Get the remote participant's audio element. * * @returns {Element} audio element */ RemoteVideo.prototype._getAudioElement = function() { return this._audioStreamElement; }; /** * Check if the remote participant's audio can have its volume adjusted. * * @returns {boolean} true if the volume can be adjusted. */ RemoteVideo.prototype._canSetAudioVolume = function() { const audioElement = this._getAudioElement(); return audioElement && audioElement.volume !== undefined; }; /** * Change the remote participant's volume level. * * @param {int} newVal - The value to set the slider to. */ RemoteVideo.prototype._setAudioVolume = function(newVal) { if (this._canSetAudioVolume()) { this._getAudioElement().volume = newVal; } }; /** * Updates the remote video menu. * * @param isMuted the new muted state to update to */ RemoteVideo.prototype.updateRemoteVideoMenu = function(isMuted) { if (typeof isMuted !== 'undefined') { this.isAudioMuted = isMuted; } this._generatePopupContent(); }; /** * @inheritDoc * @override */ RemoteVideo.prototype.setVideoMutedView = function(isMuted) { SmallVideo.prototype.setVideoMutedView.call(this, isMuted); // Update 'mutedWhileDisconnected' flag this._figureOutMutedWhileDisconnected(); }; /** * Figures out the value of {@link #mutedWhileDisconnected} flag by taking into * account remote participant's network connectivity and video muted status. * * @private */ RemoteVideo.prototype._figureOutMutedWhileDisconnected = function() { const isActive = this.isConnectionActive(); if (!isActive && this.isVideoMuted) { this.mutedWhileDisconnected = true; } else if (isActive && !this.isVideoMuted) { this.mutedWhileDisconnected = false; } }; /** * Adds the remote video menu element for the given id in the * given parentElement. * */ RemoteVideo.prototype.addRemoteVideoMenu = function() { if (interfaceConfig.filmStripOnly) { return; } this._generatePopupContent(); }; /** * Removes the remote stream element corresponding to the given stream and * parent container. * * @param stream the MediaStream * @param isVideo true if given stream is a video one. */ RemoteVideo.prototype.removeRemoteStreamElement = function(stream) { if (!this.container) { return false; } const isVideo = stream.isVideoTrack(); const elementID = SmallVideo.getStreamElementID(stream); const select = $(`#${elementID}`); select.remove(); if (isVideo) { this.wasVideoPlayed = false; } logger.info(`${isVideo ? 'Video' : 'Audio' } removed ${this.id}`, select); // when removing only the video element and we are on stage // update the stage if (isVideo && this.isCurrentlyOnLargeVideo()) { this.VideoLayout.updateLargeVideo(this.id); } else { // Missing video stream will affect display mode this.updateView(); } }; /** * Checks whether the remote user associated with this RemoteVideo * has connectivity issues. * * @return {boolean} true if the user's connection is fine or * false otherwise. */ RemoteVideo.prototype.isConnectionActive = function() { return this.user.getConnectionStatus() === JitsiParticipantConnectionStatus.ACTIVE; }; /** * The remote video is considered "playable" once the stream has started * according to the {@link #hasVideoStarted} result. * It will be allowed to display video also in * {@link JitsiParticipantConnectionStatus.INTERRUPTED} if the video was ever * played and was not muted while not in ACTIVE state. This basically means * that there is stalled video image cached that could be displayed. It's used * to show "grey video image" in user's thumbnail when there are connectivity * issues. * * @inheritdoc * @override */ RemoteVideo.prototype.isVideoPlayable = function() { const connectionState = APP.conference.getParticipantConnectionStatus(this.id); return SmallVideo.prototype.isVideoPlayable.call(this) && this.hasVideoStarted() && (connectionState === JitsiParticipantConnectionStatus.ACTIVE || (connectionState === JitsiParticipantConnectionStatus.INTERRUPTED && !this.mutedWhileDisconnected)); }; /** * @inheritDoc */ RemoteVideo.prototype.updateView = function() { this.$container.toggleClass('audio-only', APP.conference.isAudioOnly()); this.updateConnectionStatusIndicator(); // This must be called after 'updateConnectionStatusIndicator' because it // affects the display mode by modifying 'mutedWhileDisconnected' flag SmallVideo.prototype.updateView.call(this); }; /** * Updates the UI to reflect user's connectivity status. */ RemoteVideo.prototype.updateConnectionStatusIndicator = function() { const connectionStatus = this.user.getConnectionStatus(); logger.debug(`${this.id} thumbnail connection status: ${connectionStatus}`); // FIXME rename 'mutedWhileDisconnected' to 'mutedWhileNotRendering' // Update 'mutedWhileDisconnected' flag this._figureOutMutedWhileDisconnected(); this.updateConnectionStatus(connectionStatus); const isInterrupted = connectionStatus === JitsiParticipantConnectionStatus.INTERRUPTED; // Toggle thumbnail video problem filter this.selectVideoElement().toggleClass( 'videoThumbnailProblemFilter', isInterrupted); this.$avatar().toggleClass( 'videoThumbnailProblemFilter', isInterrupted); }; /** * Removes RemoteVideo from the page. */ RemoteVideo.prototype.remove = function() { logger.log('Remove thumbnail', this.id); this.removeAudioLevelIndicator(); const toolbarContainer = this.container.querySelector('.videocontainer__toolbar'); if (toolbarContainer) { ReactDOM.unmountComponentAtNode(toolbarContainer); } this.removeConnectionIndicator(); this.removeDisplayName(); this.removeAvatar(); this.removePresenceLabel(); this._unmountIndicators(); this.removeRemoteVideoMenu(); // Make sure that the large video is updated if are removing its // corresponding small video. this.VideoLayout.updateAfterThumbRemoved(this.id); // Remove whole container if (this.container.parentNode) { this.container.parentNode.removeChild(this.container); } }; RemoteVideo.prototype.waitForPlayback = function(streamElement, stream) { const webRtcStream = stream.getOriginalStream(); const isVideo = stream.isVideoTrack(); if (!isVideo || webRtcStream.id === 'mixedmslabel') { return; } const self = this; // Triggers when video playback starts const onPlayingHandler = function() { self.wasVideoPlayed = true; self.VideoLayout.remoteVideoActive(streamElement, self.id); streamElement.onplaying = null; // Refresh to show the video self.updateView(); }; streamElement.onplaying = onPlayingHandler; }; /** * Checks whether the video stream has started for this RemoteVideo instance. * * @returns {boolean} true if this RemoteVideo has a video stream for which * the playback has been started. */ RemoteVideo.prototype.hasVideoStarted = function() { return this.wasVideoPlayed; }; RemoteVideo.prototype.addRemoteStreamElement = function(stream) { if (!this.container) { return; } const isVideo = stream.isVideoTrack(); isVideo ? this.videoStream = stream : this.audioStream = stream; if (isVideo) { this.setVideoType(stream.videoType); } // Add click handler. const onClickHandler = event => { const $source = $(event.target || event.srcElement); const { classList } = event.target; const clickedOnPopover = $source.parents('.connection-info').length > 0; const clickedOnPopoverTrigger = $source.parents('.popover-trigger').length > 0 || classList.contains('popover-trigger'); const clickedOnRemoteMenu = $source.parents('.remotevideomenu').length > 0; const ignoreClick = clickedOnPopoverTrigger || clickedOnPopover || clickedOnRemoteMenu; if (!ignoreClick) { this.VideoLayout.handleVideoThumbClicked(this.id); } // On IE we need to populate this handler on video // and it does not give event instance as an argument, // so we check here for methods. if (event.stopPropagation && event.preventDefault && !ignoreClick) { event.stopPropagation(); event.preventDefault(); } return false; }; this.container.onclick = onClickHandler; if (!stream.getOriginalStream()) { return; } let streamElement = SmallVideo.createStreamElement(stream); // Put new stream element always in front UIUtils.prependChild(this.container, streamElement); // If we hide element when Temasys plugin is used then // we'll never receive 'onplay' event and other logic won't work as expected // NOTE: hiding will not have effect when Temasys plugin is in use, as // calling attach will show it back $(streamElement).hide(); // If the container is currently visible // we attach the stream to the element. if (!isVideo || (this.container.offsetParent !== null && isVideo)) { this.waitForPlayback(streamElement, stream); streamElement = stream.attach(streamElement); } $(streamElement).click(onClickHandler); if (!isVideo) { this._audioStreamElement = streamElement; // If the remote video menu was created before the audio stream was // attached we need to update the menu in order to show the volume // slider. this.updateRemoteVideoMenu(); } }; /** * Sets the display name for the given video span id. * * @param displayName the display name to set */ RemoteVideo.prototype.setDisplayName = function(displayName) { if (!this.container) { logger.warn(`Unable to set displayName - ${this.videoSpanId } does not exist`); return; } this.updateDisplayName({ displayName: displayName || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME, elementID: `${this.videoSpanId}_name`, participantID: this.id }); }; /** * Removes remote video menu element from video element identified by * given videoElementId. * * @param videoElementId the id of local or remote video element. */ RemoteVideo.prototype.removeRemoteVideoMenu = function() { const menuSpan = this.$container.find('.remotevideomenu'); if (menuSpan.length) { ReactDOM.unmountComponentAtNode(menuSpan.get(0)); menuSpan.remove(); } }; /** * Mounts the {@code PresenceLabel} for displaying the participant's current * presence status. * * @return {void} */ RemoteVideo.prototype.addPresenceLabel = function() { const presenceLabelContainer = this.container.querySelector('.presence-label-container'); if (presenceLabelContainer) { ReactDOM.render( , presenceLabelContainer); } }; /** * Unmounts the {@code PresenceLabel} component. * * @return {void} */ RemoteVideo.prototype.removePresenceLabel = function() { const presenceLabelContainer = this.container.querySelector('.presence-label-container'); if (presenceLabelContainer) { ReactDOM.unmountComponentAtNode(presenceLabelContainer); } }; RemoteVideo.createContainer = function(spanId) { const container = document.createElement('span'); container.id = spanId; container.className = 'videocontainer'; container.innerHTML = `
`; const remotes = document.getElementById('filmstripRemoteVideosContainer'); return remotes.appendChild(container); }; export default RemoteVideo;