diff --git a/conference.js b/conference.js index d74f8ec05..bf79f678f 100644 --- a/conference.js +++ b/conference.js @@ -1286,6 +1286,8 @@ export default { // check the roles for the new user and reflect them APP.UI.updateUserRole(user); + + updateRemoteThumbnailsVisibility(); }); room.on(ConferenceEvents.USER_LEFT, (id, user) => { APP.store.dispatch(participantLeft(id, user)); @@ -1293,6 +1295,8 @@ export default { APP.API.notifyUserLeft(id); APP.UI.removeUser(id, user.getDisplayName()); APP.UI.onSharedVideoStop(id); + + updateRemoteThumbnailsVisibility(); }); room.on(ConferenceEvents.USER_STATUS_CHANGED, (id, status) => { @@ -1475,6 +1479,8 @@ export default { reportError(e); } } + + updateRemoteThumbnailsVisibility(); }); } @@ -1811,6 +1817,8 @@ export default { } }); } + + updateRemoteThumbnailsVisibility(); }); room.addCommandListener( this.commands.defaults.SHARED_VIDEO, ({value, attributes}, id) => { @@ -1826,6 +1834,21 @@ export default { APP.UI.onSharedVideoUpdate(id, value, attributes); } }); + + function updateRemoteThumbnailsVisibility() { + const localUserId = APP.conference.getMyUserId(); + const remoteParticipantsCount = room.getParticipantCount() - 1; + + // Get the remote thumbnail count for cases where there are + // non-participants displaying video, such as with video sharing. + const remoteVideosCount = APP.UI.getRemoteVideosCount(); + + const shouldShowRemoteThumbnails = APP.UI.isPinned(localUserId) + || remoteVideosCount > 1 + || remoteParticipantsCount !== remoteVideosCount; + + APP.UI.setRemoteThumbnailsVisibility(shouldShowRemoteThumbnails); + } }, /** * Adds any room listener. diff --git a/config.js b/config.js index 47c9a8e29..4b7223c5f 100644 --- a/config.js +++ b/config.js @@ -57,6 +57,9 @@ var config = { // eslint-disable-line no-unused-vars webrtcIceTcpDisable: false, openSctp: true, // Toggle to enable/disable SCTP channels + + // Disable hiding of remote thumbnails when in a 1-on-1 conference call. + disable1On1Mode: false, disableStats: false, disableAudioLevels: false, channelLastN: -1, // The default value of the channel attribute last-n. diff --git a/css/_filmstrip.scss b/css/_filmstrip.scss index 00f6338d7..8c7fb225b 100644 --- a/css/_filmstrip.scss +++ b/css/_filmstrip.scss @@ -62,10 +62,18 @@ videos. */ font-size: 0pt; + #filmstripLocalVideo { + padding-left: 0; + } + &.hidden { bottom: -196px; } + .remote-videos-container { + display: flex; + } + .videocontainer { display: none; position: relative; @@ -130,4 +138,13 @@ margin-bottom: auto; padding-right: $defaultToolbarSize; } + + .remote-videos-container { + transition: opacity 1s; + + &.hide-videos { + opacity: 0; + pointer-events: none; + } + } } diff --git a/css/_jitsi_popover.scss b/css/_jitsi_popover.scss index 77e983703..2397b295f 100644 --- a/css/_jitsi_popover.scss +++ b/css/_jitsi_popover.scss @@ -44,4 +44,28 @@ border-width: 5px; border-bottom-width: 0; } + + /** + * Override default "top" styles to support popovers appearing from the + * left of the popover trigger element. + */ + &.left { + margin-left: -$popoverMenuPadding; + margin-top: 0; + + .arrow { + border-color: transparent transparent transparent $popoverBg; + border-width: 5px 0px 5px 5px; + margin-left: 0; + margin-top: -5px; + } + + .jitsipopover { + &__menu-padding { + bottom: 0; + height: 100%; + width: $popoverMenuPadding; + } + } + } } diff --git a/css/_vertical_filmstrip_overrides.scss b/css/_vertical_filmstrip_overrides.scss new file mode 100644 index 000000000..be256b0b7 --- /dev/null +++ b/css/_vertical_filmstrip_overrides.scss @@ -0,0 +1,118 @@ +/** + * Override other styles to support vertical filmstrip mode. + */ +.vertical-filmstrip { + .filmstrip { + align-items: flex-end; + box-sizing: border-box; + display: flex; + flex-direction: column-reverse; + height: 100%; + + /** + * Hide videos by making them slight to the right. + */ + .filmstrip__videos { + right: 0; + transition: right 2s; + + &.hidden { + bottom: auto; + right: -196px; + } + } + + #filmstripLocalVideo { + height: auto; + justify-content: flex-end; + } + + /** + * Remove unnecssary padding that is normally used to prevent horizontal + * filmstrip from overlapping the left edge of the screen. + */ + #filmstripLocalVideo, + #filmstripRemoteVideos { + padding: 0; + } + + #filmstripRemoteVideos { + display: flex; + flex: 1; + flex-direction: column; + height: auto; + overflow-x: hidden !important; + + .remote-videos-container { + flex-direction: column; + } + } + + /** + * Rotate the hide filmstrip icon so it points towards the right edge + * of the screen. + */ + &__toolbar { + transform: rotate(-90deg); + } + + /** + * Move the remote video menu trigger to the bottom left of the + * video thumbnail. + */ + .remotevideomenu { + bottom: 0; + left: 0; + top: auto; + right: auto; + transform: rotate(-90deg); + } + + #remoteVideos { + flex-direction: column-reverse; + height: 100%; + } + + .videocontainer { + /** + * Move status icons to the bottom right of the thumbnail. + */ + &__toolbar { + text-align: right; + + .toolbar-icon { + float: none; + } + } + } + } + + /** + * For video labels that display on the top right to adjust its position as + * the filmstrip itself or filmstrip remote videos appear and disappear. + */ + .video-state-indicator { + transition: right 2s; + + &.with-filmstrip { + &#recordingLabel { + right: 200px; + } + + &#videoResolutionLabel { + right: 150px; + } + } + } + + /** + * Move toastr closer to the bottom of the screen and move left to avoid + * overlapping of videos when they are configured at default height. + */ + #toast-container { + &.notification-bottom-right { + bottom: 25px; + right: 130 + 2 * ($thumbnailVideoMargin + $thumbnailsBorder) + $thumbnailVideoBorder; + } + } +} diff --git a/css/main.scss b/css/main.scss index 894620bb4..43630b571 100644 --- a/css/main.scss +++ b/css/main.scss @@ -73,5 +73,6 @@ @import 'policy'; @import 'filmstrip'; @import 'unsupported-browser/main'; +@import 'vertical_filmstrip_overrides'; /* Modules END */ diff --git a/interface_config.js b/interface_config.js index 56e44fd10..69f7a7d76 100644 --- a/interface_config.js +++ b/interface_config.js @@ -55,6 +55,12 @@ var interfaceConfig = { // eslint-disable-line no-unused-vars * Whether to only show the filmstrip (and hide the toolbar). */ filmStripOnly: false, + + /** + * Whether to show thumbnails in filmstrip as a column instead of as a row. + */ + VERTICAL_FILMSTRIP: false, + //A html text to be shown to guests on the close page, false disables it CLOSE_PAGE_GUEST_HINT: false, RANDOM_AVATAR_URL_PREFIX: false, diff --git a/modules/UI/UI.js b/modules/UI/UI.js index c404e2813..2e713a8e4 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -309,6 +309,10 @@ UI.start = function () { SideContainerToggler.init(eventEmitter); Filmstrip.init(eventEmitter); + // By default start with remote videos hidden and rely on other logic to + // make them visible. + UI.setRemoteThumbnailsVisibility(false); + VideoLayout.init(eventEmitter); if (!interfaceConfig.filmStripOnly) { VideoLayout.initLargeVideo(); @@ -339,6 +343,10 @@ UI.start = function () { JitsiPopover.enabled = false; } + if (interfaceConfig.VERTICAL_FILMSTRIP) { + $("body").addClass("vertical-filmstrip"); + } + document.title = interfaceConfig.APP_NAME; if (!interfaceConfig.filmStripOnly) { @@ -1142,6 +1150,15 @@ UI.getLargeVideo = function () { return VideoLayout.getLargeVideo(); }; +/** + * Returns whether or not the passed in user id is currently pinned to the large + * video. + * + * @param {string} userId - The id of the user to check is pinned or not. + * @returns {boolean} True if the user is currently pinned to the large video. + */ +UI.isPinned = userId => VideoLayout.getPinnedId() === userId; + /** * Shows dialog with a link to FF extension. */ @@ -1392,6 +1409,23 @@ UI.isRingOverlayVisible = () => RingOverlay.isVisible(); */ UI.onUserFeaturesChanged = user => VideoLayout.onUserFeaturesChanged(user); +/** + * Returns the number of known remote videos. + * + * @returns {number} The number of remote videos. + */ +UI.getRemoteVideosCount = () => VideoLayout.getRemoteVideosCount(); + +/** + * Makes remote thumbnail videos visible or not visible. + * + * @param {boolean} shouldHide - True if remote thumbnails should be hidden, + * false f they should be visible. + * @returns {void} + */ +UI.setRemoteThumbnailsVisibility + = shouldHide => Filmstrip.setRemoteVideoVisibility(shouldHide); + const UIListeners = new Map([ [ UIEvents.ETHERPAD_CLICKED, diff --git a/modules/UI/recording/Recording.js b/modules/UI/recording/Recording.js index 5046b9cfd..cde988eae 100644 --- a/modules/UI/recording/Recording.js +++ b/modules/UI/recording/Recording.js @@ -201,6 +201,15 @@ function _showStopRecordingPrompt(recordingType) { * position */ function moveToCorner(selector, move) { + const { + remoteVideosCount, + remoteVideosVisible, + visible + } = APP.store.getState()['features/filmstrip']; + selector.toggleClass( + 'with-filmstrip', + Boolean(remoteVideosCount && remoteVideosVisible && visible)); + let moveToCornerClass = "moveToCorner"; let containsClass = selector.hasClass(moveToCornerClass); @@ -295,6 +304,10 @@ var Recording = { APP.UI.messageHandler.enableNotifications(false); APP.UI.messageHandler.enablePopups(false); } + + this.eventEmitter.addListener(UIEvents.UPDATED_FILMSTRIP_DISPLAY, () =>{ + this._updateStatusLabel(); + }); }, /** diff --git a/modules/UI/shared_video/SharedVideo.js b/modules/UI/shared_video/SharedVideo.js index 35d4d59f6..7c5ffb2ee 100644 --- a/modules/UI/shared_video/SharedVideo.js +++ b/modules/UI/shared_video/SharedVideo.js @@ -456,6 +456,9 @@ export default class SharedVideoManager { // revert to original behavior (prevents pausing // for participants not sharing the video to pause it) $("#sharedVideo").css("pointer-events","auto"); + + this.emitter.emit( + UIEvents.UPDATE_SHARED_VIDEO, null, 'removed'); }); this.url = null; @@ -656,7 +659,7 @@ SharedVideoThumb.prototype.createContainer = function (spanId) { avatar.src = "https://img.youtube.com/vi/" + this.url + "/0.jpg"; container.appendChild(avatar); - var remotes = document.getElementById('remoteVideos'); + var remotes = document.getElementById('filmstripRemoteVideosContainer'); return remotes.appendChild(container); }; diff --git a/modules/UI/util/JitsiPopover.js b/modules/UI/util/JitsiPopover.js index da6dd4622..ce6686b39 100644 --- a/modules/UI/util/JitsiPopover.js +++ b/modules/UI/util/JitsiPopover.js @@ -1,4 +1,74 @@ /* global $ */ + +const positionConfigurations = { + left: { + + // Align the popover's right side to the target element. + my: 'right', + + // Align the popover to the left side of the target element. + at: 'left', + + // Force the popover to fit within the viewport. + collision: 'fit', + + /** + * Callback invoked by jQuery UI tooltip. + * + * @param {Object} position - The top and bottom position the popover + * element should be set at. + * @param {Object} element. - Additional size and position information + * about the popover element and target. + * @param {Object} elements.element - Has position and size related data + * for the popover element itself. + * @param {Object} elements.target - Has position and size related data + * for the target element the popover displays from. + */ + using: function setPositionLeft(position, elements) { + const { element, target } = elements; + + $('.jitsipopover').css({ + display: 'table', + left: position.left, + top: position.top + }); + + // Move additional padding to the right edge of the popover and + // allow css to take care of width. The padding is used to maintain + // a hover state between the target and the popover. + $('.jitsipopover > .jitsipopover__menu-padding').css({ + left: element.width + }); + + // Find the distance from the top of the popover to the center of + // the target and use that value to position the arrow to point to + // it. + const verticalCenterOfTarget = target.height / 2; + const verticalDistanceFromTops = target.top - element.top; + const verticalPositionOfTargetCenter + = verticalDistanceFromTops + verticalCenterOfTarget; + + $('.jitsipopover > .arrow').css({ + left: element.width, + top: verticalPositionOfTargetCenter + }); + } + }, + top: { + my: "bottom", + at: "top", + collision: "fit", + using: function setPositionTop(position, elements) { + var calcLeft = elements.target.left - elements.element.left + + elements.target.width/2; + $(".jitsipopover").css( + {top: position.top, left: position.left, display: "table"}); + $(".jitsipopover > .arrow").css({left: calcLeft}); + $(".jitsipopover > .jitsipopover__menu-padding").css( + {left: calcLeft - 50}); + } + } +}; var JitsiPopover = (function () { /** * The default options @@ -7,7 +77,8 @@ var JitsiPopover = (function () { skin: 'white', content: '', hasArrow: true, - onBeforePosition: undefined + onBeforePosition: undefined, + position: 'top' }; /** @@ -21,7 +92,6 @@ var JitsiPopover = (function () { function JitsiPopover(element, options) { this.options = Object.assign({}, defaultOptions, options); - this.elementIsHovered = false; this.popoverIsHovered = false; this.popoverShown = false; @@ -45,12 +115,15 @@ var JitsiPopover = (function () { * Returns template for popover */ JitsiPopover.prototype.getTemplate = function () { + const { hasArrow, position, skin } = this.options; + let arrow = ''; - if (this.options.hasArrow) { + if (hasArrow) { arrow = '
'; } + return ( - `
+ `
${arrow}
@@ -129,21 +202,14 @@ var JitsiPopover = (function () { * Refreshes the position of the popover. */ JitsiPopover.prototype.refreshPosition = function () { - $(".jitsipopover").position({ - my: "bottom", - at: "top", - collision: "fit", - of: this.element, - using: function (position, elements) { - var calcLeft = elements.target.left - elements.element.left + - elements.target.width/2; - $(".jitsipopover").css( - {top: position.top, left: position.left, display: "table"}); - $(".jitsipopover > .arrow").css({left: calcLeft}); - $(".jitsipopover > .jitsipopover__menu-padding").css( - {left: calcLeft - 50}); + const positionOptions = Object.assign( + {}, + positionConfigurations[this.options.position], + { + of: this.element } - }); + ); + $(".jitsipopover").position(positionOptions); }; /** diff --git a/modules/UI/videolayout/ConnectionIndicator.js b/modules/UI/videolayout/ConnectionIndicator.js index 2959d4b7b..470d0fc3b 100644 --- a/modules/UI/videolayout/ConnectionIndicator.js +++ b/modules/UI/videolayout/ConnectionIndicator.js @@ -1,4 +1,4 @@ -/* global $, APP */ +/* global $, APP, interfaceConfig */ /* jshint -W101 */ import JitsiPopover from "../util/JitsiPopover"; @@ -309,7 +309,8 @@ ConnectionIndicator.prototype.create = function () { this.popover = new JitsiPopover($(element), { content: popoverContent, skin: "black", - onBeforePosition: el => APP.translation.translateElement(el) + onBeforePosition: el => APP.translation.translateElement(el), + position: interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top' }); // override popover show method to make sure we will update the content diff --git a/modules/UI/videolayout/Filmstrip.js b/modules/UI/videolayout/Filmstrip.js index 1d32698e1..f60bbf83e 100644 --- a/modules/UI/videolayout/Filmstrip.js +++ b/modules/UI/videolayout/Filmstrip.js @@ -1,4 +1,9 @@ -/* global $, APP, JitsiMeetJS, interfaceConfig */ +/* global $, APP, config, JitsiMeetJS, interfaceConfig */ + +import { + setFilmstripRemoteVideosVisibility, + setFilmstripVisibility +} from '../../../react/features/filmstrip'; import UIEvents from "../../../service/UI/UIEvents"; import UIUtil from "../util/UIUtil"; @@ -14,6 +19,7 @@ const Filmstrip = { this.iconMenuUpClassName = 'icon-menu-up'; this.filmstripContainerClassName = 'filmstrip'; this.filmstrip = $('#remoteVideos'); + this.filmstripRemoteVideos = $('#filmstripRemoteVideosContainer'); this.eventEmitter = eventEmitter; // Show the toggle button and add event listeners only when out of @@ -24,6 +30,28 @@ const Filmstrip = { } }, + /** + * Sets a class on the remote videos container for CSS to adjust visibility + * of the remote videos. Will no-op if config.debug is truthy, as should be + * the case with torture tests. + * + * @param {boolean} shouldHide - True if remote videos should be hidden, + * false if they should be visible. + * @returns {void} + */ + setRemoteVideoVisibility(shouldShow) { + // FIXME Checking config.debug is a grand hack to avoid fixing the + // torture tests after the 1-on-1 UI was implemented, which hides remote + // videos on 1-on-1 calls. If this check is to be kept, at least create + // new torture tests to verify 1-on-1 mode. + if (config.debug || config.disable1On1Mode) { + return; + } + + APP.store.dispatch(setFilmstripRemoteVideosVisibility(shouldShow)); + this.filmstripRemoteVideos.toggleClass('hide-videos', !shouldShow); + }, + /** * Initializes the filmstrip toolbar. */ @@ -150,11 +178,14 @@ const Filmstrip = { // Emit/fire UIEvents.TOGGLED_FILMSTRIP. const eventEmitter = this.eventEmitter; + const isFilmstripVisible = this.isFilmstripVisible(); + if (eventEmitter) { eventEmitter.emit( UIEvents.TOGGLED_FILMSTRIP, this.isFilmstripVisible()); } + APP.store.dispatch(setFilmstripVisibility(isFilmstripVisible)); }, /** @@ -177,7 +208,10 @@ const Filmstrip = { * @returns {number} height */ getFilmstripHeight() { - if (this.isFilmstripVisible()) { + // 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 (this.isFilmstripVisible() && !interfaceConfig.VERTICAL_FILMSTRIP) { return $(`.${this.filmstripContainerClassName}`).outerHeight(); } else { return 0; @@ -364,13 +398,27 @@ const Filmstrip = { (remoteLocalWidthRatio * numberRemoteThumbs + 1), availableHeight * interfaceConfig.LOCAL_THUMBNAIL_RATIO); const h = lW / interfaceConfig.LOCAL_THUMBNAIL_RATIO; + + const removeVideoWidth = lW * remoteLocalWidthRatio; + + let localVideo; + if (interfaceConfig.VERTICAL_FILMSTRIP) { + // scale both width and height + localVideo = { + thumbWidth: removeVideoWidth, + thumbHeight: h * remoteLocalWidthRatio + }; + } else { + localVideo = { + thumbWidth: lW, + thumbHeight: h + }; + } + return { - localVideo:{ - thumbWidth: lW, - thumbHeight: h - }, + localVideo, remoteVideo: { - thumbWidth: lW * remoteLocalWidthRatio, + thumbWidth: removeVideoWidth, thumbHeight: h } }; @@ -406,10 +454,15 @@ const Filmstrip = { })); } promises.push(new Promise((resolve) => { - this.filmstrip.animate({ - // adds 2 px because of small video 1px border - height: remote.thumbHeight + 2 - }, this._getAnimateOptions(animate, resolve)); + // Let CSS take care of height in vertical filmstrip mode. + if (interfaceConfig.VERTICAL_FILMSTRIP) { + resolve(); + } else { + this.filmstrip.animate({ + // adds 2 px because of small video 1px border + height: remote.thumbHeight + 2 + }, this._getAnimateOptions(animate, resolve)); + } })); promises.push(new Promise(() => { @@ -456,8 +509,7 @@ const Filmstrip = { } let localThumb = $("#localVideoContainer"); - let remoteThumbs = this.filmstrip.children(selector) - .not("#localVideoContainer"); + let remoteThumbs = this.filmstripRemoteVideos.children(selector); // Exclude the local video container if it has been hidden. if (localThumb.hasClass("hidden")) { diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index 834abe968..36da7bc98 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -91,7 +91,8 @@ RemoteVideo.prototype._initPopupMenu = function (popupMenuElement) { content: popupMenuElement.outerHTML, skin: "black", hasArrow: false, - onBeforePosition: el => APP.translation.translateElement(el) + onBeforePosition: el => APP.translation.translateElement(el), + position: interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top' }; let element = $("#" + this.videoSpanId + " .remotevideomenu"); this.popover = new JitsiPopover(element, options); @@ -800,7 +801,7 @@ RemoteVideo.createContainer = function (spanId) { overlay.className = "videocontainer__hoverOverlay"; container.appendChild(overlay); - var remotes = document.getElementById('remoteVideos'); + var remotes = document.getElementById('filmstripRemoteVideosContainer'); return remotes.appendChild(container); }; diff --git a/modules/UI/videolayout/VideoLayout.js b/modules/UI/videolayout/VideoLayout.js index 7deb8555f..b2bc72a30 100644 --- a/modules/UI/videolayout/VideoLayout.js +++ b/modules/UI/videolayout/VideoLayout.js @@ -1,6 +1,10 @@ /* global APP, $, interfaceConfig */ const logger = require("jitsi-meet-logger").getLogger(__filename); +import { + setFilmstripRemoteVideosCount +} from '../../../react/features/filmstrip'; + import Filmstrip from "./Filmstrip"; import UIEvents from "../../../service/UI/UIEvents"; import UIUtil from "../util/UIUtil"; @@ -550,6 +554,9 @@ var VideoLayout = { if (onComplete && typeof onComplete === "function") onComplete(); }); + + APP.store.dispatch( + setFilmstripRemoteVideosCount(this.getRemoteVideosCount())); return { localVideo, remoteVideo }; }, @@ -1133,6 +1140,15 @@ var VideoLayout = { */ getLargeVideoWrapper() { return this.getCurrentlyOnLargeContainer().$wrapper; + }, + + /** + * Returns the number of remove video ids. + * + * @returns {number} The number of remote videos. + */ + getRemoteVideosCount() { + return Object.keys(remoteVideos).length; } }; diff --git a/react/features/conference/components/Conference.web.js b/react/features/conference/components/Conference.web.js index 8b91441f1..b3aee0b07 100644 --- a/react/features/conference/components/Conference.web.js +++ b/react/features/conference/components/Conference.web.js @@ -10,6 +10,7 @@ import { OverlayContainer } from '../../overlay'; import { Toolbox } from '../../toolbox'; import { HideNotificationBarStyle } from '../../unsupported-browser'; import { VideoStatusLabel } from '../../video-status-label'; +import '../../filmstrip'; declare var $: Function; declare var APP: Object; @@ -106,35 +107,7 @@ class Conference extends Component { src = 'images/spin.svg' />
-
-
- -
- -
); } + + /** + * Creates a React Element for displaying filmstrip videos. + * + * @private + * @returns {ReactElement} + */ + _renderFilmstrip() { + return ( +
+
+
+ +
+ +