| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683 | /* global $, APP, interfaceConfig */
/* eslint-disable no-unused-vars */
import React from 'react';
import ReactDOM from 'react-dom';
import { browser } from '../../../react/features/base/lib-jitsi-meet';
import {
    ORIENTATION,
    LargeVideoBackground
} from '../../../react/features/large-video';
/* eslint-enable no-unused-vars */
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;
/**
 * The CSS class used to add a filter effect on the large video when there is
 * a problem with local video.
 *
 * @private
 * @type {string}
 */
const LOCAL_PROBLEM_FILTER_CLASS = 'videoProblemFilter';
/**
 * The CSS class used to add a filter effect on the large video when there is
 * a problem with remote video.
 *
 * @private
 * @type {string}
 */
const REMOTE_PROBLEM_FILTER_CLASS = 'remoteVideoProblemFilter';
/**
 * 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 };
}
/**
 * Container for user video.
 */
export class VideoContainer extends LargeContainer {
    /**
     *
     */
    get $video() {
        return $('#largeVideo');
    }
    /**
     *
     */
    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;
        /**
         * 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;
        /**
         * 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 = $('#dominantSpeakerAvatarContainer').height();
        const onPlayingCallback = function(event) {
            if (typeof resizeContainer === 'function') {
                resizeContainer(event);
            }
            this.wasVideoRendered = true;
        }.bind(this);
        this.$video[0].onplaying = onPlayingCallback;
        /**
         * A Set of functions to invoke when the video element resizes.
         *
         * @private
         */
        this._resizeListeners = new Set();
        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(LOCAL_PROBLEM_FILTER_CLASS, enable);
        this._updateBackground();
    }
    /**
     * 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) {
        let containerWidthToUse = containerWidth;
        /* eslint-enable max-params */
        if (this.stream && this.isScreenSharing()) {
            if (interfaceConfig.VERTICAL_FILMSTRIP) {
                containerWidthToUse -= Filmstrip.getFilmstripWidth();
            }
            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 = $('#dominantSpeakerAvatarContainer');
            $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;
        }
        const [ width, height ]
            = this.getVideoSize(containerWidth, containerHeight);
        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);
        // 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 = stream;
        this.videoType = videoType;
        if (!stream) {
            return;
        }
        stream.attach(this.$video[0]);
        const flipX = stream.isLocal() && this.localFlipX;
        this.$video.css({
            transform: flipX ? 'scaleX(-1)' : 'none'
        });
        this._updateBackground();
        // 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._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) {
        // 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(REMOTE_PROBLEM_FILTER_CLASS, show);
        this.$avatar.toggleClass(REMOTE_PROBLEM_FILTER_CLASS, show);
        this._updateBackground();
    }
    /**
     * 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.
     *
     * TODO: refactor this since Temasys is no longer supported.
     */
    show() {
        return new Promise(resolve => {
            this.$wrapperParent.css('visibility', 'visible').fadeTo(
                FADE_DURATION_MS,
                1,
                () => {
                    resolve();
                }
            );
        });
    }
    /**
     *
     */
    hide() {
        // as the container is hidden/replaced by another container
        // hide its avatar
        this.showAvatar(false);
        return new Promise(resolve => {
            this.$wrapperParent.fadeTo(FADE_DURATION_MS, 0, () => {
                this.$wrapperParent.css('visibility', 'hidden');
                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);
    }
    /**
     * 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.isSafariWithWebrtc()) {
            return;
        }
        ReactDOM.render(
            <LargeVideoBackground
                hidden = { this._hideBackground }
                mirror = {
                    this.stream
                    && this.stream.isLocal()
                    && this.localFlipX
                }
                orientationFit = { this._backgroundOrientation }
                showLocalProblemFilter
                    = { this.$video.hasClass(LOCAL_PROBLEM_FILTER_CLASS) }
                showRemoteProblemFilter
                    = { this.$video.hasClass(REMOTE_PROBLEM_FILTER_CLASS) }
                videoElement = { this.$video && this.$video[0] }
                videoTrack = { this.stream } />,
            document.getElementById('largeVideoBackgroundContainer')
        );
    }
}
 |