This commit is contained in:
234
modules/UI/UI.js
Normal file
234
modules/UI/UI.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/* global APP, config */
|
||||
|
||||
|
||||
const UI = {};
|
||||
|
||||
import Logger from '@jitsi/logger';
|
||||
|
||||
import {
|
||||
conferenceWillInit
|
||||
} from '../../react/features/base/conference/actions';
|
||||
import { isMobileBrowser } from '../../react/features/base/environment/utils';
|
||||
import { setColorAlpha } from '../../react/features/base/util/helpers';
|
||||
import { sanitizeUrl } from '../../react/features/base/util/uri';
|
||||
import { setDocumentUrl } from '../../react/features/etherpad/actions';
|
||||
import {
|
||||
setNotificationsEnabled,
|
||||
showNotification
|
||||
} from '../../react/features/notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE } from '../../react/features/notifications/constants';
|
||||
import { joinLeaveNotificationsDisabled } from '../../react/features/notifications/functions';
|
||||
import {
|
||||
dockToolbox,
|
||||
setToolboxEnabled,
|
||||
showToolbox
|
||||
} from '../../react/features/toolbox/actions.web';
|
||||
|
||||
import EtherpadManager from './etherpad/Etherpad';
|
||||
import UIUtil from './util/UIUtil';
|
||||
import VideoLayout from './videolayout/VideoLayout';
|
||||
|
||||
const logger = Logger.getLogger(__filename);
|
||||
|
||||
let etherpadManager;
|
||||
|
||||
/**
|
||||
* Indicates if we're currently in full screen mode.
|
||||
*
|
||||
* @return {boolean} {true} to indicate that we're currently in full screen
|
||||
* mode, {false} otherwise
|
||||
*/
|
||||
UI.isFullScreen = function() {
|
||||
return UIUtil.isFullScreen();
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize conference UI.
|
||||
*/
|
||||
UI.initConference = function() {
|
||||
UI.showToolbar();
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts the UI module and initializes all related components.
|
||||
*/
|
||||
UI.start = function() {
|
||||
APP.store.dispatch(conferenceWillInit());
|
||||
|
||||
if (isMobileBrowser()) {
|
||||
document.body.classList.add('mobile-browser');
|
||||
} else {
|
||||
document.body.classList.add('desktop-browser');
|
||||
}
|
||||
|
||||
if (config.backgroundAlpha !== undefined) {
|
||||
const backgroundColor = getComputedStyle(document.body).getPropertyValue('background-color');
|
||||
const alphaColor = setColorAlpha(backgroundColor, config.backgroundAlpha);
|
||||
|
||||
document.body.style.backgroundColor = alphaColor;
|
||||
}
|
||||
|
||||
if (config.iAmRecorder) {
|
||||
// in case of iAmSipGateway keep local video visible
|
||||
if (!config.iAmSipGateway) {
|
||||
APP.store.dispatch(setNotificationsEnabled(false));
|
||||
}
|
||||
|
||||
APP.store.dispatch(setToolboxEnabled(false));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles etherpad click.
|
||||
*/
|
||||
UI.onEtherpadClicked = function() {
|
||||
etherpadManager && etherpadManager.toggleEtherpad();
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function onResize() {
|
||||
VideoLayout.onResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup some DOM event listeners.
|
||||
*/
|
||||
UI.bindEvents = () => {
|
||||
// Resize and reposition videos in full screen mode.
|
||||
document.addEventListener('webkitfullscreenchange', onResize);
|
||||
document.addEventListener('mozfullscreenchange', onResize);
|
||||
document.addEventListener('fullscreenchange', onResize);
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unbind some DOM event listeners.
|
||||
*/
|
||||
UI.unbindEvents = () => {
|
||||
document.removeEventListener('webkitfullscreenchange', onResize);
|
||||
document.removeEventListener('mozfullscreenchange', onResize);
|
||||
document.removeEventListener('fullscreenchange', onResize);
|
||||
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
|
||||
/**
|
||||
* Setup and show Etherpad.
|
||||
* @param {string} name etherpad id
|
||||
*/
|
||||
UI.initEtherpad = name => {
|
||||
const { getState, dispatch } = APP.store;
|
||||
const configState = getState()['features/base/config'];
|
||||
const etherpadBaseUrl = sanitizeUrl(configState.etherpad_base);
|
||||
|
||||
if (etherpadManager || !etherpadBaseUrl || !name) {
|
||||
return;
|
||||
}
|
||||
logger.log('Etherpad is enabled');
|
||||
|
||||
etherpadManager = new EtherpadManager();
|
||||
|
||||
const url = new URL(name, etherpadBaseUrl);
|
||||
|
||||
dispatch(setDocumentUrl(url.toString()));
|
||||
|
||||
if (configState.openSharedDocumentOnJoin) {
|
||||
etherpadManager.toggleEtherpad();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the shared document manager object.
|
||||
* @return {EtherpadManager} the shared document manager object
|
||||
*/
|
||||
UI.getSharedDocumentManager = () => etherpadManager;
|
||||
|
||||
/**
|
||||
* Show user on UI.
|
||||
* @param {JitsiParticipant} user
|
||||
*/
|
||||
UI.addUser = function(user) {
|
||||
const status = user.getStatus();
|
||||
|
||||
if (status) {
|
||||
// FIXME: move updateUserStatus in participantPresenceChanged action
|
||||
UI.updateUserStatus(user, status);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the user status.
|
||||
*
|
||||
* @param {JitsiParticipant} user - The user which status we need to update.
|
||||
* @param {string} status - The new status.
|
||||
*/
|
||||
UI.updateUserStatus = (user, status) => {
|
||||
const reduxState = APP.store.getState() || {};
|
||||
const { calleeInfoVisible } = reduxState['features/invite'] || {};
|
||||
|
||||
// We hide status updates when join/leave notifications are disabled,
|
||||
// as jigasi is the component with statuses and they are seen as join/leave notifications.
|
||||
if (!status || calleeInfoVisible || joinLeaveNotificationsDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayName = user.getDisplayName();
|
||||
|
||||
APP.store.dispatch(showNotification({
|
||||
titleKey: `${displayName} connected`,
|
||||
descriptionKey: 'dialOut.statusMessage'
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.SHORT));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets muted video state for participant
|
||||
*/
|
||||
UI.setVideoMuted = function(id) {
|
||||
VideoLayout._updateLargeVideoIfDisplayed(id, true);
|
||||
|
||||
if (APP.conference.isLocalId(id)) {
|
||||
APP.conference.updateVideoIconEnabled();
|
||||
}
|
||||
};
|
||||
|
||||
UI.updateLargeVideo = (id, forceUpdate) => VideoLayout.updateLargeVideo(id, forceUpdate);
|
||||
|
||||
// Used by torture.
|
||||
UI.showToolbar = timeout => APP.store.dispatch(showToolbox(timeout));
|
||||
|
||||
// Used by torture.
|
||||
UI.dockToolbar = dock => APP.store.dispatch(dockToolbox(dock));
|
||||
|
||||
UI.handleLastNEndpoints = function(leavingIds, enteringIds) {
|
||||
VideoLayout.onLastNEndpointsChanged(leavingIds, enteringIds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update audio level visualization for specified user.
|
||||
* @param {string} id user id
|
||||
* @param {number} lvl audio level
|
||||
*/
|
||||
UI.setAudioLevel = (id, lvl) => VideoLayout.setAudioLevel(id, lvl);
|
||||
|
||||
/**
|
||||
* Returns the id of the current video shown on large.
|
||||
* Currently used by tests (torture).
|
||||
*/
|
||||
UI.getLargeVideoID = function() {
|
||||
return VideoLayout.getLargeVideoID();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the current video shown on large.
|
||||
* Currently used by tests (torture).
|
||||
*/
|
||||
UI.getLargeVideo = function() {
|
||||
return VideoLayout.getLargeVideo();
|
||||
};
|
||||
|
||||
// TODO: Export every function separately. For now there is no point of doing
|
||||
// this because we are importing everything.
|
||||
export default UI;
|
||||
17
modules/UI/UIErrors.js
Normal file
17
modules/UI/UIErrors.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* A list of all UI errors.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Indicates that a Feedback request is currently in progress.
|
||||
*
|
||||
* @type {{FEEDBACK_REQUEST_IN_PROGRESS: string}}
|
||||
*/
|
||||
export const FEEDBACK_REQUEST_IN_PROGRESS = 'FeedbackRequestInProgress';
|
||||
|
||||
/**
|
||||
* Indicated an attempted audio only screen share session with no audio track present
|
||||
*
|
||||
* @type {{AUDIO_ONLY_SCREEN_SHARE_NO_TRACK: string}}
|
||||
*/
|
||||
export const AUDIO_ONLY_SCREEN_SHARE_NO_TRACK = 'AudioOnlyScreenShareNoTrack';
|
||||
66
modules/UI/audio_levels/AudioLevels.js
Normal file
66
modules/UI/audio_levels/AudioLevels.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/* global interfaceConfig */
|
||||
|
||||
import UIUtil from '../util/UIUtil';
|
||||
|
||||
/**
|
||||
* Responsible for drawing audio levels.
|
||||
*/
|
||||
const AudioLevels = {
|
||||
/**
|
||||
* Updates the audio level of the large video.
|
||||
*
|
||||
* @param audioLevel the new audio level to set.
|
||||
*/
|
||||
updateLargeVideoAudioLevel(elementId, audioLevel) {
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (!UIUtil.isVisible(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let level = parseFloat(audioLevel);
|
||||
|
||||
level = isNaN(level) ? 0 : level;
|
||||
|
||||
let shadowElement = element.getElementsByClassName('dynamic-shadow');
|
||||
|
||||
if (shadowElement && shadowElement.length > 0) {
|
||||
shadowElement = shadowElement[0];
|
||||
}
|
||||
|
||||
shadowElement.style.boxShadow = this._updateLargeVideoShadow(level);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the large video shadow effect.
|
||||
*/
|
||||
_updateLargeVideoShadow(level) {
|
||||
const scale = 2;
|
||||
|
||||
// Internal circle audio level.
|
||||
const int = {
|
||||
level: level > 0.15 ? 20 : 0,
|
||||
color: interfaceConfig.AUDIO_LEVEL_PRIMARY_COLOR
|
||||
};
|
||||
|
||||
// External circle audio level.
|
||||
const ext = {
|
||||
level: parseFloat(
|
||||
((int.level * scale * level) + int.level).toFixed(0)),
|
||||
color: interfaceConfig.AUDIO_LEVEL_SECONDARY_COLOR
|
||||
};
|
||||
|
||||
// Internal blur.
|
||||
int.blur = int.level ? 2 : 0;
|
||||
|
||||
// External blur.
|
||||
ext.blur = ext.level ? 6 : 0;
|
||||
|
||||
return [
|
||||
`0 0 ${int.blur}px ${int.level}px ${int.color}`,
|
||||
`0 0 ${ext.blur}px ${ext.level}px ${ext.color}`
|
||||
].join(', ');
|
||||
}
|
||||
};
|
||||
|
||||
export default AudioLevels;
|
||||
186
modules/UI/etherpad/Etherpad.js
Normal file
186
modules/UI/etherpad/Etherpad.js
Normal file
@@ -0,0 +1,186 @@
|
||||
/* global APP, interfaceConfig */
|
||||
|
||||
import $ from 'jquery';
|
||||
|
||||
import { setDocumentEditingState } from '../../../react/features/etherpad/actions';
|
||||
import { getSharedDocumentUrl } from '../../../react/features/etherpad/functions';
|
||||
import { getToolboxHeight } from '../../../react/features/toolbox/functions.web';
|
||||
import Filmstrip from '../videolayout/Filmstrip';
|
||||
import LargeContainer from '../videolayout/LargeContainer';
|
||||
import VideoLayout from '../videolayout/VideoLayout';
|
||||
|
||||
|
||||
/**
|
||||
* Default Etherpad frame width.
|
||||
*/
|
||||
const DEFAULT_WIDTH = 640;
|
||||
|
||||
/**
|
||||
* Default Etherpad frame height.
|
||||
*/
|
||||
const DEFAULT_HEIGHT = 480;
|
||||
|
||||
const ETHERPAD_CONTAINER_TYPE = 'etherpad';
|
||||
|
||||
/**
|
||||
* Container for Etherpad iframe.
|
||||
*/
|
||||
class Etherpad extends LargeContainer {
|
||||
/**
|
||||
* Creates new Etherpad object
|
||||
*/
|
||||
constructor(url) {
|
||||
super();
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
|
||||
iframe.id = 'etherpadIFrame';
|
||||
iframe.src = url;
|
||||
iframe.style.border = 0;
|
||||
iframe.scrolling = 'no';
|
||||
iframe.width = DEFAULT_WIDTH;
|
||||
iframe.height = DEFAULT_HEIGHT;
|
||||
iframe.setAttribute('style', 'visibility: hidden;');
|
||||
|
||||
this.container.appendChild(iframe);
|
||||
|
||||
this.iframe = iframe;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get isOpen() {
|
||||
return Boolean(this.iframe);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get container() {
|
||||
return document.getElementById('etherpad');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
resize(containerWidth, containerHeight) {
|
||||
let height, width;
|
||||
|
||||
if (interfaceConfig.VERTICAL_FILMSTRIP) {
|
||||
height = containerHeight - getToolboxHeight();
|
||||
width = containerWidth - Filmstrip.getVerticalFilmstripWidth();
|
||||
} else {
|
||||
height = containerHeight - Filmstrip.getFilmstripHeight();
|
||||
width = containerWidth;
|
||||
}
|
||||
|
||||
$(this.iframe)
|
||||
.width(width)
|
||||
.height(height);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
show() {
|
||||
const $iframe = $(this.iframe);
|
||||
const $container = $(this.container);
|
||||
const self = this;
|
||||
|
||||
return new Promise(resolve => {
|
||||
$iframe.fadeIn(300, () => {
|
||||
self.bodyBackground = document.body.style.background;
|
||||
document.body.style.background = '#eeeeee';
|
||||
$iframe.css({ visibility: 'visible' });
|
||||
$container.css({ zIndex: 2 });
|
||||
|
||||
APP.store.dispatch(setDocumentEditingState(true));
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
hide() {
|
||||
const $iframe = $(this.iframe);
|
||||
const $container = $(this.container);
|
||||
|
||||
document.body.style.background = this.bodyBackground;
|
||||
|
||||
return new Promise(resolve => {
|
||||
$iframe.fadeOut(300, () => {
|
||||
$iframe.css({ visibility: 'hidden' });
|
||||
$container.css({ zIndex: 0 });
|
||||
|
||||
APP.store.dispatch(setDocumentEditingState(false));
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} do not switch on dominant speaker event if on stage.
|
||||
*/
|
||||
stayOnStage() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager of the Etherpad frame.
|
||||
*/
|
||||
export default class EtherpadManager {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
constructor() {
|
||||
this.etherpad = null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get isOpen() {
|
||||
return Boolean(this.etherpad);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
isVisible() {
|
||||
return VideoLayout.isLargeContainerTypeVisible(ETHERPAD_CONTAINER_TYPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new Etherpad frame.
|
||||
*/
|
||||
openEtherpad() {
|
||||
this.etherpad = new Etherpad(getSharedDocumentUrl(APP.store.getState));
|
||||
VideoLayout.addLargeVideoContainer(
|
||||
ETHERPAD_CONTAINER_TYPE,
|
||||
this.etherpad
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle Etherpad frame visibility.
|
||||
* Open new Etherpad frame if there is no Etherpad frame yet.
|
||||
*/
|
||||
toggleEtherpad() {
|
||||
if (!this.isOpen) {
|
||||
this.openEtherpad();
|
||||
}
|
||||
|
||||
const isVisible = this.isVisible();
|
||||
|
||||
VideoLayout.showLargeVideoContainer(
|
||||
ETHERPAD_CONTAINER_TYPE, !isVisible);
|
||||
|
||||
APP.store.dispatch(setDocumentEditingState(!isVisible));
|
||||
}
|
||||
}
|
||||
59
modules/UI/util/UIUtil.js
Normal file
59
modules/UI/util/UIUtil.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import $ from 'jquery';
|
||||
|
||||
/**
|
||||
* Created by hristo on 12/22/14.
|
||||
*/
|
||||
const UIUtil = {
|
||||
|
||||
/**
|
||||
* Escapes the given text.
|
||||
*/
|
||||
escapeHtml(unsafeText) {
|
||||
return $('<div/>').text(unsafeText)
|
||||
.html();
|
||||
},
|
||||
|
||||
/**
|
||||
* Inserts given child element as the first one into the container.
|
||||
* @param container the container to which new child element will be added
|
||||
* @param newChild the new element that will be inserted into the container
|
||||
*/
|
||||
prependChild(container, newChild) {
|
||||
const firstChild = container.childNodes[0];
|
||||
let result;
|
||||
|
||||
if (firstChild) {
|
||||
result = container.insertBefore(newChild, firstChild);
|
||||
} else {
|
||||
result = container.appendChild(newChild);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Indicates if we're currently in full screen mode.
|
||||
*
|
||||
* @return {boolean} {true} to indicate that we're currently in full screen
|
||||
* mode, {false} otherwise
|
||||
*/
|
||||
isFullScreen() {
|
||||
return Boolean(document.fullscreenElement
|
||||
|| document.mozFullScreenElement
|
||||
|| document.webkitFullscreenElement
|
||||
|| document.msFullscreenElement);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the given DOM element is currently visible. The offsetParent
|
||||
* will be null if the "display" property of the element or any of its
|
||||
* parent containers is set to "none". This method will NOT check the
|
||||
* visibility property though.
|
||||
* @param {el} The DOM element we'd like to check for visibility
|
||||
*/
|
||||
isVisible(el) {
|
||||
return el.offsetParent !== null;
|
||||
}
|
||||
};
|
||||
|
||||
export default UIUtil;
|
||||
34
modules/UI/videolayout/Filmstrip.js
Normal file
34
modules/UI/videolayout/Filmstrip.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/* global APP, interfaceConfig */
|
||||
|
||||
import {
|
||||
getVerticalFilmstripVisibleAreaWidth,
|
||||
isFilmstripVisible
|
||||
} from '../../../react/features/filmstrip/functions';
|
||||
|
||||
const Filmstrip = {
|
||||
/**
|
||||
* Returns the height of filmstrip
|
||||
* @returns {number} height
|
||||
*/
|
||||
getFilmstripHeight() {
|
||||
// FIXME Make it more clear the getFilmstripHeight check is used in
|
||||
// horizontal film strip mode for calculating how tall large video
|
||||
// display should be.
|
||||
if (isFilmstripVisible(APP.store) && !interfaceConfig.VERTICAL_FILMSTRIP) {
|
||||
return document.querySelector('.filmstrip')?.offsetHeight ?? 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the width of the vertical filmstrip if the filmstrip is visible and 0 otherwise.
|
||||
*
|
||||
* @returns {number} - The width of the vertical filmstrip if the filmstrip is visible and 0 otherwise.
|
||||
*/
|
||||
getVerticalFilmstripWidth() {
|
||||
return isFilmstripVisible(APP.store) ? getVerticalFilmstripVisibleAreaWidth() : 0;
|
||||
}
|
||||
};
|
||||
|
||||
export default Filmstrip;
|
||||
67
modules/UI/videolayout/LargeContainer.js
Normal file
67
modules/UI/videolayout/LargeContainer.js
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
/**
|
||||
* Base class for all Large containers which we can show.
|
||||
*/
|
||||
export default class LargeContainer {
|
||||
/* eslint-disable no-unused-vars, no-empty-function */
|
||||
/**
|
||||
* Show this container.
|
||||
* @returns Promise
|
||||
*/
|
||||
show() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide this container.
|
||||
* @returns Promise
|
||||
*/
|
||||
hide() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize this container.
|
||||
* @param {number} containerWidth available width
|
||||
* @param {number} containerHeight available height
|
||||
* @param {boolean} animate if container should animate it's resize process
|
||||
*/
|
||||
resize(containerWidth, containerHeight, animate) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for "hover in" events.
|
||||
*/
|
||||
onHoverIn(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for "hover out" events.
|
||||
*/
|
||||
onHoverOut(e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update video stream.
|
||||
* @param {string} userID
|
||||
* @param {JitsiTrack?} stream new stream
|
||||
* @param {string} videoType video type
|
||||
*/
|
||||
setStream(userID, stream, videoType) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide user avatar.
|
||||
* @param {boolean} show
|
||||
*/
|
||||
showAvatar(show) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether current container needs to be switched on dominant speaker event
|
||||
* when the container is on stage.
|
||||
* @return {boolean}
|
||||
*/
|
||||
stayOnStage() {
|
||||
}
|
||||
|
||||
/* eslint-enable no-unused-vars, no-empty-function */
|
||||
}
|
||||
780
modules/UI/videolayout/LargeVideoManager.js
Normal file
780
modules/UI/videolayout/LargeVideoManager.js
Normal file
@@ -0,0 +1,780 @@
|
||||
/* global APP */
|
||||
/* eslint-disable no-unused-vars */
|
||||
import Logger from '@jitsi/logger';
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { createScreenSharingIssueEvent } from '../../../react/features/analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../react/features/analytics/functions';
|
||||
import Avatar from '../../../react/features/base/avatar/components/Avatar';
|
||||
import theme from '../../../react/features/base/components/themes/participantsPaneTheme.json';
|
||||
import { getSsrcRewritingFeatureFlag } from '../../../react/features/base/config/functions.any';
|
||||
import i18next from '../../../react/features/base/i18n/i18next';
|
||||
import { JitsiTrackEvents } from '../../../react/features/base/lib-jitsi-meet';
|
||||
import { VIDEO_TYPE } from '../../../react/features/base/media/constants';
|
||||
import {
|
||||
getLocalParticipant,
|
||||
getParticipantById,
|
||||
getParticipantDisplayName,
|
||||
isLocalScreenshareParticipant,
|
||||
isScreenShareParticipant
|
||||
} from '../../../react/features/base/participants/functions';
|
||||
import { getHideSelfView } from '../../../react/features/base/settings/functions.any';
|
||||
import { trackStreamingStatusChanged } from '../../../react/features/base/tracks/actions.any';
|
||||
import { getVideoTrackByParticipant } from '../../../react/features/base/tracks/functions.any';
|
||||
import { CHAT_SIZE } from '../../../react/features/chat/constants';
|
||||
import {
|
||||
isTrackStreamingStatusActive,
|
||||
isTrackStreamingStatusInactive,
|
||||
isTrackStreamingStatusInterrupted
|
||||
} from '../../../react/features/connection-indicator/functions';
|
||||
import { FILMSTRIP_BREAKPOINT } from '../../../react/features/filmstrip/constants';
|
||||
import { getVerticalViewMaxWidth, isFilmstripResizable } from '../../../react/features/filmstrip/functions';
|
||||
import {
|
||||
updateKnownLargeVideoResolution
|
||||
} from '../../../react/features/large-video/actions';
|
||||
import { getParticipantsPaneOpen } from '../../../react/features/participants-pane/functions';
|
||||
import PresenceLabel from '../../../react/features/presence-status/components/PresenceLabel';
|
||||
import { shouldDisplayTileView } from '../../../react/features/video-layout/functions.any';
|
||||
/* eslint-enable no-unused-vars */
|
||||
import { createDeferred } from '../../util/helpers';
|
||||
import AudioLevels from '../audio_levels/AudioLevels';
|
||||
|
||||
import { VIDEO_CONTAINER_TYPE, VideoContainer } 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() {
|
||||
/**
|
||||
* The map of <tt>LargeContainer</tt>s where the key is the video
|
||||
* container type.
|
||||
* @type {Object.<string, LargeContainer>}
|
||||
*/
|
||||
this.containers = {};
|
||||
|
||||
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));
|
||||
this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer);
|
||||
|
||||
// use the same video container to handle desktop tracks
|
||||
this.addContainer(DESKTOP_CONTAINER_TYPE, this.videoContainer);
|
||||
|
||||
/**
|
||||
* The preferred width passed as an argument to {@link updateContainerSize}.
|
||||
*
|
||||
* @type {number|undefined}
|
||||
*/
|
||||
this.preferredWidth = undefined;
|
||||
|
||||
/**
|
||||
* The preferred height passed as an argument to {@link updateContainerSize}.
|
||||
*
|
||||
* @type {number|undefined}
|
||||
*/
|
||||
this.preferredHeight = undefined;
|
||||
|
||||
/**
|
||||
* The calculated width that will be used for the large video.
|
||||
* @type {number}
|
||||
*/
|
||||
this.width = 0;
|
||||
|
||||
/**
|
||||
* The calculated height that will be used for the large video.
|
||||
* @type {number}
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* The video track in effect.
|
||||
* This is used to add and remove listeners on track streaming status change.
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
this.videoTrack = undefined;
|
||||
|
||||
this.container = document.getElementById('largeVideoContainer');
|
||||
|
||||
this.container.style.display = 'inline-block';
|
||||
|
||||
this.container.addEventListener('mouseenter', e => this.onHoverIn(e));
|
||||
this.container.addEventListener('mouseleave', 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);
|
||||
|
||||
// Remove track streaming status listener.
|
||||
// TODO: when this class is converted to a function react component,
|
||||
// use a custom hook to update a local track streaming status.
|
||||
if (this.videoTrack && !this.videoTrack.local) {
|
||||
this.videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
|
||||
this.handleTrackStreamingStatusChanged);
|
||||
APP.store.dispatch(trackStreamingStatusChanged(this.videoTrack.jitsiTrack,
|
||||
this.videoTrack.jitsiTrack.getTrackStreamingStatus()));
|
||||
}
|
||||
|
||||
this.removePresenceLabel();
|
||||
|
||||
ReactDOM.unmountComponentAtNode(this._dominantSpeakerAvatarContainer);
|
||||
|
||||
this.container.style.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 if we're switching between users or between different sources of the same user.
|
||||
const container = this.getCurrentContainer();
|
||||
const isUserSwitch = container.id !== this.newStreamData.id
|
||||
|| container.stream?.getSourceName() !== this.newStreamData.stream?.getSourceName();
|
||||
const preUpdate = isUserSwitch ? container.hide() : Promise.resolve();
|
||||
|
||||
preUpdate.then(() => {
|
||||
const { id, stream, videoType, resolve } = this.newStreamData;
|
||||
|
||||
this.newStreamData = null;
|
||||
|
||||
const state = APP.store.getState();
|
||||
const shouldHideSelfView = getHideSelfView(state);
|
||||
const localId = getLocalParticipant(state)?.id;
|
||||
|
||||
|
||||
// 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);
|
||||
|
||||
logger.debug(`Scheduled large video update for ${id}`);
|
||||
this.state = videoType;
|
||||
// eslint-disable-next-line no-shadow
|
||||
const container = this.getCurrentContainer();
|
||||
|
||||
if (shouldHideSelfView && localId === id) {
|
||||
return container.hide();
|
||||
}
|
||||
|
||||
container.setStream(id, stream, videoType);
|
||||
|
||||
// change the avatar url on large
|
||||
this.updateAvatar();
|
||||
|
||||
const isVideoMuted = !stream || stream.isMuted();
|
||||
const participant = getParticipantById(state, id);
|
||||
const videoTrack = getVideoTrackByParticipant(state, participant);
|
||||
|
||||
// Remove track streaming status listener from the old track and add it to the new track,
|
||||
// in order to stop updating track streaming status for the old track and start it for the new track.
|
||||
// TODO: when this class is converted to a function react component,
|
||||
// use a custom hook to update a local track streaming status.
|
||||
if (this.videoTrack?.jitsiTrack?.getSourceName() !== videoTrack?.jitsiTrack?.getSourceName()
|
||||
|| this.videoTrack?.jitsiTrack?.isP2P !== videoTrack?.jitsiTrack?.isP2P) {
|
||||
// In the case where we switch from jvb to p2p when we need to switch the p2p and jvb track, they will be
|
||||
// with the same source name. In order to add the streaming status listener we need to check if the isP2P
|
||||
// flag is different. Without this check we won't have the correct stream status listener for the track.
|
||||
// Normally the Thumbnail and ConnectionIndicator components will update the streaming status the same way
|
||||
// and this may mask the problem. But if for some reason the update from the Thumbnail and
|
||||
// ConnectionIndicator components don't happen this may lead to showing the avatar instead of
|
||||
// the video because of the old track inactive streaming status.
|
||||
if (this.videoTrack && !this.videoTrack.local) {
|
||||
this.videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
|
||||
this.handleTrackStreamingStatusChanged);
|
||||
APP.store.dispatch(trackStreamingStatusChanged(this.videoTrack.jitsiTrack,
|
||||
this.videoTrack.jitsiTrack.getTrackStreamingStatus()));
|
||||
}
|
||||
|
||||
this.videoTrack = videoTrack;
|
||||
|
||||
if (this.videoTrack && !this.videoTrack.local) {
|
||||
this.videoTrack.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
|
||||
this.handleTrackStreamingStatusChanged);
|
||||
APP.store.dispatch(trackStreamingStatusChanged(this.videoTrack.jitsiTrack,
|
||||
this.videoTrack.jitsiTrack.getTrackStreamingStatus()));
|
||||
}
|
||||
}
|
||||
const streamingStatusActive = isTrackStreamingStatusActive(videoTrack);
|
||||
const isVideoRenderable = !isVideoMuted
|
||||
&& (APP.conference.isLocalId(id)
|
||||
|| isLocalScreenshareParticipant(participant)
|
||||
|| streamingStatusActive
|
||||
);
|
||||
|
||||
const isAudioOnly = APP.conference.isAudioOnly();
|
||||
|
||||
// Multi-stream is not supported on plan-b endpoints even if its is enabled via config.js. A virtual
|
||||
// screenshare tile is still created when a remote endpoint starts screenshare to keep the behavior
|
||||
// consistent and an avatar is displayed on the original participant thumbnail as long as screenshare is in
|
||||
// progress.
|
||||
const legacyScreenshare = videoType === VIDEO_TYPE.DESKTOP && !isScreenShareParticipant(participant);
|
||||
|
||||
const showAvatar
|
||||
= isVideoContainer
|
||||
&& ((isAudioOnly && videoType !== VIDEO_TYPE.DESKTOP) || !isVideoRenderable || legacyScreenshare);
|
||||
|
||||
logger.debug(`scheduleLargeVideoUpdate: Remote track ${videoTrack?.jitsiTrack}, isVideoMuted=${
|
||||
isVideoMuted}, streamingStatusActive=${streamingStatusActive}, isVideoRenderable=${
|
||||
isVideoRenderable}, showAvatar=${showAvatar}`);
|
||||
|
||||
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();
|
||||
|
||||
if ((!shouldDisplayTileView(state) || participant?.pinned) // In theory the tile view may not be
|
||||
// enabled yet when we auto pin the participant.
|
||||
|
||||
&& participant && !participant.local && !participant.fakeParticipant) {
|
||||
// remote participant only
|
||||
|
||||
const track = getVideoTrackByParticipant(state, participant);
|
||||
|
||||
const isScreenSharing = track?.videoType === 'desktop';
|
||||
|
||||
if (isScreenSharing) {
|
||||
// send the event
|
||||
sendAnalytics(createScreenSharingIssueEvent({
|
||||
source: 'large-video',
|
||||
isVideoMuted,
|
||||
isAudioOnly,
|
||||
isVideoContainer,
|
||||
videoType
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
} 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 = isTrackStreamingStatusInactive(videoTrack) ? '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.scheduleLargeVideoUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle track streaming status change event by
|
||||
* by dispatching an action to update track streaming status for the given track in app state.
|
||||
*
|
||||
* @param {JitsiTrack} jitsiTrack the track with streaming status updated
|
||||
* @param {JitsiTrackStreamingStatus} streamingStatus the updated track streaming status
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
handleTrackStreamingStatusChanged(jitsiTrack, streamingStatus) {
|
||||
APP.store.dispatch(trackStreamingStatusChanged(jitsiTrack, streamingStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const state = APP.store.getState();
|
||||
|
||||
if (messageKey) {
|
||||
// Get user's display name
|
||||
const displayName
|
||||
= getParticipantDisplayName(state, 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(width, height) {
|
||||
if (typeof width === 'number') {
|
||||
this.preferredWidth = width;
|
||||
}
|
||||
if (typeof height === 'number') {
|
||||
this.preferredHeight = height;
|
||||
}
|
||||
|
||||
let widthToUse = this.preferredWidth || window.innerWidth;
|
||||
const state = APP.store.getState();
|
||||
const { isOpen } = state['features/chat'];
|
||||
const { width: filmstripWidth, visible } = state['features/filmstrip'];
|
||||
const isParticipantsPaneOpen = getParticipantsPaneOpen(state);
|
||||
const resizableFilmstrip = isFilmstripResizable(state);
|
||||
|
||||
if (isParticipantsPaneOpen) {
|
||||
widthToUse -= theme.participantsPaneWidth;
|
||||
}
|
||||
|
||||
if (isOpen && window.innerWidth > 580) {
|
||||
/**
|
||||
* If chat state is open, we re-compute the container width
|
||||
* by subtracting the chat width, which may be resized by the user.
|
||||
*/
|
||||
const chatWidth = state['features/chat'].width?.current ?? CHAT_SIZE;
|
||||
|
||||
widthToUse -= chatWidth;
|
||||
}
|
||||
|
||||
if (resizableFilmstrip && visible && filmstripWidth.current >= FILMSTRIP_BREAKPOINT) {
|
||||
widthToUse -= getVerticalViewMaxWidth(state);
|
||||
}
|
||||
|
||||
this.width = widthToUse;
|
||||
this.height = this.preferredHeight || 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 = getComputedStyle(
|
||||
document.getElementById('remoteConnectionMessage')).display !== 'none';
|
||||
|
||||
if (isConnectionMessageVisible) {
|
||||
this.removePresenceLabel();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const presenceLabelContainer = document.getElementById('remotePresenceMessage');
|
||||
|
||||
if (presenceLabelContainer) {
|
||||
ReactDOM.render(
|
||||
<Provider store = { APP.store }>
|
||||
<I18nextProvider i18n = { i18next }>
|
||||
<PresenceLabel
|
||||
participantID = { id }
|
||||
className = 'presence-label' />
|
||||
</I18nextProvider>
|
||||
</Provider>,
|
||||
presenceLabelContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the messages about the displayed participant's presence status.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
removePresenceLabel() {
|
||||
const presenceLabelContainer = document.getElementById('remotePresenceMessage');
|
||||
|
||||
if (presenceLabelContainer) {
|
||||
ReactDOM.unmountComponentAtNode(presenceLabelContainer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide watermark.
|
||||
* @param {boolean} show
|
||||
*/
|
||||
showWatermark(show) {
|
||||
const watermark = document.querySelectorAll('.watermark');
|
||||
|
||||
watermark.forEach(el => {
|
||||
el.style.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 participant = getParticipantById(APP.store.getState(), this.id);
|
||||
const state = APP.store.getState();
|
||||
const videoTrack = getVideoTrackByParticipant(state, participant);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
show = !APP.conference.isLocalId(this.id)
|
||||
&& (isTrackStreamingStatusInterrupted(videoTrack) || isTrackStreamingStatusInactive(videoTrack));
|
||||
}
|
||||
|
||||
if (show) {
|
||||
document.getElementById('remoteConnectionMessage').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('remoteConnectionMessage').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
676
modules/UI/videolayout/VideoContainer.js
Normal file
676
modules/UI/videolayout/VideoContainer.js
Normal file
@@ -0,0 +1,676 @@
|
||||
/* global APP, interfaceConfig */
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
import Logger from '@jitsi/logger';
|
||||
import $ from 'jquery';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { browser } from '../../../react/features/base/lib-jitsi-meet';
|
||||
import { FILMSTRIP_BREAKPOINT } from '../../../react/features/filmstrip/constants';
|
||||
import { setLargeVideoDimensions } from '../../../react/features/large-video/actions.any';
|
||||
import { LargeVideoBackground, ORIENTATION } from '../../../react/features/large-video/components/LargeVideoBackground';
|
||||
import { LAYOUTS } from '../../../react/features/video-layout/constants';
|
||||
import { getCurrentLayout } from '../../../react/features/video-layout/functions.any';
|
||||
/* eslint-enable no-unused-vars */
|
||||
import UIUtil from '../util/UIUtil';
|
||||
|
||||
import Filmstrip from './Filmstrip';
|
||||
import LargeContainer from './LargeContainer';
|
||||
|
||||
// FIXME should be 'video'
|
||||
export const VIDEO_CONTAINER_TYPE = 'camera';
|
||||
|
||||
// Corresponds to animation duration from the animatedFadeIn and animatedFadeOut CSS classes.
|
||||
const FADE_DURATION_MS = 300;
|
||||
|
||||
const logger = Logger.getLogger(__filename);
|
||||
|
||||
/**
|
||||
* List of container events that we are going to process for the large video.
|
||||
*
|
||||
* NOTE: Currently used only for logging for debug purposes.
|
||||
*/
|
||||
const containerEvents = [ 'abort', 'canplaythrough', 'ended', 'error', 'stalled', 'suspend', 'waiting' ];
|
||||
|
||||
/**
|
||||
* Returns an array of the video dimensions, so that it keeps it's aspect
|
||||
* ratio and fits available area with it's larger dimension. This method
|
||||
* ensures that whole video will be visible and can leave empty areas.
|
||||
*
|
||||
* @param videoWidth the width of the video to position
|
||||
* @param videoHeight the height of the video to position
|
||||
* @param videoSpaceWidth the width of the available space
|
||||
* @param videoSpaceHeight the height of the available space
|
||||
* @param subtractFilmstrip whether to subtract the filmstrip or not
|
||||
* @return an array with 2 elements, the video width and the video height
|
||||
*/
|
||||
function computeDesktopVideoSize( // eslint-disable-line max-params
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
videoSpaceWidth,
|
||||
videoSpaceHeight,
|
||||
subtractFilmstrip) {
|
||||
if (videoWidth === 0 || videoHeight === 0 || videoSpaceWidth === 0 || videoSpaceHeight === 0) {
|
||||
// Avoid NaN values caused by division by 0.
|
||||
return [ 0, 0 ];
|
||||
}
|
||||
|
||||
const aspectRatio = videoWidth / videoHeight;
|
||||
let availableWidth = Math.max(videoWidth, videoSpaceWidth);
|
||||
let availableHeight = Math.max(videoHeight, videoSpaceHeight);
|
||||
|
||||
if (interfaceConfig.VERTICAL_FILMSTRIP) {
|
||||
if (subtractFilmstrip) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
videoSpaceWidth -= Filmstrip.getVerticalFilmstripWidth();
|
||||
}
|
||||
} else {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
videoSpaceHeight -= Filmstrip.getFilmstripHeight();
|
||||
}
|
||||
|
||||
if (availableWidth / aspectRatio >= videoSpaceHeight) {
|
||||
availableHeight = videoSpaceHeight;
|
||||
availableWidth = availableHeight * aspectRatio;
|
||||
}
|
||||
|
||||
if (availableHeight * aspectRatio >= videoSpaceWidth) {
|
||||
availableWidth = videoSpaceWidth;
|
||||
availableHeight = availableWidth / aspectRatio;
|
||||
}
|
||||
|
||||
return [ availableWidth, availableHeight ];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of the video dimensions. It respects the
|
||||
* VIDEO_LAYOUT_FIT config, to fit the video to the screen, by hiding some parts
|
||||
* of it, or to fit it to the height or width.
|
||||
*
|
||||
* @param videoWidth the original video width
|
||||
* @param videoHeight the original video height
|
||||
* @param videoSpaceWidth the width of the video space
|
||||
* @param videoSpaceHeight the height of the video space
|
||||
* @return an array with 2 elements, the video width and the video height
|
||||
*/
|
||||
function computeCameraVideoSize( // eslint-disable-line max-params
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
videoSpaceWidth,
|
||||
videoSpaceHeight,
|
||||
videoLayoutFit) {
|
||||
if (videoWidth === 0 || videoHeight === 0 || videoSpaceWidth === 0 || videoSpaceHeight === 0) {
|
||||
// Avoid NaN values caused by division by 0.
|
||||
return [ 0, 0 ];
|
||||
}
|
||||
|
||||
const aspectRatio = videoWidth / videoHeight;
|
||||
const videoSpaceRatio = videoSpaceWidth / videoSpaceHeight;
|
||||
|
||||
switch (videoLayoutFit) {
|
||||
case 'height':
|
||||
return [ videoSpaceHeight * aspectRatio, videoSpaceHeight ];
|
||||
case 'width':
|
||||
return [ videoSpaceWidth, videoSpaceWidth / aspectRatio ];
|
||||
case 'nocrop':
|
||||
return computeCameraVideoSize(
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
videoSpaceWidth,
|
||||
videoSpaceHeight,
|
||||
videoSpaceRatio < aspectRatio ? 'width' : 'height');
|
||||
case 'both': {
|
||||
const maxZoomCoefficient = interfaceConfig.MAXIMUM_ZOOMING_COEFFICIENT
|
||||
|| Infinity;
|
||||
|
||||
if (videoSpaceRatio === aspectRatio) {
|
||||
return [ videoSpaceWidth, videoSpaceHeight ];
|
||||
}
|
||||
|
||||
let [ width, height ] = computeCameraVideoSize(
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
videoSpaceWidth,
|
||||
videoSpaceHeight,
|
||||
videoSpaceRatio < aspectRatio ? 'height' : 'width');
|
||||
const maxWidth = videoSpaceWidth * maxZoomCoefficient;
|
||||
const maxHeight = videoSpaceHeight * maxZoomCoefficient;
|
||||
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
height = width / aspectRatio;
|
||||
} else if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
|
||||
return [ width, height ];
|
||||
}
|
||||
default:
|
||||
return [ videoWidth, videoHeight ];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the video horizontal and vertical indents,
|
||||
* so that if fits its parent.
|
||||
*
|
||||
* @return an array with 2 elements, the horizontal indent and the vertical
|
||||
* indent
|
||||
*/
|
||||
function getCameraVideoPosition( // eslint-disable-line max-params
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
videoSpaceWidth,
|
||||
videoSpaceHeight) {
|
||||
// Parent height isn't completely calculated when we position the video in
|
||||
// full screen mode and this is why we use the screen height in this case.
|
||||
// Need to think it further at some point and implement it properly.
|
||||
if (UIUtil.isFullScreen()) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
videoSpaceHeight = window.innerHeight;
|
||||
}
|
||||
|
||||
const horizontalIndent = (videoSpaceWidth - videoWidth) / 2;
|
||||
const verticalIndent = (videoSpaceHeight - videoHeight) / 2;
|
||||
|
||||
return { horizontalIndent,
|
||||
verticalIndent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for user video.
|
||||
*/
|
||||
export class VideoContainer extends LargeContainer {
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get video() {
|
||||
return document.getElementById('largeVideo');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
get id() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new VideoContainer instance.
|
||||
* @param resizeContainer {Function} function that takes care of the size
|
||||
* of the video container.
|
||||
*/
|
||||
constructor(resizeContainer) {
|
||||
super();
|
||||
this.stream = null;
|
||||
this.userId = null;
|
||||
this.videoType = null;
|
||||
this.localFlipX = true;
|
||||
this.resizeContainer = resizeContainer;
|
||||
|
||||
/**
|
||||
* Whether the background should fit the height of the container
|
||||
* (portrait) or fit the width of the container (landscape).
|
||||
*
|
||||
* @private
|
||||
* @type {string|null}
|
||||
*/
|
||||
this._backgroundOrientation = null;
|
||||
|
||||
/**
|
||||
* Flag indicates whether or not the background should be rendered.
|
||||
* If the background will not be visible then it is hidden to save
|
||||
* on performance.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this._hideBackground = true;
|
||||
|
||||
this._isHidden = false;
|
||||
|
||||
/**
|
||||
* Flag indicates whether or not the avatar is currently displayed.
|
||||
* @type {boolean}
|
||||
*/
|
||||
this.avatarDisplayed = false;
|
||||
this.avatar = document.getElementById('dominantSpeaker');
|
||||
|
||||
/**
|
||||
* The HTMLElements of the remote connection message.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
this.remoteConnectionMessage = document.getElementById('remoteConnectionMessage');
|
||||
this.remotePresenceMessage = document.getElementById('remotePresenceMessage');
|
||||
|
||||
this.$wrapper = $('#largeVideoWrapper');
|
||||
|
||||
this.wrapperParent = document.getElementById('largeVideoElementsContainer');
|
||||
this.avatarHeight = document.getElementById('dominantSpeakerAvatarContainer').getBoundingClientRect().height;
|
||||
this.video.onplaying = function(event) {
|
||||
logger.debug('Large video is playing!');
|
||||
if (typeof resizeContainer === 'function') {
|
||||
resizeContainer(event);
|
||||
}
|
||||
};
|
||||
|
||||
containerEvents.forEach(event => {
|
||||
this.video.addEventListener(event, () => {
|
||||
logger.debug(`${event} handler was called for the large video.`);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A Set of functions to invoke when the video element resizes.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
this._resizeListeners = new Set();
|
||||
|
||||
this.video.onresize = this._onResize.bind(this);
|
||||
this._play = this._play.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a function to the known subscribers of video element resize
|
||||
* events.
|
||||
*
|
||||
* @param {Function} callback - The subscriber to notify when the video
|
||||
* element resizes.
|
||||
* @returns {void}
|
||||
*/
|
||||
addResizeListener(callback) {
|
||||
this._resizeListeners.add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains media stream ID of the underlying {@link JitsiTrack}.
|
||||
* @return {string|null}
|
||||
*/
|
||||
getStreamID() {
|
||||
return this.stream ? this.stream.getId() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get size of video element.
|
||||
* @returns {{width, height}}
|
||||
*/
|
||||
getStreamSize() {
|
||||
const video = this.video;
|
||||
|
||||
|
||||
return {
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal video size for specified container size.
|
||||
* @param {number} containerWidth container width
|
||||
* @param {number} containerHeight container height
|
||||
* @param {number} verticalFilmstripWidth current width of the vertical filmstrip
|
||||
* @returns {{availableWidth, availableHeight}}
|
||||
*/
|
||||
_getVideoSize(containerWidth, containerHeight, verticalFilmstripWidth) {
|
||||
const { width, height } = this.getStreamSize();
|
||||
|
||||
if (this.stream && this.isScreenSharing()) {
|
||||
return computeDesktopVideoSize(width,
|
||||
height,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
verticalFilmstripWidth < FILMSTRIP_BREAKPOINT);
|
||||
}
|
||||
|
||||
return computeCameraVideoSize(width,
|
||||
height,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
interfaceConfig.VIDEO_LAYOUT_FIT);
|
||||
}
|
||||
|
||||
/* eslint-disable max-params */
|
||||
/**
|
||||
* Calculate optimal video position (offset for top left corner)
|
||||
* for specified video size and container size.
|
||||
* @param {number} width video width
|
||||
* @param {number} height video height
|
||||
* @param {number} containerWidth container width
|
||||
* @param {number} containerHeight container height
|
||||
* @param {number} verticalFilmstripWidth current width of the vertical filmstrip
|
||||
* @returns {{horizontalIndent, verticalIndent}}
|
||||
*/
|
||||
getVideoPosition(width, height, containerWidth, containerHeight, verticalFilmstripWidth) {
|
||||
let containerWidthToUse = containerWidth;
|
||||
|
||||
/* eslint-enable max-params */
|
||||
if (this.stream && this.isScreenSharing()) {
|
||||
if (interfaceConfig.VERTICAL_FILMSTRIP && verticalFilmstripWidth < FILMSTRIP_BREAKPOINT) {
|
||||
containerWidthToUse -= Filmstrip.getVerticalFilmstripWidth();
|
||||
}
|
||||
|
||||
return getCameraVideoPosition(width,
|
||||
height,
|
||||
containerWidthToUse,
|
||||
containerHeight);
|
||||
}
|
||||
|
||||
return getCameraVideoPosition(width,
|
||||
height,
|
||||
containerWidthToUse,
|
||||
containerHeight);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the positioning of the remote connection presence message and the
|
||||
* connection status message which escribes that the remote user is having
|
||||
* connectivity issues.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
positionRemoteStatusMessages() {
|
||||
this._positionParticipantStatus(this.remoteConnectionMessage);
|
||||
this._positionParticipantStatus(this.remotePresenceMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the position of the passed in jQuery object so it displays
|
||||
* in the middle of the video container or below the avatar.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_positionParticipantStatus(element) {
|
||||
if (this.avatarDisplayed) {
|
||||
const avatarImage = document.getElementById('dominantSpeakerAvatarContainer').getBoundingClientRect();
|
||||
|
||||
element.style.top = avatarImage.top + avatarImage.height + 10;
|
||||
} else {
|
||||
const height = element.getBoundingClientRect().height;
|
||||
const parentHeight = element.parentElement.getBoundingClientRect().height;
|
||||
|
||||
element.style.top = (parentHeight / 2) - (height / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
resize(containerWidth, containerHeight, animate = false) {
|
||||
// XXX Prevent TypeError: undefined is not an object when the Web
|
||||
// browser does not support WebRTC (yet).
|
||||
if (!this.video) {
|
||||
return;
|
||||
}
|
||||
const state = APP.store.getState();
|
||||
const currentLayout = getCurrentLayout(state);
|
||||
|
||||
const verticalFilmstripWidth = state['features/filmstrip'].width?.current;
|
||||
|
||||
if (currentLayout === LAYOUTS.TILE_VIEW || currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW) {
|
||||
// We don't need to resize the large video since it won't be displayed and we'll resize when returning back
|
||||
// to stage view.
|
||||
return;
|
||||
}
|
||||
|
||||
this.positionRemoteStatusMessages();
|
||||
|
||||
const [ width, height ] = this._getVideoSize(containerWidth, containerHeight, verticalFilmstripWidth);
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
// We don't need to set 0 for width or height since the visibility is controlled by the visibility css prop
|
||||
// on the largeVideoElementsContainer. Also if the width/height of the video element is 0 the attached
|
||||
// stream won't be played. Normally if we attach a new stream we won't resize the video element until the
|
||||
// stream has been played. But setting width/height to 0 will prevent the video from playing.
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((containerWidth > width) || (containerHeight > height)) {
|
||||
this._backgroundOrientation = containerWidth > width ? ORIENTATION.LANDSCAPE : ORIENTATION.PORTRAIT;
|
||||
this._hideBackground = false;
|
||||
} else {
|
||||
this._hideBackground = true;
|
||||
}
|
||||
|
||||
this._updateBackground();
|
||||
|
||||
const { horizontalIndent, verticalIndent }
|
||||
= this.getVideoPosition(width, height, containerWidth, containerHeight, verticalFilmstripWidth);
|
||||
|
||||
APP.store.dispatch(setLargeVideoDimensions(height, width));
|
||||
|
||||
this.$wrapper.animate({
|
||||
width,
|
||||
height,
|
||||
|
||||
top: verticalIndent,
|
||||
bottom: verticalIndent,
|
||||
|
||||
left: horizontalIndent,
|
||||
right: horizontalIndent
|
||||
}, {
|
||||
queue: false,
|
||||
duration: animate ? 500 : 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a function from the known subscribers of video element resize
|
||||
* events.
|
||||
*
|
||||
* @param {Function} callback - The callback to remove from known
|
||||
* subscribers of video resize events.
|
||||
* @returns {void}
|
||||
*/
|
||||
removeResizeListener(callback) {
|
||||
this._resizeListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the large video element.
|
||||
*
|
||||
* @param {number} retries - Number of retries to play the large video if play fails.
|
||||
* @returns {void}
|
||||
*/
|
||||
_play(retries = 0) {
|
||||
this.video.play()
|
||||
.then(() => {
|
||||
logger.debug(`Successfully played large video after ${retries + 1} retries!`);
|
||||
})
|
||||
.catch(e => {
|
||||
if (retries < 3) {
|
||||
logger.debug(`Error while trying to playing the large video. Will retry after 1s. Retries: ${
|
||||
retries}. Error: ${e}`);
|
||||
window.setTimeout(() => {
|
||||
this._play(retries + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
logger.error(`Error while trying to playing the large video after 3 retries: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update video stream.
|
||||
* @param {string} userID
|
||||
* @param {JitsiTrack?} stream new stream
|
||||
* @param {string} videoType video type
|
||||
*/
|
||||
setStream(userID, stream, videoType) {
|
||||
if (this.userId === userID && this.stream === stream && !stream?.forceStreamToReattach) {
|
||||
logger.debug(`SetStream on the large video for user ${userID} ignored: the stream is not changed!`);
|
||||
|
||||
// Handles the use case for the remote participants when the
|
||||
// videoType is received with delay after turning on/off the
|
||||
// desktop sharing.
|
||||
if (this.videoType !== videoType) {
|
||||
this.videoType = videoType;
|
||||
this.resizeContainer();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.userId = userID;
|
||||
|
||||
if (stream?.forceStreamToReattach) {
|
||||
delete stream.forceStreamToReattach;
|
||||
}
|
||||
|
||||
// detach old stream
|
||||
if (this.stream && this.video) {
|
||||
this.stream.detach(this.video);
|
||||
}
|
||||
|
||||
this.stream = stream;
|
||||
this.videoType = videoType;
|
||||
|
||||
if (!stream) {
|
||||
logger.debug('SetStream on the large video is called without a stream argument!');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.video) {
|
||||
logger.debug(`Attaching a remote track to the large video for user ${userID}`);
|
||||
stream.attach(this.video).catch(error => {
|
||||
logger.error(`Attaching the remote track ${stream} to large video has failed with `, error);
|
||||
});
|
||||
|
||||
// Ensure large video gets play() called on it when a new stream is attached to it.
|
||||
this._play();
|
||||
|
||||
const flipX = stream.isLocal() && this.localFlipX && !this.isScreenSharing();
|
||||
|
||||
this.video.style.transform = flipX ? 'scaleX(-1)' : 'none';
|
||||
this._updateBackground();
|
||||
} else {
|
||||
logger.debug(`SetStream on the large video won't attach a track for ${
|
||||
userID} because no large video element was found!`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the flipX state of the local video.
|
||||
* @param val {boolean} true if flipped.
|
||||
*/
|
||||
setLocalFlipX(val) {
|
||||
this.localFlipX = val;
|
||||
if (!this.video || !this.stream || !this.stream.isLocal() || this.isScreenSharing()) {
|
||||
return;
|
||||
}
|
||||
this.video.style.transform = this.localFlipX ? 'scaleX(-1)' : 'none';
|
||||
|
||||
this._updateBackground();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if current video stream is screen sharing.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isScreenSharing() {
|
||||
return this.videoType === 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide user avatar.
|
||||
* @param {boolean} show
|
||||
*/
|
||||
showAvatar(show) {
|
||||
this.avatar.style.visibility = show ? 'visible' : 'hidden';
|
||||
this.avatarDisplayed = show;
|
||||
|
||||
APP.API.notifyLargeVideoVisibilityChanged(show);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show video container.
|
||||
*/
|
||||
show() {
|
||||
return new Promise(resolve => {
|
||||
this.wrapperParent.style.visibility = 'visible';
|
||||
this.wrapperParent.classList.remove('animatedFadeOut');
|
||||
this.wrapperParent.classList.add('animatedFadeIn');
|
||||
setTimeout(() => {
|
||||
this._isHidden = false;
|
||||
this._updateBackground();
|
||||
resolve();
|
||||
}, FADE_DURATION_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide video container.
|
||||
*/
|
||||
hide() {
|
||||
// as the container is hidden/replaced by another container
|
||||
// hide its avatar
|
||||
this.showAvatar(false);
|
||||
|
||||
return new Promise(resolve => {
|
||||
this.wrapperParent.classList.remove('animatedFadeIn');
|
||||
this.wrapperParent.classList.add('animatedFadeOut');
|
||||
setTimeout(() => {
|
||||
this.wrapperParent.style.visibility = 'hidden';
|
||||
this._isHidden = true;
|
||||
this._updateBackground();
|
||||
resolve();
|
||||
}, FADE_DURATION_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean} switch on dominant speaker event if on stage.
|
||||
*/
|
||||
stayOnStage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked when the video element changes dimensions.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onResize() {
|
||||
this._resizeListeners.forEach(callback => callback());
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches and/or updates a React Component to be used as a background for
|
||||
* the large video, to display blurred video and fill up empty space not
|
||||
* taken up by the large video.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateBackground() {
|
||||
// Do not the background display on browsers that might experience
|
||||
// performance issues from the presence of the background or if
|
||||
// explicitly disabled.
|
||||
if (interfaceConfig.DISABLE_VIDEO_BACKGROUND
|
||||
|| browser.isFirefox()
|
||||
|| browser.isWebKitBased()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<LargeVideoBackground
|
||||
hidden = { this._hideBackground || this._isHidden }
|
||||
mirror = {
|
||||
this.stream
|
||||
&& this.stream.isLocal()
|
||||
&& this.localFlipX
|
||||
}
|
||||
orientationFit = { this._backgroundOrientation }
|
||||
videoElement = { this.video }
|
||||
videoTrack = { this.stream } />,
|
||||
document.getElementById('largeVideoBackgroundContainer')
|
||||
);
|
||||
}
|
||||
}
|
||||
316
modules/UI/videolayout/VideoLayout.js
Normal file
316
modules/UI/videolayout/VideoLayout.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/* global APP */
|
||||
|
||||
import Logger from '@jitsi/logger';
|
||||
|
||||
import { MEDIA_TYPE, VIDEO_TYPE } from '../../../react/features/base/media/constants';
|
||||
import {
|
||||
getParticipantById,
|
||||
getPinnedParticipant,
|
||||
isScreenShareParticipantById
|
||||
} from '../../../react/features/base/participants/functions';
|
||||
import {
|
||||
getTrackByMediaTypeAndParticipant,
|
||||
getVideoTrackByParticipant
|
||||
} from '../../../react/features/base/tracks/functions.any';
|
||||
|
||||
import LargeVideoManager from './LargeVideoManager';
|
||||
import { VIDEO_CONTAINER_TYPE } from './VideoContainer';
|
||||
|
||||
const logger = Logger.getLogger(__filename);
|
||||
let largeVideo;
|
||||
|
||||
const VideoLayout = {
|
||||
/**
|
||||
* Handler for local flip X changed event.
|
||||
*/
|
||||
onLocalFlipXChanged(localFlipX) {
|
||||
if (largeVideo) {
|
||||
largeVideo.onLocalFlipXChange(localFlipX);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans up state of this singleton {@code VideoLayout}.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
reset() {
|
||||
this._resetLargeVideo();
|
||||
},
|
||||
|
||||
initLargeVideo() {
|
||||
this._resetLargeVideo();
|
||||
|
||||
largeVideo = new LargeVideoManager();
|
||||
|
||||
const { store } = APP;
|
||||
const { localFlipX } = store.getState()['features/base/settings'];
|
||||
|
||||
if (typeof localFlipX === 'boolean') {
|
||||
largeVideo.onLocalFlipXChange(localFlipX);
|
||||
}
|
||||
largeVideo.updateContainerSize();
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the audio level of the video elements associated to the given id.
|
||||
*
|
||||
* @param id the video identifier in the form it comes from the library
|
||||
* @param lvl the new audio level to update to
|
||||
*/
|
||||
setAudioLevel(id, lvl) {
|
||||
if (largeVideo && id === largeVideo.id) {
|
||||
largeVideo.updateLargeVideoAudioLevel(lvl);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* FIXME get rid of this method once muted indicator are reactified (by
|
||||
* making sure that user with no tracks is displayed as muted )
|
||||
*
|
||||
* If participant has no tracks will make the UI display muted status.
|
||||
* @param {string} participantId
|
||||
*/
|
||||
updateVideoMutedForNoTracks(participantId) {
|
||||
const participant = APP.conference.getParticipantById(participantId);
|
||||
|
||||
if (participant && !participant.getTracksByMediaType('video').length) {
|
||||
VideoLayout._updateLargeVideoIfDisplayed(participantId, true);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Return the type of the remote video.
|
||||
* @param id the id for the remote video
|
||||
* @returns {String} the video type video or screen.
|
||||
*/
|
||||
getRemoteVideoType(id) {
|
||||
const state = APP.store.getState();
|
||||
const participant = getParticipantById(state, id);
|
||||
const isScreenShare = isScreenShareParticipantById(state, id);
|
||||
|
||||
if (participant?.fakeParticipant && !isScreenShare) {
|
||||
return VIDEO_TYPE.CAMERA;
|
||||
}
|
||||
|
||||
if (isScreenShare) {
|
||||
return VIDEO_TYPE.DESKTOP;
|
||||
}
|
||||
|
||||
const videoTrack = getTrackByMediaTypeAndParticipant(state['features/base/tracks'], MEDIA_TYPE.VIDEO, id);
|
||||
|
||||
return videoTrack?.videoType;
|
||||
},
|
||||
|
||||
getPinnedId() {
|
||||
const { id } = getPinnedParticipant(APP.store.getState()) || {};
|
||||
|
||||
return id || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* On last N change event.
|
||||
*
|
||||
* @param endpointsLeavingLastN the list currently leaving last N
|
||||
* endpoints
|
||||
* @param endpointsEnteringLastN the list currently entering last N
|
||||
* endpoints
|
||||
*/
|
||||
onLastNEndpointsChanged(endpointsLeavingLastN, endpointsEnteringLastN) {
|
||||
if (endpointsLeavingLastN) {
|
||||
endpointsLeavingLastN.forEach(this._updateLargeVideoIfDisplayed, this);
|
||||
}
|
||||
|
||||
if (endpointsEnteringLastN) {
|
||||
endpointsEnteringLastN.forEach(this._updateLargeVideoIfDisplayed, this);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resizes the video area.
|
||||
*/
|
||||
resizeVideoArea() {
|
||||
if (largeVideo) {
|
||||
largeVideo.updateContainerSize();
|
||||
largeVideo.resize(false);
|
||||
}
|
||||
},
|
||||
|
||||
isLargeVideoVisible() {
|
||||
return this.isLargeContainerTypeVisible(VIDEO_CONTAINER_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* @return {LargeContainer} the currently displayed container on large
|
||||
* video.
|
||||
*/
|
||||
getCurrentlyOnLargeContainer() {
|
||||
return largeVideo.getCurrentContainer();
|
||||
},
|
||||
|
||||
isCurrentlyOnLarge(id) {
|
||||
return largeVideo && largeVideo.id === id;
|
||||
},
|
||||
|
||||
updateLargeVideo(id, forceUpdate, forceStreamToReattach = false) {
|
||||
if (!largeVideo) {
|
||||
logger.debug(`Ignoring large video update with user id ${id}: large video not initialized yet!`);
|
||||
|
||||
return;
|
||||
}
|
||||
const currentContainer = largeVideo.getCurrentContainer();
|
||||
const currentContainerType = largeVideo.getCurrentContainerType();
|
||||
const isOnLarge = this.isCurrentlyOnLarge(id);
|
||||
const state = APP.store.getState();
|
||||
const participant = getParticipantById(state, id);
|
||||
const videoTrack = getVideoTrackByParticipant(state, participant);
|
||||
const videoStream = videoTrack?.jitsiTrack;
|
||||
|
||||
if (videoStream && forceStreamToReattach) {
|
||||
videoStream.forceStreamToReattach = forceStreamToReattach;
|
||||
}
|
||||
|
||||
if (isOnLarge && !forceUpdate
|
||||
&& LargeVideoManager.isVideoContainer(currentContainerType)
|
||||
&& videoStream) {
|
||||
const currentStreamId = currentContainer.getStreamID();
|
||||
const newStreamId = videoStream?.getId() || null;
|
||||
|
||||
// FIXME it might be possible to get rid of 'forceUpdate' argument
|
||||
if (currentStreamId !== newStreamId) {
|
||||
logger.debug('Enforcing large video update for stream change');
|
||||
forceUpdate = true; // eslint-disable-line no-param-reassign
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOnLarge || forceUpdate) {
|
||||
const videoType = this.getRemoteVideoType(id);
|
||||
|
||||
largeVideo.updateLargeVideo(
|
||||
id,
|
||||
videoStream,
|
||||
videoType || VIDEO_TYPE.CAMERA
|
||||
).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
addLargeVideoContainer(type, container) {
|
||||
largeVideo && largeVideo.addContainer(type, container);
|
||||
},
|
||||
|
||||
removeLargeVideoContainer(type) {
|
||||
largeVideo && largeVideo.removeContainer(type);
|
||||
},
|
||||
|
||||
/**
|
||||
* @returns Promise
|
||||
*/
|
||||
showLargeVideoContainer(type, show) {
|
||||
if (!largeVideo) {
|
||||
return Promise.reject();
|
||||
}
|
||||
|
||||
const isVisible = this.isLargeContainerTypeVisible(type);
|
||||
|
||||
if (isVisible === show) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let containerTypeToShow = type;
|
||||
|
||||
// if we are hiding a container and there is focusedVideo
|
||||
// (pinned remote video) use its video type,
|
||||
// if not then use default type - large video
|
||||
|
||||
if (!show) {
|
||||
const pinnedId = this.getPinnedId();
|
||||
|
||||
if (pinnedId) {
|
||||
containerTypeToShow = this.getRemoteVideoType(pinnedId);
|
||||
} else {
|
||||
containerTypeToShow = VIDEO_CONTAINER_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
return largeVideo.showContainer(containerTypeToShow);
|
||||
},
|
||||
|
||||
isLargeContainerTypeVisible(type) {
|
||||
return largeVideo && largeVideo.state === type;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the id of the current video shown on large.
|
||||
* Currently used by tests (torture).
|
||||
*/
|
||||
getLargeVideoID() {
|
||||
return largeVideo && largeVideo.id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the the current video shown on large.
|
||||
* Currently used by tests (torture).
|
||||
*/
|
||||
getLargeVideo() {
|
||||
return largeVideo;
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the wrapper jquery selector for the largeVideo
|
||||
* @returns {JQuerySelector} the wrapper jquery selector for the largeVideo
|
||||
*/
|
||||
getLargeVideoWrapper() {
|
||||
return this.getCurrentlyOnLargeContainer().$wrapper;
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to invoke when the video layout has changed and elements
|
||||
* have to be re-arranged and resized.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
refreshLayout() {
|
||||
VideoLayout.resizeVideoArea();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleans up any existing largeVideo instance.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_resetLargeVideo() {
|
||||
if (largeVideo) {
|
||||
largeVideo.destroy();
|
||||
}
|
||||
|
||||
largeVideo = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers an update of large video if the passed in participant is
|
||||
* currently displayed on large video.
|
||||
*
|
||||
* @param {string} participantId - The participant ID that should trigger an
|
||||
* update of large video if displayed.
|
||||
* @param {boolean} force - Whether or not the large video update should
|
||||
* happen no matter what.
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateLargeVideoIfDisplayed(participantId, force = false) {
|
||||
if (this.isCurrentlyOnLarge(participantId)) {
|
||||
this.updateLargeVideo(participantId, force, false);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles window resizes.
|
||||
*/
|
||||
onResize() {
|
||||
VideoLayout.resizeVideoArea();
|
||||
}
|
||||
};
|
||||
|
||||
export default VideoLayout;
|
||||
Reference in New Issue
Block a user