- Modify Etherpad and SharedVideo so their resizing takes into account the width of the filmstrip in vertical filmstrip mode. - Modify Filmstrip's getFilmstripWidth to account for when the filmstrip is hidden. - modify VideoContainer so in vertical filmstrip mode it centers the shared desktop stream in the middle of the available space not taken by filmstrip. - Also allow clickthrough on the secondary toolbar itself while still allowing clicks on the toolbar's buttons. This allows clicks on shared videos to go through.
691 lines
21 KiB
JavaScript
691 lines
21 KiB
JavaScript
/* global $, APP, interfaceConfig */
|
|
|
|
import Filmstrip from './Filmstrip';
|
|
import LargeContainer from './LargeContainer';
|
|
import UIEvents from '../../../service/UI/UIEvents';
|
|
import UIUtil from '../util/UIUtil';
|
|
|
|
// FIXME should be 'video'
|
|
export const VIDEO_CONTAINER_TYPE = 'camera';
|
|
|
|
const FADE_DURATION_MS = 300;
|
|
|
|
const logger = require('jitsi-meet-logger').getLogger(__filename);
|
|
|
|
/**
|
|
* 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
|
|
* @return an array with 2 elements, the video width and the video height
|
|
*/
|
|
function computeDesktopVideoSize( // eslint-disable-line max-params
|
|
videoWidth,
|
|
videoHeight,
|
|
videoSpaceWidth,
|
|
videoSpaceHeight) {
|
|
const aspectRatio = videoWidth / videoHeight;
|
|
|
|
let availableWidth = Math.max(videoWidth, videoSpaceWidth);
|
|
let availableHeight = Math.max(videoHeight, videoSpaceHeight);
|
|
|
|
if (interfaceConfig.VERTICAL_FILMSTRIP) {
|
|
// eslint-disable-next-line no-param-reassign
|
|
videoSpaceWidth -= Filmstrip.getFilmstripWidth();
|
|
} 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) {
|
|
const aspectRatio = videoWidth / videoHeight;
|
|
|
|
switch (videoLayoutFit) {
|
|
case 'height':
|
|
return [ videoSpaceHeight * aspectRatio, videoSpaceHeight ];
|
|
case 'width':
|
|
return [ videoSpaceWidth, videoSpaceWidth / aspectRatio ];
|
|
case 'both': {
|
|
const videoSpaceRatio = videoSpaceWidth / videoSpaceHeight;
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Returns an array of the video horizontal and vertical indents.
|
|
* Centers horizontally and top aligns vertically.
|
|
*
|
|
* @return an array with 2 elements, the horizontal indent and the vertical
|
|
* indent
|
|
*/
|
|
function getDesktopVideoPosition(videoWidth, videoHeight, videoSpaceWidth) {
|
|
const horizontalIndent = (videoSpaceWidth - videoWidth) / 2;
|
|
|
|
const verticalIndent = 0;// Top aligned
|
|
|
|
return { horizontalIndent,
|
|
verticalIndent };
|
|
}
|
|
|
|
/**
|
|
* Container for user video.
|
|
*/
|
|
export class VideoContainer extends LargeContainer {
|
|
// FIXME: With Temasys we have to re-select everytime
|
|
/**
|
|
*
|
|
*/
|
|
get $video() {
|
|
return $('#largeVideo');
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get $videoBackground() {
|
|
return $('#largeVideoBackground');
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get id() {
|
|
return this.userId;
|
|
}
|
|
|
|
/**
|
|
* Creates new VideoContainer instance.
|
|
* @param resizeContainer {Function} function that takes care of the size
|
|
* of the video container.
|
|
* @param emitter {EventEmitter} the event emitter that will be used by
|
|
* this instance.
|
|
*/
|
|
constructor(resizeContainer, emitter) {
|
|
super();
|
|
this.stream = null;
|
|
this.userId = null;
|
|
this.videoType = null;
|
|
this.localFlipX = true;
|
|
this.emitter = emitter;
|
|
this.resizeContainer = resizeContainer;
|
|
|
|
this.isVisible = false;
|
|
|
|
/**
|
|
* Flag indicates whether or not the avatar is currently displayed.
|
|
* @type {boolean}
|
|
*/
|
|
this.avatarDisplayed = false;
|
|
this.$avatar = $('#dominantSpeaker');
|
|
|
|
/**
|
|
* A jQuery selector of the remote connection message.
|
|
* @type {jQuery|HTMLElement}
|
|
*/
|
|
this.$remoteConnectionMessage = $('#remoteConnectionMessage');
|
|
|
|
this.$remotePresenceMessage = $('#remotePresenceMessage');
|
|
|
|
/**
|
|
* Indicates whether or not the video stream attached to the video
|
|
* element has started(which means that there is any image rendered
|
|
* even if the video is stalled).
|
|
* @type {boolean}
|
|
*/
|
|
this.wasVideoRendered = false;
|
|
|
|
this.$wrapper = $('#largeVideoWrapper');
|
|
|
|
/**
|
|
* FIXME: currently using parent() because I can't come up with name
|
|
* for id. We'll need to probably refactor the HTML related to the large
|
|
* video anyway.
|
|
*/
|
|
this.$wrapperParent = this.$wrapper.parent();
|
|
|
|
this.avatarHeight = $('#dominantSpeakerAvatar').height();
|
|
|
|
const onPlayingCallback = function(event) {
|
|
if (typeof resizeContainer === 'function') {
|
|
resizeContainer(event);
|
|
}
|
|
this.wasVideoRendered = true;
|
|
}.bind(this);
|
|
|
|
// This does not work with Temasys plugin - has to be a property to be
|
|
// copied between new <object> elements
|
|
// this.$video.on('play', onPlay);
|
|
|
|
this.$video[0].onplaying = onPlayingCallback;
|
|
|
|
/**
|
|
* A Set of functions to invoke when the video element resizes.
|
|
*
|
|
* @private
|
|
*/
|
|
this._resizeListeners = new Set();
|
|
|
|
// As of May 16, 2017, temasys does not support resize events.
|
|
this.$video[0].onresize = this._onResize.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);
|
|
}
|
|
|
|
/**
|
|
* Enables a filter on the video which indicates that there are some
|
|
* problems with the local media connection.
|
|
*
|
|
* @param {boolean} enable <tt>true</tt> if the filter is to be enabled or
|
|
* <tt>false</tt> otherwise.
|
|
*/
|
|
enableLocalConnectionProblemFilter(enable) {
|
|
this.$video.toggleClass('videoProblemFilter', enable);
|
|
this.$videoBackground.toggleClass('videoProblemFilter', enable);
|
|
}
|
|
|
|
/**
|
|
* 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[0];
|
|
|
|
|
|
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
|
|
* @returns {{availableWidth, availableHeight}}
|
|
*/
|
|
getVideoSize(containerWidth, containerHeight) {
|
|
const { width, height } = this.getStreamSize();
|
|
|
|
if (this.stream && this.isScreenSharing()) {
|
|
return computeDesktopVideoSize(width,
|
|
height,
|
|
containerWidth,
|
|
containerHeight);
|
|
}
|
|
|
|
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
|
|
* @returns {{horizontalIndent, verticalIndent}}
|
|
*/
|
|
getVideoPosition(width, height, containerWidth, containerHeight) {
|
|
/* eslint-enable max-params */
|
|
if (this.stream && this.isScreenSharing()) {
|
|
let availableContainerWidth = containerWidth;
|
|
|
|
if (interfaceConfig.VERTICAL_FILMSTRIP) {
|
|
availableContainerWidth -= Filmstrip.getFilmstripWidth();
|
|
}
|
|
|
|
return getDesktopVideoPosition(width,
|
|
height,
|
|
availableContainerWidth,
|
|
containerHeight);
|
|
}
|
|
|
|
return getCameraVideoPosition(width,
|
|
height,
|
|
containerWidth,
|
|
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 = $('#dominantSpeakerAvatar');
|
|
|
|
$element.css(
|
|
'top',
|
|
$avatarImage.offset().top + $avatarImage.height() + 10);
|
|
} else {
|
|
const height = $element.height();
|
|
const parentHeight = $element.parent().height();
|
|
|
|
$element.css('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.length === 0) {
|
|
return;
|
|
}
|
|
|
|
this._hideVideoBackground();
|
|
|
|
const [ width, height ]
|
|
= this.getVideoSize(containerWidth, containerHeight);
|
|
|
|
if ((containerWidth > width) || (containerHeight > height)) {
|
|
this._showVideoBackground();
|
|
const css
|
|
= containerWidth > width
|
|
? { width: '100%',
|
|
height: 'auto' }
|
|
: { width: 'auto',
|
|
height: '100%' };
|
|
|
|
this.$videoBackground.css(css);
|
|
}
|
|
|
|
const { horizontalIndent, verticalIndent }
|
|
= this.getVideoPosition(width, height,
|
|
containerWidth, containerHeight);
|
|
|
|
// update avatar position
|
|
const top = (containerHeight / 2) - (this.avatarHeight / 4 * 3);
|
|
|
|
this.$avatar.css('top', top);
|
|
|
|
this.positionRemoteStatusMessages();
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Update video stream.
|
|
* @param {string} userID
|
|
* @param {JitsiTrack?} stream new stream
|
|
* @param {string} videoType video type
|
|
*/
|
|
setStream(userID, stream, videoType) {
|
|
this.userId = userID;
|
|
if (this.stream === stream) {
|
|
// 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;
|
|
}
|
|
|
|
// The stream has changed, so the image will be lost on detach
|
|
this.wasVideoRendered = false;
|
|
|
|
|
|
// detach old stream
|
|
if (this.stream) {
|
|
this.stream.detach(this.$video[0]);
|
|
this.stream.detach(this.$videoBackground[0]);
|
|
}
|
|
|
|
this.stream = stream;
|
|
this.videoType = videoType;
|
|
|
|
if (!stream) {
|
|
return;
|
|
}
|
|
|
|
stream.attach(this.$video[0]);
|
|
stream.attach(this.$videoBackground[0]);
|
|
|
|
this._hideVideoBackground();
|
|
|
|
const flipX = stream.isLocal() && this.localFlipX;
|
|
|
|
this.$video.css({
|
|
transform: flipX ? 'scaleX(-1)' : 'none'
|
|
});
|
|
this.$videoBackground.css({
|
|
transform: flipX ? 'scaleX(-1)' : 'none'
|
|
});
|
|
|
|
// Reset the large video background depending on the stream.
|
|
this.setLargeVideoBackground(this.avatarDisplayed);
|
|
}
|
|
|
|
/**
|
|
* 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()) {
|
|
return;
|
|
}
|
|
this.$video.css({
|
|
transform: this.localFlipX ? 'scaleX(-1)' : 'none'
|
|
});
|
|
|
|
this.$videoBackground.css({
|
|
transform: this.localFlipX ? 'scaleX(-1)' : 'none'
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* 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) {
|
|
// TO FIX: Video background need to be black, so that we don't have a
|
|
// flickering effect when scrolling between videos and have the screen
|
|
// move to grey before going back to video. Avatars though can have the
|
|
// default background set.
|
|
// In order to fix this code we need to introduce video background or
|
|
// find a workaround for the video flickering.
|
|
this.setLargeVideoBackground(show);
|
|
|
|
this.$avatar.css('visibility', show ? 'visible' : 'hidden');
|
|
this.avatarDisplayed = show;
|
|
|
|
this.emitter.emit(UIEvents.LARGE_VIDEO_AVATAR_VISIBLE, show);
|
|
APP.API.notifyLargeVideoVisibilityChanged(show);
|
|
}
|
|
|
|
/**
|
|
* Indicates that the remote user who is currently displayed by this video
|
|
* container is having connectivity issues.
|
|
*
|
|
* @param {boolean} show <tt>true</tt> to show or <tt>false</tt> to hide
|
|
* the indication.
|
|
*/
|
|
showRemoteConnectionProblemIndicator(show) {
|
|
this.$video.toggleClass('remoteVideoProblemFilter', show);
|
|
this.$videoBackground.toggleClass('remoteVideoProblemFilter', show);
|
|
|
|
this.$avatar.toggleClass('remoteVideoProblemFilter', show);
|
|
}
|
|
|
|
|
|
/**
|
|
* We are doing fadeOut/fadeIn animations on parent div which wraps
|
|
* largeVideo, because when Temasys plugin is in use it replaces
|
|
* <video> elements with plugin <object> tag. In Safari jQuery is
|
|
* unable to store values on this plugin object which breaks all
|
|
* animation effects performed on it directly.
|
|
*/
|
|
show() {
|
|
// its already visible
|
|
if (this.isVisible) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
this.$wrapperParent.css('visibility', 'visible').fadeTo(
|
|
FADE_DURATION_MS,
|
|
1,
|
|
() => {
|
|
this.isVisible = true;
|
|
resolve();
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
hide() {
|
|
// as the container is hidden/replaced by another container
|
|
// hide its avatar
|
|
this.showAvatar(false);
|
|
|
|
// its already hidden
|
|
if (!this.isVisible) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
this.$wrapperParent.fadeTo(FADE_DURATION_MS, 0, () => {
|
|
this.$wrapperParent.css('visibility', 'hidden');
|
|
this.isVisible = false;
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return {boolean} switch on dominant speaker event if on stage.
|
|
*/
|
|
stayOnStage() {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Sets the large video container background depending on the container
|
|
* type and the parameter indicating if an avatar is currently shown on
|
|
* large.
|
|
*
|
|
* @param {boolean} isAvatar - Indicates if the avatar is currently shown
|
|
* on the large video.
|
|
* @returns {void}
|
|
*/
|
|
setLargeVideoBackground(isAvatar) {
|
|
$('#largeVideoContainer').css('background',
|
|
this.videoType === VIDEO_CONTAINER_TYPE && !isAvatar
|
|
? '#000' : interfaceConfig.DEFAULT_BACKGROUND);
|
|
}
|
|
|
|
/**
|
|
* Sets the blur background to be invisible and pauses any playing video.
|
|
*
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_hideVideoBackground() {
|
|
this.$videoBackground.css({ visibility: 'hidden' });
|
|
this.$videoBackground[0].pause();
|
|
}
|
|
|
|
/**
|
|
* Callback invoked when the video element changes dimensions.
|
|
*
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_onResize() {
|
|
this._resizeListeners.forEach(callback => callback());
|
|
}
|
|
|
|
/**
|
|
* Sets the blur background to be visible and starts any loaded video.
|
|
*
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_showVideoBackground() {
|
|
this.$videoBackground.css({ visibility: 'visible' });
|
|
|
|
// XXX HTMLMediaElement.play's Promise may be rejected. Certain
|
|
// environments such as Google Chrome and React Native will report the
|
|
// rejection as unhandled. And that may appear scary depending on how
|
|
// the environment words the report. To reduce the risk of scaring a
|
|
// developer, make sure that the rejection is handled. We cannot really
|
|
// do anything substantial about the rejection and, more importantly, we
|
|
// do not care. Some browsers (at this time, only Edge is known) don't
|
|
// return a promise from .play(), so check before trying to catch.
|
|
const res = this.$videoBackground[0].play();
|
|
|
|
if (typeof res !== 'undefined') {
|
|
res.catch(reason => logger.error(reason));
|
|
}
|
|
}
|
|
}
|