From 2f1223f7211bf8aec270136e8266052bcd973f08 Mon Sep 17 00:00:00 2001 From: bgrozev Date: Tue, 7 Aug 2018 20:39:10 -0500 Subject: [PATCH 1/3] fix: Handles the case of e2eRtt being undefined. (#3354) --- .../connection-stats/components/ConnectionStatsTable.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/react/features/connection-stats/components/ConnectionStatsTable.js b/react/features/connection-stats/components/ConnectionStatsTable.js index c9f1efd46..ec0b5cd1e 100644 --- a/react/features/connection-stats/components/ConnectionStatsTable.js +++ b/react/features/connection-stats/components/ConnectionStatsTable.js @@ -227,7 +227,11 @@ class ConnectionStatsTable extends Component { */ _renderE2eRtt() { const { e2eRtt, region, t } = this.props; - const str = `${e2eRtt.toFixed(0)}ms (${region ? region : 'unknown'})`; + let str = e2eRtt ? `${e2eRtt.toFixed(0)}ms` : 'N/A'; + + if (region) { + str += ` (${region})`; + } return ( From c353e9377f6afda12771e28e3ed7252eb07a7aa6 Mon Sep 17 00:00:00 2001 From: virtuacoplenny Date: Wed, 8 Aug 2018 11:48:23 -0700 Subject: [PATCH 2/3] feat(tile-view): initial implementation for tile view (#3317) * feat(tile-view): initial implementation for tile view - Modify the classname on the app root so layout can adjust depending on the desired layout mode--vertical filmstrip, horizontal filmstrip, and tile view. - Create a button for toggling tile view. - Add a StateListenerRegistry to automatically update the selected participant and max receiver frame height on tile view toggle. - Rezise thumbnails when switching in and out of tile view. - Move the local video when switching in and out of tile view. - Update reactified pieces of thumbnails when switching in and out of tile view. - Cap the max receiver video quality in tile view based on tile size. - Use CSS to hide UI components that should not display in tile view. - Signal follow me changes. * change local video id for tests * change approach: leverage more css * squash: fix some formatting * squash: prevent pinning, hide pin border in tile view * squash: change logic for maxReceiverQuality due to sidestepping resizing logic * squash: fix typo, columns configurable, remove unused constants * squash: resize with js again * squash: use yana's math for calculating tile size --- css/filmstrip/_small_video.scss | 11 +- css/filmstrip/_tile_view.scss | 113 ++++++++++++++++++ css/filmstrip/_tile_view_overrides.scss | 47 ++++++++ css/main.scss | 2 + interface_config.js | 9 +- lang/main.json | 2 + modules/FollowMe.js | 67 ++++++++++- modules/UI/shared_video/SharedVideoThumb.js | 8 +- modules/UI/videolayout/Filmstrip.js | 110 +++++++++++++++-- modules/UI/videolayout/LocalVideo.js | 69 +++++++++-- modules/UI/videolayout/RemoteVideo.js | 23 +++- modules/UI/videolayout/SmallVideo.js | 55 ++++++++- modules/UI/videolayout/VideoLayout.js | 36 +++++- react/features/base/conference/functions.js | 35 +++++- .../conference/components/Conference.web.js | 69 +++++++---- .../filmstrip/components/web/Filmstrip.js | 4 +- react/features/large-video/actions.js | 12 +- .../large-video/components/AbstractLabels.js | 12 +- .../large-video/components/Labels.web.js | 3 +- .../large-video/components/LargeVideo.web.js | 2 +- .../toolbox/components/web/Toolbox.js | 3 + react/features/video-layout/actionTypes.js | 10 ++ react/features/video-layout/actions.js | 20 ++++ .../video-layout/components/TileViewButton.js | 90 ++++++++++++++ .../features/video-layout/components/index.js | 1 + react/features/video-layout/constants.js | 10 ++ react/features/video-layout/functions.js | 78 ++++++++++++ react/features/video-layout/index.js | 8 ++ react/features/video-layout/middleware.web.js | 6 + react/features/video-layout/reducer.js | 17 +++ react/features/video-layout/subscriber.js | 24 ++++ service/UI/UIEvents.js | 1 + 32 files changed, 876 insertions(+), 81 deletions(-) create mode 100644 css/filmstrip/_tile_view.scss create mode 100644 css/filmstrip/_tile_view_overrides.scss create mode 100644 react/features/video-layout/actionTypes.js create mode 100644 react/features/video-layout/actions.js create mode 100644 react/features/video-layout/components/TileViewButton.js create mode 100644 react/features/video-layout/components/index.js create mode 100644 react/features/video-layout/constants.js create mode 100644 react/features/video-layout/functions.js create mode 100644 react/features/video-layout/reducer.js create mode 100644 react/features/video-layout/subscriber.js diff --git a/css/filmstrip/_small_video.scss b/css/filmstrip/_small_video.scss index a42e1eb9e..86b822358 100644 --- a/css/filmstrip/_small_video.scss +++ b/css/filmstrip/_small_video.scss @@ -14,14 +14,9 @@ * Focused video thumbnail. */ &.videoContainerFocused { - transition-duration: 0.5s; - -webkit-transition-duration: 0.5s; - -webkit-animation-name: greyPulse; - -webkit-animation-duration: 2s; - -webkit-animation-iteration-count: 1; - border: $thumbnailVideoBorder solid $videoThumbnailSelected !important; + border: $thumbnailVideoBorder solid $videoThumbnailSelected; box-shadow: inset 0 0 3px $videoThumbnailSelected, - 0 0 3px $videoThumbnailSelected !important; + 0 0 3px $videoThumbnailSelected; } .remotevideomenu > .icon-menu { @@ -31,7 +26,7 @@ /** * Hovered video thumbnail. */ - &:hover { + &:hover:not(.videoContainerFocused):not(.active-speaker) { cursor: hand; border: $thumbnailVideoBorder solid $videoThumbnailHovered; box-shadow: inset 0 0 3px $videoThumbnailHovered, diff --git a/css/filmstrip/_tile_view.scss b/css/filmstrip/_tile_view.scss new file mode 100644 index 000000000..83ec6acd3 --- /dev/null +++ b/css/filmstrip/_tile_view.scss @@ -0,0 +1,113 @@ +/** + * CSS styles that are specific to the filmstrip that shows the thumbnail tiles. + */ +.tile-view { + /** + * Add a border around the active speaker to make the thumbnail easier to + * see. + */ + .active-speaker { + border: $thumbnailVideoBorder solid $videoThumbnailSelected; + box-shadow: inset 0 0 3px $videoThumbnailSelected, + 0 0 3px $videoThumbnailSelected; + } + + #filmstripRemoteVideos { + align-items: center; + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + } + + .filmstrip__videos .videocontainer { + &:not(.active-speaker), + &:hover:not(.active-speaker) { + border: none; + box-shadow: none; + } + } + + #remoteVideos { + /** + * Height is modified with an inline style in horizontal filmstrip mode + * so !important is used to override that. + */ + height: 100% !important; + width: 100%; + } + + .filmstrip { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: $filmstripVideosZ + } + + /** + * Regardless of the user setting, do not let the filmstrip be in a hidden + * state. + */ + .filmstrip__videos.hidden { + display: block; + } + + #filmstripRemoteVideos { + box-sizing: border-box; + + /** + * Allow scrolling of the thumbnails. + */ + overflow: auto; + } + + /** + * The size of the thumbnails should be set with javascript, based on + * desired column count and window width. The rows are created using flex + * and allowing the thumbnails to wrap. + */ + #filmstripRemoteVideosContainer { + align-content: center; + align-items: center; + box-sizing: border-box; + display: flex; + flex-wrap: wrap; + height: 100vh; + justify-content: center; + padding: 100px 0; + + .videocontainer { + box-sizing: border-box; + display: block; + margin: 5px; + } + + video { + object-fit: contain; + } + } + + .has-overflow#filmstripRemoteVideosContainer { + align-content: baseline; + } + + .has-overflow .videocontainer { + align-self: baseline; + } + + /** + * Firefox flex acts a little differently. To make sure the bottom row of + * thumbnails is not overlapped by the horizontal toolbar, margin is added + * to the local thumbnail to keep it from the bottom of the screen. It is + * assumed the local thumbnail will always be on the bottom row. + */ + .has-overflow #localVideoContainer { + margin-bottom: 100px !important; + } +} diff --git a/css/filmstrip/_tile_view_overrides.scss b/css/filmstrip/_tile_view_overrides.scss new file mode 100644 index 000000000..c79273a9e --- /dev/null +++ b/css/filmstrip/_tile_view_overrides.scss @@ -0,0 +1,47 @@ +/** + * Various overrides outside of the filmstrip to style the app to support a + * tiled thumbnail experience. + */ +.tile-view { + /** + * Let the avatar grow with the tile. + */ + .userAvatar { + max-height: initial; + max-width: initial; + } + + /** + * Hide various features that should not be displayed while in tile view. + */ + #dominantSpeaker, + #filmstripLocalVideoThumbnail, + #largeVideoElementsContainer, + #sharedVideo, + .filmstrip__toolbar { + display: none; + } + + #localConnectionMessage, + #remoteConnectionMessage, + .watermark { + z-index: $filmstripVideosZ + 1; + } + + /** + * The follow styling uses !important to override inline styles set with + * javascript. + * + * TODO: These overrides should be more easy to remove and should be removed + * when the components are in react so their rendering done declaratively, + * making conditional styling easier to apply. + */ + #largeVideoElementsContainer, + #remoteConnectionMessage, + #remotePresenceMessage { + display: none !important; + } + #largeVideoContainer { + background-color: $defaultBackground !important; + } +} diff --git a/css/main.scss b/css/main.scss index 87a01c646..aecc17e84 100644 --- a/css/main.scss +++ b/css/main.scss @@ -72,6 +72,8 @@ @import 'filmstrip/filmstrip_toolbar'; @import 'filmstrip/horizontal_filmstrip'; @import 'filmstrip/small_video'; +@import 'filmstrip/tile_view'; +@import 'filmstrip/tile_view_overrides'; @import 'filmstrip/vertical_filmstrip'; @import 'filmstrip/vertical_filmstrip_overrides'; @import 'unsupported-browser/main'; diff --git a/interface_config.js b/interface_config.js index b9d17b1c5..9b731e2b4 100644 --- a/interface_config.js +++ b/interface_config.js @@ -48,7 +48,8 @@ var interfaceConfig = { 'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen', 'fodeviceselection', 'hangup', 'profile', 'info', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', - 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts' + 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts', + 'tileview' ], SETTINGS_SECTIONS: [ 'devices', 'language', 'moderator', 'profile' ], @@ -172,6 +173,12 @@ var interfaceConfig = { */ RECENT_LIST_ENABLED: true + /** + * How many columns the tile view can expand to. The respected range is + * between 1 and 5. + */ + // TILE_VIEW_MAX_COLUMNS: 5, + /** * Specify custom URL for downloading android mobile app. */ diff --git a/lang/main.json b/lang/main.json index 2d5f356f6..46c7da6ff 100644 --- a/lang/main.json +++ b/lang/main.json @@ -102,6 +102,7 @@ "shortcuts": "Toggle shortcuts", "speakerStats": "Toggle speaker statistics", "toggleCamera": "Toggle camera", + "tileView": "Toggle tile view", "videomute": "Toggle mute video" }, "addPeople": "Add people to your call", @@ -144,6 +145,7 @@ "raiseHand": "Raise / Lower your hand", "shortcuts": "View shortcuts", "speakerStats": "Speaker stats", + "tileViewToggle": "Toggle tile view", "invite": "Invite people" }, "chat":{ diff --git a/modules/FollowMe.js b/modules/FollowMe.js index 239e1f68d..2428f5c1f 100644 --- a/modules/FollowMe.js +++ b/modules/FollowMe.js @@ -21,6 +21,7 @@ import { getPinnedParticipant, pinParticipant } from '../react/features/base/participants'; +import { setTileView } from '../react/features/video-layout'; import UIEvents from '../service/UI/UIEvents'; import VideoLayout from './UI/videolayout/VideoLayout'; @@ -117,6 +118,31 @@ class State { } } + /** + * A getter for this object instance to know the state of tile view. + * + * @returns {boolean} True if tile view is enabled. + */ + get tileViewEnabled() { + return this._tileViewEnabled; + } + + /** + * A setter for {@link tileViewEnabled}. Fires a property change event for + * other participants to follow. + * + * @param {boolean} b - Whether or not tile view is enabled. + * @returns {void} + */ + set tileViewEnabled(b) { + const oldValue = this._tileViewEnabled; + + if (oldValue !== b) { + this._tileViewEnabled = b; + this._firePropertyChange('tileViewEnabled', oldValue, b); + } + } + /** * Invokes {_propertyChangeCallback} to notify it that {property} had its * value changed from {oldValue} to {newValue}. @@ -189,6 +215,10 @@ class FollowMe { this._sharedDocumentToggled .bind(this, this._UI.getSharedDocumentManager().isVisible()); } + + this._tileViewToggled.bind( + this, + APP.store.getState()['features/video-layout'].tileViewEnabled); } /** @@ -214,6 +244,10 @@ class FollowMe { this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this); this._UI.addListener(UIEvents.TOGGLED_SHARED_DOCUMENT, this.sharedDocEventHandler); + + this.tileViewEventHandler = this._tileViewToggled.bind(this); + this._UI.addListener(UIEvents.TOGGLED_TILE_VIEW, + this.tileViewEventHandler); } /** @@ -227,6 +261,8 @@ class FollowMe { this.sharedDocEventHandler); this._UI.removeListener(UIEvents.PINNED_ENDPOINT, this.pinnedEndpointEventHandler); + this._UI.removeListener(UIEvents.TOGGLED_TILE_VIEW, + this.tileViewEventHandler); } /** @@ -266,6 +302,18 @@ class FollowMe { this._local.sharedDocumentVisible = sharedDocumentVisible; } + /** + * Notifies this instance that the tile view mode has been enabled or + * disabled. + * + * @param {boolean} enabled - True if tile view has been enabled, false + * if has been disabled. + * @returns {void} + */ + _tileViewToggled(enabled) { + this._local.tileViewEnabled = enabled; + } + /** * Changes the nextOnStage property value. * @@ -316,7 +364,8 @@ class FollowMe { attributes: { filmstripVisible: local.filmstripVisible, nextOnStage: local.nextOnStage, - sharedDocumentVisible: local.sharedDocumentVisible + sharedDocumentVisible: local.sharedDocumentVisible, + tileViewEnabled: local.tileViewEnabled } }); } @@ -355,6 +404,7 @@ class FollowMe { this._onFilmstripVisible(attributes.filmstripVisible); this._onNextOnStage(attributes.nextOnStage); this._onSharedDocumentVisible(attributes.sharedDocumentVisible); + this._onTileViewEnabled(attributes.tileViewEnabled); } /** @@ -434,6 +484,21 @@ class FollowMe { } } + /** + * Process a tile view enabled / disabled event received from FOLLOW-ME. + * + * @param {boolean} enabled - Whether or not tile view should be shown. + * @private + * @returns {void} + */ + _onTileViewEnabled(enabled) { + if (typeof enabled === 'undefined') { + return; + } + + APP.store.dispatch(setTileView(enabled === 'true')); + } + /** * Pins / unpins the video thumbnail given by clickId. * diff --git a/modules/UI/shared_video/SharedVideoThumb.js b/modules/UI/shared_video/SharedVideoThumb.js index bdffe7d6c..50708559d 100644 --- a/modules/UI/shared_video/SharedVideoThumb.js +++ b/modules/UI/shared_video/SharedVideoThumb.js @@ -1,4 +1,6 @@ -/* global $ */ +/* global $, APP */ +import { shouldDisplayTileView } from '../../../react/features/video-layout'; + import SmallVideo from '../videolayout/SmallVideo'; const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -64,7 +66,9 @@ SharedVideoThumb.prototype.createContainer = function(spanId) { * The thumb click handler. */ SharedVideoThumb.prototype.videoClick = function() { - this._togglePin(); + if (!shouldDisplayTileView(APP.store.getState())) { + this._togglePin(); + } }; /** diff --git a/modules/UI/videolayout/Filmstrip.js b/modules/UI/videolayout/Filmstrip.js index 41d6531b3..0c81939a3 100644 --- a/modules/UI/videolayout/Filmstrip.js +++ b/modules/UI/videolayout/Filmstrip.js @@ -1,6 +1,13 @@ /* global $, APP, interfaceConfig */ import { setFilmstripVisible } from '../../../react/features/filmstrip'; +import { + LAYOUTS, + getCurrentLayout, + getMaxColumnCount, + getTileViewGridDimensions, + shouldDisplayTileView +} from '../../../react/features/video-layout'; import UIEvents from '../../../service/UI/UIEvents'; import UIUtil from '../util/UIUtil'; @@ -233,6 +240,10 @@ const Filmstrip = { * @returns {*|{localVideo, remoteVideo}} */ calculateThumbnailSize() { + if (shouldDisplayTileView(APP.store.getState())) { + return this._calculateThumbnailSizeForTileView(); + } + const availableSizes = this.calculateAvailableSize(); const width = availableSizes.availableWidth; const height = availableSizes.availableHeight; @@ -247,11 +258,10 @@ const Filmstrip = { * @returns {{availableWidth: number, availableHeight: number}} */ calculateAvailableSize() { - let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT; - const thumbs = this.getThumbs(true); - const numvids = thumbs.remoteThumbs.length; - - const localVideoContainer = $('#localVideoContainer'); + const state = APP.store.getState(); + const currentLayout = getCurrentLayout(state); + const isHorizontalFilmstripView + = currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW; /** * If the videoAreaAvailableWidth is set we use this one to calculate @@ -268,10 +278,15 @@ const Filmstrip = { - UIUtil.parseCssInt(this.filmstrip.css('borderRightWidth'), 10) - 5; + let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT; let availableWidth = videoAreaAvailableWidth; + const thumbs = this.getThumbs(true); + // If local thumb is not hidden if (thumbs.localThumb) { + const localVideoContainer = $('#localVideoContainer'); + availableWidth = Math.floor( videoAreaAvailableWidth - ( UIUtil.parseCssInt( @@ -289,10 +304,12 @@ const Filmstrip = { ); } - // If the number of videos is 0 or undefined or we're in vertical + // If the number of videos is 0 or undefined or we're not in horizontal // filmstrip mode we don't need to calculate further any adjustments // to width based on the number of videos present. - if (numvids && !interfaceConfig.VERTICAL_FILMSTRIP) { + const numvids = thumbs.remoteThumbs.length; + + if (numvids && isHorizontalFilmstripView) { const remoteVideoContainer = thumbs.remoteThumbs.eq(0); availableWidth = Math.floor( @@ -322,8 +339,10 @@ const Filmstrip = { availableHeight = Math.min(maxHeight, window.innerHeight - 18); - return { availableWidth, - availableHeight }; + return { + availableHeight, + availableWidth + }; }, /** @@ -434,6 +453,51 @@ const Filmstrip = { }; }, + /** + * Calculates the size for thumbnails when in tile view layout. + * + * @returns {{localVideo, remoteVideo}} + */ + _calculateThumbnailSizeForTileView() { + const tileAspectRatio = 16 / 9; + + // The distance from the top and bottom of the screen, as set by CSS, to + // avoid overlapping UI elements. + const topBottomPadding = 200; + + // Minimum space to keep between the sides of the tiles and the sides + // of the window. + const sideMargins = 30 * 2; + + const state = APP.store.getState(); + + const viewWidth = document.body.clientWidth - sideMargins; + const viewHeight = document.body.clientHeight - topBottomPadding; + + const { + columns, + visibleRows + } = getTileViewGridDimensions(state, getMaxColumnCount()); + const initialWidth = viewWidth / columns; + const aspectRatioHeight = initialWidth / tileAspectRatio; + + const heightOfEach = Math.min( + aspectRatioHeight, + viewHeight / visibleRows); + const widthOfEach = tileAspectRatio * heightOfEach; + + return { + localVideo: { + thumbWidth: widthOfEach, + thumbHeight: heightOfEach + }, + remoteVideo: { + thumbWidth: widthOfEach, + thumbHeight: heightOfEach + } + }; + }, + /** * Resizes thumbnails * @param local @@ -443,6 +507,28 @@ const Filmstrip = { */ // eslint-disable-next-line max-params resizeThumbnails(local, remote, forceUpdate = false) { + const state = APP.store.getState(); + + if (shouldDisplayTileView(state)) { + // The size of the side margins for each tile as set in CSS. + const sideMargins = 10 * 2; + const { + columns, + rows + } = getTileViewGridDimensions(state, getMaxColumnCount()); + const hasOverflow = rows > columns; + + // Width is set so that the flex layout can automatically wrap + // tiles onto new rows. + this.filmstripRemoteVideos.css({ + width: (local.thumbWidth * columns) + (columns * sideMargins) + }); + + this.filmstripRemoteVideos.toggleClass('has-overflow', hasOverflow); + } else { + this.filmstripRemoteVideos.css('width', ''); + } + const thumbs = this.getThumbs(!forceUpdate); if (thumbs.localThumb) { @@ -466,13 +552,15 @@ const Filmstrip = { }); } + const currentLayout = getCurrentLayout(APP.store.getState()); + // Let CSS take care of height in vertical filmstrip mode. - if (interfaceConfig.VERTICAL_FILMSTRIP) { + if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) { $('#filmstripLocalVideo').css({ // adds 4 px because of small video 2px border width: `${local.thumbWidth + 4}px` }); - } else { + } else if (currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW) { this.filmstrip.css({ // adds 4 px because of small video 2px border height: `${remote.thumbHeight + 4}px` diff --git a/modules/UI/videolayout/LocalVideo.js b/modules/UI/videolayout/LocalVideo.js index d5909ae9a..20043daa7 100644 --- a/modules/UI/videolayout/LocalVideo.js +++ b/modules/UI/videolayout/LocalVideo.js @@ -11,6 +11,7 @@ import { getAvatarURLByParticipantId } from '../../../react/features/base/participants'; import { updateSettings } from '../../../react/features/base/settings'; +import { shouldDisplayTileView } from '../../../react/features/video-layout'; /* eslint-enable no-unused-vars */ const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -26,7 +27,7 @@ function LocalVideo(VideoLayout, emitter, streamEndedCallback) { this.streamEndedCallback = streamEndedCallback; this.container = this.createContainer(); this.$container = $(this.container); - $('#filmstripLocalVideoThumbnail').append(this.container); + this.updateDOMLocation(); this.localVideoId = null; this.bindHoverHandler(); @@ -109,16 +110,7 @@ LocalVideo.prototype.changeVideo = function(stream) { this.localVideoId = `localVideo_${stream.getId()}`; - const localVideoContainer = document.getElementById('localVideoWrapper'); - - ReactDOM.render( - - - , - localVideoContainer - ); + this._updateVideoElement(); // eslint-disable-next-line eqeqeq const isVideo = stream.videoType != 'desktop'; @@ -128,12 +120,14 @@ LocalVideo.prototype.changeVideo = function(stream) { this.setFlipX(isVideo ? settings.localFlipX : false); const endedHandler = () => { + const localVideoContainer + = document.getElementById('localVideoWrapper'); // Only remove if there is no video and not a transition state. // Previous non-react logic created a new video element with each track // removal whereas react reuses the video component so it could be the // stream ended but a new one is being used. - if (this.videoStream.isEnded()) { + if (localVideoContainer && this.videoStream.isEnded()) { ReactDOM.unmountComponentAtNode(localVideoContainer); } @@ -235,6 +229,29 @@ LocalVideo.prototype._enableDisableContextMenu = function(enable) { } }; +/** + * Places the {@code LocalVideo} in the DOM based on the current video layout. + * + * @returns {void} + */ +LocalVideo.prototype.updateDOMLocation = function() { + if (!this.container) { + return; + } + + if (this.container.parentElement) { + this.container.parentElement.removeChild(this.container); + } + + const appendTarget = shouldDisplayTileView(APP.store.getState()) + ? document.getElementById('localVideoTileViewContainer') + : document.getElementById('filmstripLocalVideoThumbnail'); + + appendTarget && appendTarget.appendChild(this.container); + + this._updateVideoElement(); +}; + /** * Callback invoked when the thumbnail is clicked. Will directly call * VideoLayout to handle thumbnail click if certain elements have not been @@ -258,7 +275,9 @@ LocalVideo.prototype._onContainerClick = function(event) { = $source.parents('.displayNameContainer').length > 0; const clickedOnPopover = $source.parents('.popover').length > 0 || classList.contains('popover'); - const ignoreClick = clickedOnDisplayName || clickedOnPopover; + const ignoreClick = clickedOnDisplayName + || clickedOnPopover + || shouldDisplayTileView(APP.store.getState()); if (event.stopPropagation && !ignoreClick) { event.stopPropagation(); @@ -269,4 +288,28 @@ LocalVideo.prototype._onContainerClick = function(event) { } }; +/** + * Renders the React Element for displaying video in {@code LocalVideo}. + * + */ +LocalVideo.prototype._updateVideoElement = function() { + const localVideoContainer = document.getElementById('localVideoWrapper'); + + ReactDOM.render( + + + , + localVideoContainer + ); + + // Ensure the video gets play() called on it. This may be necessary in the + // case where the local video container was moved and re-attached, in which + // case video does not autoplay. + const video = this.container.querySelector('video'); + + video && video.play(); +}; + export default LocalVideo; diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index 0611df3d0..dd43de6d6 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -20,6 +20,11 @@ import { REMOTE_CONTROL_MENU_STATES, RemoteVideoMenuTriggerButton } from '../../../react/features/remote-video-menu'; +import { + LAYOUTS, + getCurrentLayout, + shouldDisplayTileView +} from '../../../react/features/video-layout'; /* eslint-enable no-unused-vars */ const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -163,8 +168,17 @@ RemoteVideo.prototype._generatePopupContent = function() { const onVolumeChange = this._setAudioVolume; const { isModerator } = APP.conference; const participantID = this.id; - const menuPosition = interfaceConfig.VERTICAL_FILMSTRIP - ? 'left bottom' : 'top center'; + + const currentLayout = getCurrentLayout(APP.store.getState()); + let remoteMenuPosition; + + if (currentLayout === LAYOUTS.TILE_VIEW) { + remoteMenuPosition = 'left top'; + } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) { + remoteMenuPosition = 'left bottom'; + } else { + remoteMenuPosition = 'top center'; + } ReactDOM.render( @@ -174,7 +188,7 @@ RemoteVideo.prototype._generatePopupContent = function() { initialVolumeValue = { initialVolumeValue } isAudioMuted = { this.isAudioMuted } isModerator = { isModerator } - menuPosition = { menuPosition } + menuPosition = { remoteMenuPosition } onMenuDisplay = {this._onRemoteVideoMenuDisplay.bind(this)} onRemoteControlToggle = { onRemoteControlToggle } @@ -613,7 +627,8 @@ RemoteVideo.prototype._onContainerClick = function(event) { const { classList } = event.target; const ignoreClick = $source.parents('.popover').length > 0 - || classList.contains('popover'); + || classList.contains('popover') + || shouldDisplayTileView(APP.store.getState()); if (!ignoreClick) { this._togglePin(); diff --git a/modules/UI/videolayout/SmallVideo.js b/modules/UI/videolayout/SmallVideo.js index 669aad38d..70942aa11 100644 --- a/modules/UI/videolayout/SmallVideo.js +++ b/modules/UI/videolayout/SmallVideo.js @@ -27,6 +27,11 @@ import { RaisedHandIndicator, VideoMutedIndicator } from '../../../react/features/filmstrip'; +import { + LAYOUTS, + getCurrentLayout, + shouldDisplayTileView +} from '../../../react/features/video-layout'; /* eslint-enable no-unused-vars */ const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -328,7 +333,21 @@ SmallVideo.prototype.setVideoMutedView = function(isMuted) { SmallVideo.prototype.updateStatusBar = function() { const statusBarContainer = this.container.querySelector('.videocontainer__toolbar'); - const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top'; + + if (!statusBarContainer) { + return; + } + + const currentLayout = getCurrentLayout(APP.store.getState()); + let tooltipPosition; + + if (currentLayout === LAYOUTS.TILE_VIEW) { + tooltipPosition = 'right'; + } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) { + tooltipPosition = 'left'; + } else { + tooltipPosition = 'top'; + } ReactDOM.render( @@ -547,7 +566,8 @@ SmallVideo.prototype.isVideoPlayable = function() { */ SmallVideo.prototype.selectDisplayMode = function() { // Display name is always and only displayed when user is on the stage - if (this.isCurrentlyOnLargeVideo()) { + if (this.isCurrentlyOnLargeVideo() + && !shouldDisplayTileView(APP.store.getState())) { return this.isVideoPlayable() && !APP.conference.isAudioOnly() ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME; } else if (this.isVideoPlayable() @@ -685,7 +705,10 @@ SmallVideo.prototype.showDominantSpeakerIndicator = function(show) { this._showDominantSpeaker = show; + this.$container.toggleClass('active-speaker', this._showDominantSpeaker); + this.updateIndicators(); + this.updateView(); }; /** @@ -765,6 +788,18 @@ SmallVideo.prototype.initBrowserSpecificProperties = function() { } }; +/** + * Helper function for re-rendering multiple react components of the small + * video. + * + * @returns {void} + */ +SmallVideo.prototype.rerender = function() { + this.updateIndicators(); + this.updateStatusBar(); + this.updateView(); +}; + /** * Updates the React element responsible for showing connection status, dominant * speaker, and raised hand icons. Uses instance variables to get the necessary @@ -784,7 +819,19 @@ SmallVideo.prototype.updateIndicators = function() { const iconSize = UIUtil.getIndicatorFontSize(); const showConnectionIndicator = this.videoIsHovered || !interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED; - const tooltipPosition = interfaceConfig.VERTICAL_FILMSTRIP ? 'left' : 'top'; + const currentLayout = getCurrentLayout(APP.store.getState()); + let statsPopoverPosition, tooltipPosition; + + if (currentLayout === LAYOUTS.TILE_VIEW) { + statsPopoverPosition = 'right top'; + tooltipPosition = 'right'; + } else if (currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) { + statsPopoverPosition = this.statsPopoverLocation; + tooltipPosition = 'left'; + } else { + statsPopoverPosition = this.statsPopoverLocation; + tooltipPosition = 'top'; + } ReactDOM.render( @@ -799,7 +846,7 @@ SmallVideo.prototype.updateIndicators = function() { enableStatsDisplay = { !interfaceConfig.filmStripOnly } statsPopoverPosition - = { this.statsPopoverLocation } + = { statsPopoverPosition } userID = { this.id } /> : null } { this._showRaisedHand diff --git a/modules/UI/videolayout/VideoLayout.js b/modules/UI/videolayout/VideoLayout.js index ff743ecef..fd97dc740 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 { + getNearestReceiverVideoQualityLevel, + setMaxReceiverVideoQuality +} from '../../../react/features/base/conference'; import { JitsiParticipantConnectionStatus } from '../../../react/features/base/lib-jitsi-meet'; @@ -9,6 +13,9 @@ import { getPinnedParticipant, pinParticipant } from '../../../react/features/base/participants'; +import { + shouldDisplayTileView +} from '../../../react/features/video-layout'; import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo'; import SharedVideoThumb from '../shared_video/SharedVideoThumb'; @@ -594,12 +601,19 @@ const VideoLayout = { Filmstrip.resizeThumbnails(localVideo, remoteVideo, forceUpdate); + if (shouldDisplayTileView(APP.store.getState())) { + const height + = (localVideo && localVideo.thumbHeight) + || (remoteVideo && remoteVideo.thumbnHeight) + || 0; + const qualityLevel = getNearestReceiverVideoQualityLevel(height); + + APP.store.dispatch(setMaxReceiverVideoQuality(qualityLevel)); + } + if (onComplete && typeof onComplete === 'function') { onComplete(); } - - return { localVideo, - remoteVideo }; }, /** @@ -1142,6 +1156,22 @@ const VideoLayout = { ); }, + /** + * Helper method to invoke when the video layout has changed and elements + * have to be re-arranged and resized. + * + * @returns {void} + */ + refreshLayout() { + localVideoThumbnail && localVideoThumbnail.updateDOMLocation(); + VideoLayout.resizeVideoArea(); + + localVideoThumbnail && localVideoThumbnail.rerender(); + Object.values(remoteVideos).forEach( + remoteVideo => remoteVideo.rerender() + ); + }, + /** * Triggers an update of large video if the passed in participant is * currently displayed on large video. diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 5b1145681..fd0692539 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -8,7 +8,8 @@ import { AVATAR_ID_COMMAND, AVATAR_URL_COMMAND, EMAIL_COMMAND, - JITSI_CONFERENCE_URL_KEY + JITSI_CONFERENCE_URL_KEY, + VIDEO_QUALITY_LEVELS } from './constants'; const logger = require('jitsi-meet-logger').getLogger(__filename); @@ -102,6 +103,38 @@ export function getCurrentConference(stateful: Function | Object) { : joining); } +/** + * Finds the nearest match for the passed in {@link availableHeight} to am + * enumerated value in {@code VIDEO_QUALITY_LEVELS}. + * + * @param {number} availableHeight - The height to which a matching video + * quality level should be found. + * @returns {number} The closest matching value from + * {@code VIDEO_QUALITY_LEVELS}. + */ +export function getNearestReceiverVideoQualityLevel(availableHeight: number) { + const qualityLevels = [ + VIDEO_QUALITY_LEVELS.HIGH, + VIDEO_QUALITY_LEVELS.STANDARD, + VIDEO_QUALITY_LEVELS.LOW + ]; + + let selectedLevel = qualityLevels[0]; + + for (let i = 1; i < qualityLevels.length; i++) { + const previousValue = qualityLevels[i - 1]; + const currentValue = qualityLevels[i]; + const diffWithCurrent = Math.abs(availableHeight - currentValue); + const diffWithPrevious = Math.abs(availableHeight - previousValue); + + if (diffWithCurrent < diffWithPrevious) { + selectedLevel = currentValue; + } + } + + return selectedLevel; +} + /** * Handle an error thrown by the backend (i.e. lib-jitsi-meet) while * manipulating a conference participant (e.g. pin or select participant). diff --git a/react/features/conference/components/Conference.web.js b/react/features/conference/components/Conference.web.js index b78c15c62..1a4e43925 100644 --- a/react/features/conference/components/Conference.web.js +++ b/react/features/conference/components/Conference.web.js @@ -4,6 +4,8 @@ import _ from 'lodash'; import React, { Component } from 'react'; import { connect as reactReduxConnect } from 'react-redux'; +import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout'; + import { obtainConfig } from '../../base/config'; import { connect, disconnect } from '../../base/connection'; import { DialogContainer } from '../../base/dialog'; @@ -13,6 +15,12 @@ import { CalleeInfoContainer } from '../../invite'; import { LargeVideo } from '../../large-video'; import { NotificationsContainer } from '../../notifications'; import { SidePanel } from '../../side-panel'; +import { + LAYOUTS, + getCurrentLayout, + shouldDisplayTileView +} from '../../video-layout'; + import { default as Notice } from './Notice'; import { Toolbox, @@ -49,9 +57,10 @@ const FULL_SCREEN_EVENTS = [ * @private * @type {Object} */ -const LAYOUT_CLASSES = { - HORIZONTAL_FILMSTRIP: 'horizontal-filmstrip', - VERTICAL_FILMSTRIP: 'vertical-filmstrip' +const LAYOUT_CLASSNAMES = { + [LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW]: 'horizontal-filmstrip', + [LAYOUTS.TILE_VIEW]: 'tile-view', + [LAYOUTS.VERTICAL_FILMSTRIP_VIEW]: 'vertical-filmstrip' }; /** @@ -68,13 +77,18 @@ type Props = { * The CSS class to apply to the root of {@link Conference} to modify the * application layout. */ - _layoutModeClassName: string, + _layoutClassName: string, /** * Conference room name. */ _room: string, + /** + * Whether or not the current UI layout should be in tile view. + */ + _shouldDisplayTileView: boolean, + dispatch: Function, t: Function } @@ -143,6 +157,25 @@ class Conference extends Component { } } + /** + * Calls into legacy UI to update the application layout, if necessary. + * + * @inheritdoc + * returns {void} + */ + componentDidUpdate(prevProps) { + if (this.props._shouldDisplayTileView + === prevProps._shouldDisplayTileView) { + return; + } + + // TODO: For now VideoLayout is being called as LargeVideo and Filmstrip + // sizing logic is still handled outside of React. Once all components + // are in react they should calculate size on their own as much as + // possible and pass down sizings. + VideoLayout.refreshLayout(); + } + /** * Disconnect from the conference when component will be * unmounted. @@ -180,7 +213,7 @@ class Conference extends Component { return (
@@ -257,29 +290,19 @@ class Conference extends Component { * @private * @returns {{ * _iAmRecorder: boolean, - * _room: ?string + * _layoutClassName: string, + * _room: ?string, + * _shouldDisplayTileView: boolean * }} */ function _mapStateToProps(state) { - const { room } = state['features/base/conference']; - const { iAmRecorder } = state['features/base/config']; + const currentLayout = getCurrentLayout(state); return { - /** - * Whether the local participant is recording the conference. - * - * @private - */ - _iAmRecorder: iAmRecorder, - - _layoutModeClassName: interfaceConfig.VERTICAL_FILMSTRIP - ? LAYOUT_CLASSES.VERTICAL_FILMSTRIP - : LAYOUT_CLASSES.HORIZONTAL_FILMSTRIP, - - /** - * Conference room name. - */ - _room: room + _iAmRecorder: state['features/base/config'].iAmRecorder, + _layoutClassName: LAYOUT_CLASSNAMES[currentLayout], + _room: state['features/base/conference'].room, + _shouldDisplayTileView: shouldDisplayTileView(state) }; } diff --git a/react/features/filmstrip/components/web/Filmstrip.js b/react/features/filmstrip/components/web/Filmstrip.js index 1f0f9f34a..b53c696db 100644 --- a/react/features/filmstrip/components/web/Filmstrip.js +++ b/react/features/filmstrip/components/web/Filmstrip.js @@ -8,6 +8,7 @@ import { dockToolbox } from '../../../toolbox'; import { setFilmstripHovered } from '../../actions'; import { shouldRemoteVideosBeVisible } from '../../functions'; + import Toolbar from './Toolbar'; declare var interfaceConfig: Object; @@ -185,9 +186,8 @@ function _mapStateToProps(state) { && state['features/toolbox'].visible && interfaceConfig.TOOLBAR_BUTTONS.length; const remoteVideosVisible = shouldRemoteVideosBeVisible(state); - const className = `${remoteVideosVisible ? '' : 'hide-videos'} ${ - reduceHeight ? 'reduce-height' : ''}`; + reduceHeight ? 'reduce-height' : ''}`.trim(); return { _className: className, diff --git a/react/features/large-video/actions.js b/react/features/large-video/actions.js index a5f9e8eec..91cc4b608 100644 --- a/react/features/large-video/actions.js +++ b/react/features/large-video/actions.js @@ -6,7 +6,9 @@ import { } from '../analytics'; import { _handleParticipantError } from '../base/conference'; import { MEDIA_TYPE } from '../base/media'; +import { getParticipants } from '../base/participants'; import { reportError } from '../base/util'; +import { shouldDisplayTileView } from '../video-layout'; import { SELECT_LARGE_VIDEO_PARTICIPANT, @@ -26,17 +28,19 @@ export function selectParticipant() { const { conference } = state['features/base/conference']; if (conference) { - const largeVideo = state['features/large-video']; - const id = largeVideo.participantId; + const ids = shouldDisplayTileView(state) + ? getParticipants(state).map(participant => participant.id) + : [ state['features/large-video'].participantId ]; try { - conference.selectParticipant(id); + conference.selectParticipants(ids); } catch (err) { _handleParticipantError(err); sendAnalytics(createSelectParticipantFailedEvent(err)); - reportError(err, `Failed to select participant ${id}`); + reportError( + err, `Failed to select participants ${ids.toString()}`); } } }; diff --git a/react/features/large-video/components/AbstractLabels.js b/react/features/large-video/components/AbstractLabels.js index a7a7e0ce8..b666f60a2 100644 --- a/react/features/large-video/components/AbstractLabels.js +++ b/react/features/large-video/components/AbstractLabels.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { isFilmstripVisible } from '../../filmstrip'; import { RecordingLabel } from '../../recording'; +import { shouldDisplayTileView } from '../../video-layout'; import { VideoQualityLabel } from '../../video-quality'; import { TranscribingLabel } from '../../transcribing/'; @@ -17,6 +18,11 @@ export type Props = { * determine display classes to set. */ _filmstripVisible: boolean, + + /** + * Whether or not the video quality label should be displayed. + */ + _showVideoQualityLabel: boolean }; /** @@ -72,11 +78,13 @@ export default class AbstractLabels extends Component { * @param {Object} state - The Redux state. * @private * @returns {{ - * _filmstripVisible: boolean + * _filmstripVisible: boolean, + * _showVideoQualityLabel: boolean * }} */ export function _abstractMapStateToProps(state: Object) { return { - _filmstripVisible: isFilmstripVisible(state) + _filmstripVisible: isFilmstripVisible(state), + _showVideoQualityLabel: !shouldDisplayTileView(state) }; } diff --git a/react/features/large-video/components/Labels.web.js b/react/features/large-video/components/Labels.web.js index c5e5c5343..41df7a7cb 100644 --- a/react/features/large-video/components/Labels.web.js +++ b/react/features/large-video/components/Labels.web.js @@ -89,7 +89,8 @@ class Labels extends AbstractLabels { this._renderTranscribingLabel() } { - this._renderVideoQualityLabel() + this.props._showVideoQualityLabel + && this._renderVideoQualityLabel() }
); diff --git a/react/features/large-video/components/LargeVideo.web.js b/react/features/large-video/components/LargeVideo.web.js index 2cc1fe952..08e08d8de 100644 --- a/react/features/large-video/components/LargeVideo.web.js +++ b/react/features/large-video/components/LargeVideo.web.js @@ -50,7 +50,7 @@ export default class LargeVideo extends Component<*> {
-
+
{ diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index f2678a563..a9854c07b 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -40,6 +40,7 @@ import { import { toggleSharedVideo } from '../../../shared-video'; import { toggleChat } from '../../../side-panel'; import { SpeakerStats } from '../../../speaker-stats'; +import { TileViewButton } from '../../../video-layout'; import { OverflowMenuVideoQualityItem, VideoQualityDialog @@ -369,6 +370,8 @@ class Toolbox extends Component { visible = { this._shouldShowButton('camera') } />
+ { this._shouldShowButton('tileview') + && } { this._shouldShowButton('invite') && !_hideInviteButton && +}; + +/** + * Component that renders a toolbar button for toggling the tile layout view. + * + * @extends AbstractButton + */ +class TileViewButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.tileView'; + iconName = 'icon-tiles-many'; + toggledIconName = 'icon-tiles-many toggled'; + tooltip = 'toolbar.tileViewToggle'; + + /** + * Handles clicking / pressing the button. + * + * @override + * @protected + * @returns {void} + */ + _handleClick() { + const { _tileViewEnabled, dispatch } = this.props; + + sendAnalytics(createToolbarEvent( + 'tileview.button', + { + 'is_enabled': _tileViewEnabled + })); + + dispatch(setTileView(!_tileViewEnabled)); + } + + /** + * Indicates whether this button is in toggled state or not. + * + * @override + * @protected + * @returns {boolean} + */ + _isToggled() { + return this.props._tileViewEnabled; + } +} + +/** + * Maps (parts of) the redux state to the associated props for the + * {@code TileViewButton} component. + * + * @param {Object} state - The Redux state. + * @returns {{ + * _tileViewEnabled: boolean + * }} + */ +function _mapStateToProps(state) { + return { + _tileViewEnabled: state['features/video-layout'].tileViewEnabled + }; +} + +export default translate(connect(_mapStateToProps)(TileViewButton)); diff --git a/react/features/video-layout/components/index.js b/react/features/video-layout/components/index.js new file mode 100644 index 000000000..baaeb408c --- /dev/null +++ b/react/features/video-layout/components/index.js @@ -0,0 +1 @@ +export { default as TileViewButton } from './TileViewButton'; diff --git a/react/features/video-layout/constants.js b/react/features/video-layout/constants.js new file mode 100644 index 000000000..72488205f --- /dev/null +++ b/react/features/video-layout/constants.js @@ -0,0 +1,10 @@ +/** + * An enumeration of the different display layouts supported by the application. + * + * @type {Object} + */ +export const LAYOUTS = { + HORIZONTAL_FILMSTRIP_VIEW: 'horizontal-filmstrip-view', + TILE_VIEW: 'tile-view', + VERTICAL_FILMSTRIP_VIEW: 'vertical-filmstrip-view' +}; diff --git a/react/features/video-layout/functions.js b/react/features/video-layout/functions.js new file mode 100644 index 000000000..31cdf97ed --- /dev/null +++ b/react/features/video-layout/functions.js @@ -0,0 +1,78 @@ +// @flow + +import { LAYOUTS } from './constants'; + +declare var interfaceConfig: Object; + +/** + * Returns the {@code LAYOUTS} constant associated with the layout + * the application should currently be in. + * + * @param {Object} state - The redux state. + * @returns {string} + */ +export function getCurrentLayout(state: Object) { + if (shouldDisplayTileView(state)) { + return LAYOUTS.TILE_VIEW; + } else if (interfaceConfig.VERTICAL_FILMSTRIP) { + return LAYOUTS.VERTICAL_FILMSTRIP_VIEW; + } + + return LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW; +} + +/** + * Returns how many columns should be displayed in tile view. The number + * returned will be between 1 and 5, inclusive. + * + * @returns {number} + */ +export function getMaxColumnCount() { + const configuredMax = interfaceConfig.TILE_VIEW_MAX_COLUMNS || 5; + + return Math.max(Math.min(configuredMax, 1), 5); +} + +/** + * Returns the cell count dimensions for tile view. Tile view tries to uphold + * equal count of tiles for height and width, until maxColumn is reached in + * which rows will be added but no more columns. + * + * @param {Object} state - The redux state. + * @param {number} maxColumns - The maximum number of columns that can be + * displayed. + * @returns {Object} An object is return with the desired number of columns, + * rows, and visible rows (the rest should overflow) for the tile view layout. + */ +export function getTileViewGridDimensions(state: Object, maxColumns: number) { + // Purposefully include all participants, which includes fake participants + // that should show a thumbnail. + const potentialThumbnails = state['features/base/participants'].length; + + const columnsToMaintainASquare = Math.ceil(Math.sqrt(potentialThumbnails)); + const columns = Math.min(columnsToMaintainASquare, maxColumns); + const rows = Math.ceil(potentialThumbnails / columns); + const visibleRows = Math.min(maxColumns, rows); + + return { + columns, + rows, + visibleRows + }; +} + +/** + * Selector for determining if the UI layout should be in tile view. Tile view + * is determined by more than just having the tile view setting enabled, as + * one-on-one calls should not be in tile view, as well as etherpad editing. + * + * @param {Object} state - The redux state. + * @returns {boolean} True if tile view should be displayed. + */ +export function shouldDisplayTileView(state: Object = {}) { + return Boolean( + state['features/video-layout'] + && state['features/video-layout'].tileViewEnabled + && !state['features/etherpad'].editing + ); +} diff --git a/react/features/video-layout/index.js b/react/features/video-layout/index.js index d43689289..a1178fd2b 100644 --- a/react/features/video-layout/index.js +++ b/react/features/video-layout/index.js @@ -1 +1,9 @@ +export * from './actions'; +export * from './actionTypes'; +export * from './components'; +export * from './constants'; +export * from './functions'; + import './middleware'; +import './reducer'; +import './subscriber'; diff --git a/react/features/video-layout/middleware.web.js b/react/features/video-layout/middleware.web.js index 35eb1f305..a3cce4527 100644 --- a/react/features/video-layout/middleware.web.js +++ b/react/features/video-layout/middleware.web.js @@ -15,6 +15,8 @@ import { import { MiddlewareRegistry } from '../base/redux'; import { TRACK_ADDED } from '../base/tracks'; +import { SET_TILE_VIEW } from './actionTypes'; + declare var APP: Object; /** @@ -71,6 +73,10 @@ MiddlewareRegistry.register(store => next => action => { Boolean(action.participant.id)); break; + case SET_TILE_VIEW: + APP.UI.emitEvent(UIEvents.TOGGLED_TILE_VIEW, action.enabled); + break; + case TRACK_ADDED: if (!action.track.local) { VideoLayout.onRemoteStreamAdded(action.track.jitsiTrack); diff --git a/react/features/video-layout/reducer.js b/react/features/video-layout/reducer.js new file mode 100644 index 000000000..dc99f4018 --- /dev/null +++ b/react/features/video-layout/reducer.js @@ -0,0 +1,17 @@ +// @flow + +import { ReducerRegistry } from '../base/redux'; + +import { SET_TILE_VIEW } from './actionTypes'; + +ReducerRegistry.register('features/video-layout', (state = {}, action) => { + switch (action.type) { + case SET_TILE_VIEW: + return { + ...state, + tileViewEnabled: action.enabled + }; + } + + return state; +}); diff --git a/react/features/video-layout/subscriber.js b/react/features/video-layout/subscriber.js new file mode 100644 index 000000000..01c408f6c --- /dev/null +++ b/react/features/video-layout/subscriber.js @@ -0,0 +1,24 @@ +// @flow + +import { + VIDEO_QUALITY_LEVELS, + setMaxReceiverVideoQuality +} from '../base/conference'; +import { StateListenerRegistry } from '../base/redux'; +import { selectParticipant } from '../large-video'; +import { shouldDisplayTileView } from './functions'; + +/** + * StateListenerRegistry provides a reliable way of detecting changes to + * preferred layout state and dispatching additional actions. + */ +StateListenerRegistry.register( + /* selector */ state => shouldDisplayTileView(state), + /* listener */ (displayTileView, { dispatch }) => { + dispatch(selectParticipant()); + + if (!displayTileView) { + dispatch(setMaxReceiverVideoQuality(VIDEO_QUALITY_LEVELS.HIGH)); + } + } +); diff --git a/service/UI/UIEvents.js b/service/UI/UIEvents.js index b23ff21a7..65cb48ac5 100644 --- a/service/UI/UIEvents.js +++ b/service/UI/UIEvents.js @@ -59,6 +59,7 @@ export default { TOGGLED_FILMSTRIP: 'UI.toggled_filmstrip', TOGGLE_SCREENSHARING: 'UI.toggle_screensharing', TOGGLED_SHARED_DOCUMENT: 'UI.toggled_shared_document', + TOGGLED_TILE_VIEW: 'UI.toggled_tile_view', HANGUP: 'UI.hangup', LOGOUT: 'UI.logout', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', From 9c03e95bf1d99998b9495777dd5355a9e9fc7f8d Mon Sep 17 00:00:00 2001 From: bgrozev Date: Wed, 8 Aug 2018 14:42:32 -0500 Subject: [PATCH 3/3] npm: Updates lib-jitsi-meet to 4a28a196160411d657518022de8bded7c02ad679. (#3357) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e418cc4b3..f7df06232 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9719,8 +9719,8 @@ } }, "lib-jitsi-meet": { - "version": "github:jitsi/lib-jitsi-meet#e097a1189ed99838605d90b959e129155bc0e50a", - "from": "github:jitsi/lib-jitsi-meet#e097a1189ed99838605d90b959e129155bc0e50a", + "version": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679", + "from": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679", "requires": { "@jitsi/sdp-interop": "0.1.13", "@jitsi/sdp-simulcast": "0.2.1", diff --git a/package.json b/package.json index a2db2f112..9484bc1c7 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "js-md5": "0.6.1", "jsc-android": "224109.1.0", "jwt-decode": "2.2.0", - "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#e097a1189ed99838605d90b959e129155bc0e50a", + "lib-jitsi-meet": "github:jitsi/lib-jitsi-meet#4a28a196160411d657518022de8bded7c02ad679", "lodash": "4.17.4", "moment": "2.19.4", "moment-duration-format": "2.2.2",