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

624 lines
20 KiB
JavaScript

/* global $, APP */
/* eslint-disable no-unused-vars */
import Logger from 'jitsi-meet-logger';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { Avatar } from '../../../react/features/base/avatar';
import { i18next } from '../../../react/features/base/i18n';
import {
JitsiParticipantConnectionStatus
} from '../../../react/features/base/lib-jitsi-meet';
import { VIDEO_TYPE } from '../../../react/features/base/media';
import { CHAT_SIZE } from '../../../react/features/chat';
import {
updateKnownLargeVideoResolution
} from '../../../react/features/large-video';
import { PresenceLabel } from '../../../react/features/presence-status';
/* eslint-enable no-unused-vars */
import UIEvents from '../../../service/UI/UIEvents';
import { createDeferred } from '../../util/helpers';
import AudioLevels from '../audio_levels/AudioLevels';
import UIUtil from '../util/UIUtil';
import { VideoContainer, VIDEO_CONTAINER_TYPE } from './VideoContainer';
const logger = Logger.getLogger(__filename);
const DESKTOP_CONTAINER_TYPE = 'desktop';
/**
* Manager for all Large containers.
*/
export default class LargeVideoManager {
/**
* Checks whether given container is a {@link VIDEO_CONTAINER_TYPE}.
* FIXME currently this is a workaround for the problem where video type is
* mixed up with container type.
* @param {string} containerType
* @return {boolean}
*/
static isVideoContainer(containerType) {
return containerType === VIDEO_CONTAINER_TYPE
|| containerType === DESKTOP_CONTAINER_TYPE;
}
/**
*
*/
constructor(emitter) {
/**
* The map of <tt>LargeContainer</tt>s where the key is the video
* container type.
* @type {Object.<string, LargeContainer>}
*/
this.containers = {};
this.eventEmitter = emitter;
this.state = VIDEO_CONTAINER_TYPE;
// FIXME: We are passing resizeContainer as parameter which is calling
// Container.resize. Probably there's better way to implement this.
this.videoContainer = new VideoContainer(
() => this.resizeContainer(VIDEO_CONTAINER_TYPE), emitter);
this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer);
// use the same video container to handle desktop tracks
this.addContainer(DESKTOP_CONTAINER_TYPE, this.videoContainer);
this.width = 0;
this.height = 0;
/**
* Cache the aspect ratio of the video displayed to detect changes to
* the aspect ratio on video resize events.
*
* @type {number}
*/
this._videoAspectRatio = 0;
this.$container = $('#largeVideoContainer');
this.$container.css({
display: 'inline-block'
});
this.$container.hover(
e => this.onHoverIn(e),
e => this.onHoverOut(e)
);
// Bind event handler so it is only bound once for every instance.
this._onVideoResolutionUpdate
= this._onVideoResolutionUpdate.bind(this);
this.videoContainer.addResizeListener(this._onVideoResolutionUpdate);
this._dominantSpeakerAvatarContainer
= document.getElementById('dominantSpeakerAvatarContainer');
}
/**
* Removes any listeners registered on child components, including
* React Components.
*
* @returns {void}
*/
destroy() {
this.videoContainer.removeResizeListener(
this._onVideoResolutionUpdate);
this.removePresenceLabel();
ReactDOM.unmountComponentAtNode(this._dominantSpeakerAvatarContainer);
this.$container.css({ display: 'none' });
}
/**
*
*/
onHoverIn(e) {
if (!this.state) {
return;
}
const container = this.getCurrentContainer();
container.onHoverIn(e);
}
/**
*
*/
onHoverOut(e) {
if (!this.state) {
return;
}
const container = this.getCurrentContainer();
container.onHoverOut(e);
}
/**
*
*/
get id() {
const container = this.getCurrentContainer();
// If a user switch for large video is in progress then provide what
// will be the end result of the update.
if (this.updateInProcess
&& this.newStreamData
&& this.newStreamData.id !== container.id) {
return this.newStreamData.id;
}
return container.id;
}
/**
*
*/
scheduleLargeVideoUpdate() {
if (this.updateInProcess || !this.newStreamData) {
return;
}
this.updateInProcess = true;
// Include hide()/fadeOut only if we're switching between users
// eslint-disable-next-line eqeqeq
const container = this.getCurrentContainer();
const isUserSwitch = this.newStreamData.id !== container.id;
const preUpdate = isUserSwitch ? container.hide() : Promise.resolve();
preUpdate.then(() => {
const { id, stream, videoType, resolve } = this.newStreamData;
// FIXME this does not really make sense, because the videoType
// (camera or desktop) is a completely different thing than
// the video container type (Etherpad, SharedVideo, VideoContainer).
const isVideoContainer
= LargeVideoManager.isVideoContainer(videoType);
this.newStreamData = null;
logger.info(`hover in ${id}`);
this.state = videoType;
// eslint-disable-next-line no-shadow
const container = this.getCurrentContainer();
container.setStream(id, stream, videoType);
// change the avatar url on large
this.updateAvatar();
// If the user's connection is disrupted then the avatar will be
// displayed in case we have no video image cached. That is if
// there was a user switch (image is lost on stream detach) or if
// the video was not rendered, before the connection has failed.
const wasUsersImageCached
= !isUserSwitch && container.wasVideoRendered;
const isVideoMuted = !stream || stream.isMuted();
const connectionStatus
= APP.conference.getParticipantConnectionStatus(id);
const isVideoRenderable
= !isVideoMuted
&& (APP.conference.isLocalId(id)
|| connectionStatus
=== JitsiParticipantConnectionStatus.ACTIVE
|| wasUsersImageCached);
const showAvatar
= isVideoContainer
&& ((APP.conference.isAudioOnly() && videoType !== VIDEO_TYPE.DESKTOP) || !isVideoRenderable);
let promise;
// do not show stream if video is muted
// but we still should show watermark
if (showAvatar) {
this.showWatermark(true);
// If the intention of this switch is to show the avatar
// we need to make sure that the video is hidden
promise = container.hide();
} else {
promise = container.show();
}
// show the avatar on large if needed
container.showAvatar(showAvatar);
// Clean up audio level after previous speaker.
if (showAvatar) {
this.updateLargeVideoAudioLevel(0);
}
const messageKey
= connectionStatus === JitsiParticipantConnectionStatus.INACTIVE ? 'connection.LOW_BANDWIDTH' : null;
// Do not show connection status message in the audio only mode,
// because it's based on the video playback status.
const overrideAndHide = APP.conference.isAudioOnly();
this.updateParticipantConnStatusIndication(
id,
!overrideAndHide && messageKey);
// Change the participant id the presence label is listening to.
this.updatePresenceLabel(id);
this.videoContainer.positionRemoteStatusMessages();
// resolve updateLargeVideo promise after everything is done
promise.then(resolve);
return promise;
}).then(() => {
// after everything is done check again if there are any pending
// new streams.
this.updateInProcess = false;
this.eventEmitter.emit(UIEvents.LARGE_VIDEO_ID_CHANGED, this.id);
this.scheduleLargeVideoUpdate();
});
}
/**
* Shows/hides notification about participant's connectivity issues to be
* shown on the large video area.
*
* @param {string} id the id of remote participant(MUC nickname)
* @param {string|null} messageKey the i18n key of the message which will be
* displayed on the large video or <tt>null</tt> to hide it.
*
* @private
*/
updateParticipantConnStatusIndication(id, messageKey) {
if (messageKey) {
// Get user's display name
const displayName
= APP.conference.getParticipantDisplayName(id);
this._setRemoteConnectionMessage(
messageKey,
{ displayName });
// Show it now only if the VideoContainer is on top
this.showRemoteConnectionMessage(
LargeVideoManager.isVideoContainer(this.state));
} else {
// Hide the message
this.showRemoteConnectionMessage(false);
}
}
/**
* Update large video.
* Switches to large video even if previously other container was visible.
* @param userID the userID of the participant associated with the stream
* @param {JitsiTrack?} stream new stream
* @param {string?} videoType new video type
* @returns {Promise}
*/
updateLargeVideo(userID, stream, videoType) {
if (this.newStreamData) {
this.newStreamData.reject();
}
this.newStreamData = createDeferred();
this.newStreamData.id = userID;
this.newStreamData.stream = stream;
this.newStreamData.videoType = videoType;
this.scheduleLargeVideoUpdate();
return this.newStreamData.promise;
}
/**
* Update container size.
*/
updateContainerSize() {
let widthToUse = UIUtil.getAvailableVideoWidth();
const { isOpen } = APP.store.getState()['features/chat'];
if (isOpen) {
/**
* If chat state is open, we re-compute the container width
* by subtracting the default width of the chat.
*/
widthToUse -= CHAT_SIZE;
}
this.width = widthToUse;
this.height = window.innerHeight;
}
/**
* Resize Large container of specified type.
* @param {string} type type of container which should be resized.
* @param {boolean} [animate=false] if resize process should be animated.
*/
resizeContainer(type, animate = false) {
const container = this.getContainer(type);
container.resize(this.width, this.height, animate);
}
/**
* Resize all Large containers.
* @param {boolean} animate if resize process should be animated.
*/
resize(animate) {
// resize all containers
Object.keys(this.containers)
.forEach(type => this.resizeContainer(type, animate));
}
/**
* Updates the src of the dominant speaker avatar
*/
updateAvatar() {
ReactDOM.render(
<Provider store = { APP.store }>
<Avatar
id = "dominantSpeakerAvatar"
participantId = { this.id }
size = { 200 } />
</Provider>,
this._dominantSpeakerAvatarContainer
);
}
/**
* Updates the audio level indicator of the large video.
*
* @param lvl the new audio level to set
*/
updateLargeVideoAudioLevel(lvl) {
AudioLevels.updateLargeVideoAudioLevel('dominantSpeaker', lvl);
}
/**
* Displays a message of the passed in participant id's presence status. The
* message will not display if the remote connection message is displayed.
*
* @param {string} id - The participant ID whose associated user's presence
* status should be displayed.
* @returns {void}
*/
updatePresenceLabel(id) {
const isConnectionMessageVisible
= $('#remoteConnectionMessage').is(':visible');
if (isConnectionMessageVisible) {
this.removePresenceLabel();
return;
}
const presenceLabelContainer = $('#remotePresenceMessage');
if (presenceLabelContainer.length) {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<PresenceLabel
participantID = { id }
className = 'presence-label' />
</I18nextProvider>
</Provider>,
presenceLabelContainer.get(0));
}
}
/**
* Removes the messages about the displayed participant's presence status.
*
* @returns {void}
*/
removePresenceLabel() {
const presenceLabelContainer = $('#remotePresenceMessage');
if (presenceLabelContainer.length) {
ReactDOM.unmountComponentAtNode(presenceLabelContainer.get(0));
}
}
/**
* Show or hide watermark.
* @param {boolean} show
*/
showWatermark(show) {
$('.watermark').css('visibility', show ? 'visible' : 'hidden');
}
/**
* Shows hides the "avatar" message which is to be displayed either in
* the middle of the screen or below the avatar image.
*
* @param {boolean|undefined} [show=undefined] <tt>true</tt> to show
* the avatar message or <tt>false</tt> to hide it. If not provided then
* the connection status of the user currently on the large video will be
* obtained form "APP.conference" and the message will be displayed if
* the user's connection is either interrupted or inactive.
*/
showRemoteConnectionMessage(show) {
if (typeof show !== 'boolean') {
const connStatus
= APP.conference.getParticipantConnectionStatus(this.id);
// eslint-disable-next-line no-param-reassign
show = !APP.conference.isLocalId(this.id)
&& (connStatus === JitsiParticipantConnectionStatus.INTERRUPTED
|| connStatus
=== JitsiParticipantConnectionStatus.INACTIVE);
}
if (show) {
$('#remoteConnectionMessage').css({ display: 'block' });
} else {
$('#remoteConnectionMessage').hide();
}
}
/**
* Updates the text which describes that the remote user is having
* connectivity issues.
*
* @param {string} msgKey the translation key which will be used to get
* the message text.
* @param {object} msgOptions translation options object.
*
* @private
*/
_setRemoteConnectionMessage(msgKey, msgOptions) {
if (msgKey) {
$('#remoteConnectionMessage')
.attr('data-i18n', msgKey)
.attr('data-i18n-options', JSON.stringify(msgOptions));
APP.translation.translateElement(
$('#remoteConnectionMessage'), msgOptions);
}
}
/**
* Add container of specified type.
* @param {string} type container type
* @param {LargeContainer} container container to add.
*/
addContainer(type, container) {
if (this.containers[type]) {
throw new Error(`container of type ${type} already exist`);
}
this.containers[type] = container;
this.resizeContainer(type);
}
/**
* Get Large container of specified type.
* @param {string} type container type.
* @returns {LargeContainer}
*/
getContainer(type) {
const container = this.containers[type];
if (!container) {
throw new Error(`container of type ${type} doesn't exist`);
}
return container;
}
/**
* Returns {@link LargeContainer} for the current {@link state}
*
* @return {LargeContainer}
*
* @throws an <tt>Error</tt> if there is no container for the current
* {@link state}.
*/
getCurrentContainer() {
return this.getContainer(this.state);
}
/**
* Returns type of the current {@link LargeContainer}
* @return {string}
*/
getCurrentContainerType() {
return this.state;
}
/**
* Remove Large container of specified type.
* @param {string} type container type.
*/
removeContainer(type) {
if (!this.containers[type]) {
throw new Error(`container of type ${type} doesn't exist`);
}
delete this.containers[type];
}
/**
* Show Large container of specified type.
* Does nothing if such container is already visible.
* @param {string} type container type.
* @returns {Promise}
*/
showContainer(type) {
if (this.state === type) {
return Promise.resolve();
}
const oldContainer = this.containers[this.state];
// FIXME when video is being replaced with other content we need to hide
// companion icons/messages. It would be best if the container would
// be taking care of it by itself, but that is a bigger refactoring
if (LargeVideoManager.isVideoContainer(this.state)) {
this.showWatermark(false);
this.showRemoteConnectionMessage(false);
}
oldContainer.hide();
this.state = type;
const container = this.getContainer(type);
return container.show().then(() => {
if (LargeVideoManager.isVideoContainer(type)) {
// FIXME when video appears on top of other content we need to
// show companion icons/messages. It would be best if
// the container would be taking care of it by itself, but that
// is a bigger refactoring
this.showWatermark(true);
// "avatar" and "video connection" can not be displayed both
// at the same time, but the latter is of higher priority and it
// will hide the avatar one if will be displayed.
this.showRemoteConnectionMessage(/* fetch the current state */);
}
});
}
/**
* Changes the flipX state of the local video.
* @param val {boolean} true if flipped.
*/
onLocalFlipXChange(val) {
this.videoContainer.setLocalFlipX(val);
}
/**
* Dispatches an action to update the known resolution state of the large video and adjusts container sizes when the
* resolution changes.
*
* @private
* @returns {void}
*/
_onVideoResolutionUpdate() {
const { height, width } = this.videoContainer.getStreamSize();
const { resolution } = APP.store.getState()['features/large-video'];
if (height !== resolution) {
APP.store.dispatch(updateKnownLargeVideoResolution(height));
}
const currentAspectRatio = height === 0 ? 0 : width / height;
if (this._videoAspectRatio !== currentAspectRatio) {
this._videoAspectRatio = currentAspectRatio;
this.resize();
}
}
}