jiti-meet/modules/UI/videolayout/RemoteVideo.js

681 lines
20 KiB
JavaScript
Raw Normal View History

/* 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 {
getPinnedParticipant,
pinParticipant
} from '../../../react/features/base/participants';
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);
2015-12-14 12:26:50 +00:00
import SmallVideo from './SmallVideo';
import UIUtils from '../util/UIUtil';
2015-12-14 12:26:50 +00:00
/**
* Creates new instance of the <tt>RemoteVideo</tt>.
* @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();
2015-12-14 12:26:50 +00:00
this.emitter = emitter;
this.videoSpanId = `participant_${this.id}`;
SmallVideo.call(this, VideoLayout);
this._audioStreamElement = null;
this._supportsRemoteControl = false;
this.statsPopoverLocation = interfaceConfig.VERTICAL_FILMSTRIP
WiP(invite-ui): Initial move of invite UI to invite button (#1950) * WiP(invite-ui): Initial move of invite UI to invite button * Adjusts styling to fit both horizontal and vertical filmstrip * Removes comment and functions not needed * [squash] Addressing various review comments * [squash] Move invite options to a separate config * [squash] Adjust invite button styles until we fix the whole UI theme * [squash] Fix the remote videos scroll * [squash]:Do not show popup menu when 1 option is available * [squash]: Disable the invite button in filmstrip mode * feat(connection-indicator): implement automatic hiding on good connection (#2009) * ref(connection-stats): use PropTypes package * feat(connection-stats): display a summary of the connection quality * feat(connection-indicator): show empty bars for interrupted connection * feat(connection-indicator): change background color based on status * feat(connection-indicator): implement automatic hiding on good connection * fix(connection-indicator): explicitly set font size Currently non-react code will set an icon size on ConnectionIndicator. This doesn't work on initial call join in vertical filmstrip after some changes to support hiding the indicator. The chosen fix is passing in the icon size to mirror what would happe with full filmstrip reactification. * ref(connection-stats): rename statuses * feat(connection-indicator): make hiding behavior configurable The original implementation made the auto hiding of the indicator configured in interfaceConfig. * fix(connection-indicator): readd class expected by torture tests * fix(connection-indicator): change connection quality display styling Bold the connection summary in the stats popover so it stands out. Change the summaries so there are only three--strong, nonoptimal, poor. * fix(connection-indicator): gray background on lost connection * feat(icons): add new gsm bars icon * feat(connection-indicator): use new 3-bar icon * ref(icons): remove icon-connection and icon-connection-lost Both have been replaced by icon-gsm-bars so they are not being referenced anymore. Mobile looks to have connect-lost as a separate icon in font-icons/jitsi.json. * fix(defaultToolbarButtons): Fixes unresolved InfoDialogButton component problem * [squash]: Makes invite button fit the container * [squash]:Addressing invite truncate, remote menu position and comment * [squash]:Fix z-index in horizontal mode, z-index in lonely call * [squash]: Fix filmstripOnly property, remove important from css
2017-10-03 16:30:42 +00:00
? 'left bottom' : 'top center';
2015-06-23 08:00:46 +00:00
this.addRemoteVideoContainer();
this.updateIndicators();
2015-06-23 08:00:46 +00:00
this.setDisplayName();
this.bindHoverHandler();
2015-06-23 08:00:46 +00:00
this.flipX = false;
this.isLocal = false;
this.popupMenuIsHovered = false;
this._isRemoteControlSessionActive = false;
/**
* The flag is set to <tt>true</tt> after the 'onplay' event has been
* triggered on the current video element. It goes back to <tt>false</tt>
* 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 <tt>true</tt> 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);
this.container.onclick = this._onContainerClick.bind(this);
2015-06-23 08:00:46 +00:00
}
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.updateRemoteVideoMenu();
2016-09-15 02:20:54 +00:00
this.VideoLayout.resizeThumbnails(true);
2016-09-28 21:31:40 +00:00
this.addAudioLevelIndicator();
2015-06-23 08:00:46 +00:00
this.addPresenceLabel();
2015-06-23 08:00:46 +00:00
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(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<AtlasKitThemeProvider mode = 'dark'>
<RemoteVideoMenuTriggerButton
initialVolumeValue = { initialVolumeValue }
isAudioMuted = { this.isAudioMuted }
isModerator = { isModerator }
onMenuDisplay
= {this._onRemoteVideoMenuDisplay.bind(this)}
onRemoteControlToggle = { onRemoteControlToggle }
onVolumeChange = { onVolumeChange }
participantID = { participantID }
remoteControlState = { remoteControlState } />
</AtlasKitThemeProvider>
</I18nextProvider>
</Provider>,
remoteVideoMenuContainer);
2016-10-27 13:09:27 +00:00
};
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 pinnedParticipant
= getPinnedParticipant(APP.store.getState()) || {};
const pinnedId = pinnedParticipant.id;
if (pinnedId !== this.id) {
APP.store.dispatch(pinParticipant(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();
2016-09-28 16:29:47 +00:00
};
/**
* 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;
}
2016-09-28 16:29:47 +00:00
};
2015-06-23 08:00:46 +00:00
/**
* Removes the remote stream element corresponding to the given stream and
* parent container.
*
* @param stream the MediaStream
2015-06-23 08:00:46 +00:00
* @param isVideo <tt>true</tt> if given <tt>stream</tt> is a video one.
*/
RemoteVideo.prototype.removeRemoteStreamElement = function(stream) {
if (!this.container) {
2015-06-23 08:00:46 +00:00
return false;
}
2015-06-23 08:00:46 +00:00
const isVideo = stream.isVideoTrack();
const elementID = SmallVideo.getStreamElementID(stream);
const select = $(`#${elementID}`);
2015-06-23 08:00:46 +00:00
select.remove();
if (isVideo) {
this.wasVideoPlayed = false;
}
logger.info(`${isVideo ? 'Video' : 'Audio'
} removed ${this.id}`, select);
this.updateView();
2015-06-23 08:00:46 +00:00
};
/**
* Checks whether the remote user associated with this <tt>RemoteVideo</tt>
* has connectivity issues.
*
* @return {boolean} <tt>true</tt> if the user's connection is fine or
* <tt>false</tt> 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);
2015-06-23 08:00:46 +00:00
};
/**
* 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();
// Remove whole container
2015-12-14 12:26:50 +00:00
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
2015-12-14 12:26:50 +00:00
}
};
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) {
2015-12-14 12:26:50 +00:00
if (!this.container) {
2015-06-23 08:00:46 +00:00
return;
2015-12-14 12:26:50 +00:00
}
2015-06-23 08:00:46 +00:00
const isVideo = stream.isVideoTrack();
isVideo ? this.videoStream = stream : this.audioStream = stream;
2015-06-23 08:00:46 +00:00
if (isVideo) {
this.setVideoType(stream.videoType);
}
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
2016-02-02 21:50:02 +00:00
// NOTE: hiding will not have effect when Temasys plugin is in use, as
// calling attach will show it back
$(streamElement).hide();
2016-02-10 15:26:16 +00:00
// 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);
}
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();
}
2016-10-31 16:12:28 +00:00
};
2015-06-23 08:00:46 +00:00
/**
* Sets the display name for the given video span id.
2016-10-20 19:28:10 +00:00
*
* @param displayName the display name to set
2015-06-23 08:00:46 +00:00
*/
2016-10-20 19:28:10 +00:00
RemoteVideo.prototype.setDisplayName = function(displayName) {
2015-06-23 08:00:46 +00:00
if (!this.container) {
logger.warn(`Unable to set displayName - ${this.videoSpanId
} does not exist`);
2015-06-23 08:00:46 +00:00
return;
}
this.updateDisplayName({
displayName: displayName || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME,
elementID: `${this.videoSpanId}_name`,
participantID: this.id
});
};
2015-06-23 08:00:46 +00:00
/**
* Removes remote video menu element from video element identified by
* given <tt>videoElementId</tt>.
*
* @param videoElementId the id of local or remote video element.
*/
RemoteVideo.prototype.removeRemoteVideoMenu = function() {
const menuSpan = this.$container.find('.remotevideomenu');
2015-06-23 08:00:46 +00:00
if (menuSpan.length) {
ReactDOM.unmountComponentAtNode(menuSpan.get(0));
2015-06-23 08:00:46 +00:00
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(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<PresenceLabel participantID = { this.id } />
</I18nextProvider>
</Provider>,
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);
}
};
/**
* Callback invoked when the thumbnail is clicked. Will directly call
* VideoLayout to handle thumbnail click if certain elements have not been
* clicked.
*
* @param {MouseEvent} event - The click event to intercept.
* @private
* @returns {void}
*/
RemoteVideo.prototype._onContainerClick = function(event) {
const $source = $(event.target || event.srcElement);
const { classList } = event.target;
const ignoreClick = $source.parents('.popover').length > 0
|| classList.contains('popover');
if (!ignoreClick) {
this._togglePin();
}
// On IE we need to populate this handler on video <object> 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;
};
RemoteVideo.createContainer = function(spanId) {
const container = document.createElement('span');
2015-06-23 08:00:46 +00:00
container.id = spanId;
container.className = 'videocontainer';
2016-09-15 02:20:54 +00:00
container.innerHTML = `
<div class = 'videocontainer__background'></div>
<div class = 'videocontainer__toptoolbar'></div>
<div class = 'videocontainer__toolbar'></div>
<div class = 'videocontainer__hoverOverlay'></div>
<div class = 'displayNameContainer'></div>
<div class = 'avatar-container'></div>
<div class ='presence-label-container'></div>
<span class = 'remotevideomenu'></span>`;
const remotes = document.getElementById('filmstripRemoteVideosContainer');
2015-06-23 08:00:46 +00:00
return remotes.appendChild(container);
};
2015-12-14 12:26:50 +00:00
export default RemoteVideo;