diff --git a/css/filmstrip/_horizontal_filmstrip.scss b/css/filmstrip/_horizontal_filmstrip.scss index bf5cb49bf..a71579894 100644 --- a/css/filmstrip/_horizontal_filmstrip.scss +++ b/css/filmstrip/_horizontal_filmstrip.scss @@ -33,18 +33,18 @@ } &__videos { - @extend %align-right; position:relative; padding: 0; /* The filmstrip should not be covered by the left toolbar. */ bottom: 0; width:auto; - overflow: visible !important; &#remoteVideos { border: $thumbnailsBorder solid transparent; transition: bottom 2s; flex-grow: 1; + display: flex; + flex-direction: row-reverse; @include minHWAutoFix() } @@ -60,41 +60,25 @@ &.hidden { bottom: calc(-196px - #{$newToolbarSizeWithPadding}); } - - .remote-videos-container { - display: flex; - } } - .remote-videos-container { - transition: opacity 1s; + .remote-videos { + & > div { + transition: opacity 1s; + position: absolute; + } + + &.is-not-overflowing > div { + right: 2px; + } } &.hide-videos { - .remote-videos-container { - opacity: 0; - pointer-events: none; - } - } - - #filmstripRemoteVideos { - @include minHWAutoFix(); - - display: flex; - flex: 1; - width: auto; - justify-content: flex-end; - flex-direction: row; - - #filmstripRemoteVideosContainer { - flex-direction: row-reverse; - /** - * Add padding as a hack for Firefox not to show scrollbars when - * unnecessary. - */ - padding: 1px 0; - overflow-y: hidden; - overflow-x: scroll; + .remote-videos { + & > div { + opacity: 0; + pointer-events: none; + } } } @@ -103,25 +87,3 @@ } } - -/** - * Workarounds for Edge and Firefox not handling scrolling properly with - * flex-direction: row-reverse. - */ - @mixin undoRowReverseVideos() { - .horizontal-filmstrip { - #remoteVideos #filmstripRemoteVideos #filmstripRemoteVideosContainer { - flex-direction: row; - } - } -} - -/** Firefox detection hack **/ -@-moz-document url-prefix() { - @include undoRowReverseVideos(); -} - -/** Edge detection hack **/ -@supports (-ms-ime-align:auto) { - @include undoRowReverseVideos(); -} diff --git a/css/filmstrip/_tile_view.scss b/css/filmstrip/_tile_view.scss index 4c5d07ec9..38f327b20 100644 --- a/css/filmstrip/_tile_view.scss +++ b/css/filmstrip/_tile_view.scss @@ -10,13 +10,11 @@ box-shadow: 0px 0px 1px 1.5px black, 0px 0px 1.3px 4px $videoThumbnailSelected; } - #filmstripRemoteVideos { + .remote-videos { align-items: center; box-sizing: border-box; display: flex; flex-direction: column; - height: 100%; - width: 100%; } .filmstrip__videos .videocontainer { @@ -34,6 +32,9 @@ */ height: 100% !important; width: 100%; + display: flex; + justify-content: center; + align-items: center; } .filmstrip { @@ -50,6 +51,10 @@ &.shift-right { margin-left: $sidebarWidth; width: calc(100% - #{$sidebarWidth}); + + .remote-videos{ + width: calc(100vw - #{$sidebarWidth}); + } } } } @@ -62,63 +67,49 @@ display: block; } - #filmstripRemoteVideos { + .remote-videos { box-sizing: border-box; + /** - * Allow vertical scrolling of the thumbnails. - */ - overflow-x: hidden; - overflow-y: 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; - flex-shrink: 0; - margin-top: auto; - margin-bottom: auto; - justify-content: center; - - .videocontainer { - border: 0; + * 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. + */ + & > div { + align-content: center; + align-items: center; box-sizing: border-box; - display: block; - margin: 2px; - } + display: flex; + margin-top: auto; + margin-bottom: auto; + justify-content: center; + position: absolute; - video { - object-fit: contain; - } + .videocontainer { + border: 0; + box-sizing: border-box; + display: block; + margin: 2px; + } - /** - * Max-width corresponding to the ASPECT_RATIO_BREAKPOINT from features/filmstrip/constants. - */ - @media only screen and (max-width: 500px) { video { - object-fit: cover; + object-fit: contain; + } + + /** + * Max-width corresponding to the ASPECT_RATIO_BREAKPOINT from features/filmstrip/constants. + */ + @media only screen and (max-width: 500px) { + video { + object-fit: cover; + } } } } - - .has-overflow#filmstripRemoteVideosContainer { - align-content: baseline; - } - - .has-overflow .videocontainer { - align-self: baseline; - } } -.shift-right #filmstripRemoteVideosContainer { +.shift-right .remote-videos > div { /** * Max-width corresponding to the ASPECT_RATIO_BREAKPOINT from features/filmstrip/constants, * from which we subtract the chat size. diff --git a/css/filmstrip/_vertical_filmstrip.scss b/css/filmstrip/_vertical_filmstrip.scss index f9e75c125..ec816f066 100644 --- a/css/filmstrip/_vertical_filmstrip.scss +++ b/css/filmstrip/_vertical_filmstrip.scss @@ -1,8 +1,10 @@ .vertical-filmstrip .filmstrip { &.hide-videos { - .remote-videos-container { - opacity: 0; - pointer-events: none; + .remote-videos { + & > div { + opacity: 0; + pointer-events: none; + } } } @@ -39,10 +41,6 @@ right: 0; z-index: $filmstripVideosZ; - &.reduce-height { - height: calc(100% - #{$newToolbarSizeWithPadding}); - } - /** * Hide videos by making them slight to the right. */ @@ -98,33 +96,10 @@ * filmstrip from overlapping the left edge of the screen. */ #filmstripLocalVideo, - #filmstripRemoteVideos { + .remote-videos { padding: 0; } - #filmstripRemoteVideos { - @include minHWAutoFix(); - - display: flex; - flex: 1; - flex-direction: column-reverse; - height: auto; - overflow-x: hidden; - overflow-y: scroll; - - #filmstripRemoteVideosContainer { - @include minHWAutoFix(); - flex-direction: column-reverse; - overflow: visible; - width: calc(100% - 8px); // 8px for margin + border of the thumbnails - - .videocontainer { - height: 0px; - width: 100%; - } - } - } - #remoteVideos { @include minHWAutoFix(); @@ -132,56 +107,21 @@ flex-grow: 1; } - .remote-videos-container { + &.reduce-height { + height: calc(100% - calc(#{$newToolbarSizeWithPadding} + #{$scrollHeight})); + } + + .remote-videos { display: flex; - transition: opacity 1s; - } + transition: height .3s ease-in; - .hide-scrollbar#filmstripRemoteVideos { - margin-right: 7px; // Scrollbar size - &::-webkit-scrollbar { - display: none; + & > div { + position: absolute; + transition: opacity 1s; + } + + &.is-not-overflowing > div { + bottom: 0px; } } } - -/** - * Workarounds for Edge and Firefox not handling scrolling properly with - * flex-direction: column-reverse. The remove videos in filmstrip should - * start scrolling from the bottom of the filmstrip, but in those browsers the - * scrolling won't happen. Per W3C spec, scrolling should happen from the - * bottom. As such, use css hacks to get around the css issue, with the intent - * being to remove the hacks as the spec is supported. - */ -@mixin undoColumnReverseVideos() { - .vertical-filmstrip { - #remoteVideos #filmstripRemoteVideos #filmstripRemoteVideosContainer { - flex-direction: column; - } - } -} - -/** - * FF does not include the scroll width when calculating the size of the content. That's why we need to include - * ourselves the width of the scroll so that the remote videos are aligned with the local one. - */ -@mixin filmstripSizeWithoutScroll { - .vertical-filmstrip { - #remoteVideos #filmstripRemoteVideos { - #filmstripRemoteVideosContainer { - width: calc(100% - 15px) // 8 px - margins + border of the thumbnails; 7px - for the scroll - } - } - } -} - -/** Firefox detection hack **/ -@-moz-document url-prefix() { - @include undoColumnReverseVideos(); - @include filmstripSizeWithoutScroll(); -} - -/** Edge detection hack **/ -@supports (-ms-ime-align:auto) { - @include undoColumnReverseVideos(); -} diff --git a/package-lock.json b/package-lock.json index 564a90044..b871c2742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11390,6 +11390,11 @@ } } }, + "memoize-one": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz", + "integrity": "sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==" + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -15318,6 +15323,15 @@ } } }, + "react-window": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz", + "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + } + }, "react-youtube": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-youtube/-/react-youtube-7.13.1.tgz", diff --git a/package.json b/package.json index 8aadd5382..c24df8bf9 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-textarea-autosize": "8.3.0", "react-transition-group": "2.4.0", "react-youtube": "7.13.1", + "react-window": "1.8.6", "redux": "4.0.4", "redux-thunk": "2.2.0", "rnnoise-wasm": "github:jitsi/rnnoise-wasm#566a16885897704d6e6d67a1d5ac5d39781db2af", diff --git a/react/features/base/media/components/web/AudioTrack.js b/react/features/base/media/components/web/AudioTrack.js index fb9f9703e..5ac1d5c91 100644 --- a/react/features/base/media/components/web/AudioTrack.js +++ b/react/features/base/media/components/web/AudioTrack.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { createAudioPlayErrorEvent, createAudioPlaySuccessEvent, sendAnalytics } from '../../../../analytics'; +import { connect } from '../../../redux'; import logger from '../../logger'; /** @@ -10,6 +11,16 @@ import logger from '../../logger'; */ type Props = { + /** + * Represents muted property of the underlying audio element. + */ + _muted: ?Boolean, + + /** + * Represents volume property of the underlying audio element. + */ + _volume: ?number, + /** * The value of the id attribute of the audio element. */ @@ -28,26 +39,15 @@ type Props = { autoPlay: boolean, /** - * Represents muted property of the underlying audio element. + * The ID of the participant associated with the audio element. */ - muted: ?Boolean, - - /** - * Represents volume property of the underlying audio element. - */ - volume: ?number, - - /** - * A function that will be executed when the reference to the underlying audio element changes in order to report - * the initial volume value. - */ - onInitialVolumeSet: Function + participantId: string }; /** * The React/Web {@link Component} which is similar to and wraps around {@code HTMLAudioElement}. */ -export default class AudioTrack extends Component { +class AudioTrack extends Component { /** * Reference to the HTML audio element, stored until the file is ready. */ @@ -94,14 +94,14 @@ export default class AudioTrack extends Component { this._attachTrack(this.props.audioTrack); if (this._ref) { - const { muted, volume } = this.props; + const { _muted, _volume } = this.props; - if (typeof volume === 'number') { - this._ref.volume = volume; + if (typeof _volume === 'number') { + this._ref.volume = _volume; } - if (typeof muted === 'boolean') { - this._ref.muted = muted; + if (typeof _muted === 'boolean') { + this._ref.muted = _muted; } } } @@ -136,14 +136,14 @@ export default class AudioTrack extends Component { if (this._ref) { const currentVolume = this._ref.volume; - const nextVolume = nextProps.volume; + const nextVolume = nextProps._volume; if (typeof nextVolume === 'number' && !isNaN(nextVolume) && currentVolume !== nextVolume) { this._ref.volume = nextVolume; } const currentMuted = this._ref.muted; - const nextMuted = nextProps.muted; + const nextMuted = nextProps._muted; if (typeof nextMuted === 'boolean' && currentMuted !== nextVolume) { this._ref.muted = nextMuted; @@ -258,10 +258,24 @@ export default class AudioTrack extends Component { */ _setRef(audioElement: ?HTMLAudioElement) { this._ref = audioElement; - const { onInitialVolumeSet } = this.props; - - if (this._ref && onInitialVolumeSet) { - onInitialVolumeSet(this._ref.volume); - } } } + +/** + * Maps (parts of) the Redux state to the associated {@code AudioTrack}'s props. + * + * @param {Object} state - The Redux state. + * @param {Object} ownProps - The props passed to the component. + * @private + * @returns {Props} + */ +function _mapStateToProps(state, ownProps) { + const { participantsVolume } = state['features/filmstrip']; + + return { + _muted: state['features/base/config'].startSilent, + _volume: participantsVolume[ownProps.participantId] + }; +} + +export default connect(_mapStateToProps)(AudioTrack); diff --git a/react/features/base/media/components/web/index.js b/react/features/base/media/components/web/index.js index bb4237197..ff7535409 100644 --- a/react/features/base/media/components/web/index.js +++ b/react/features/base/media/components/web/index.js @@ -1,3 +1,4 @@ export { default as Audio } from './Audio'; +export { default as AudioTrack } from './AudioTrack'; export { default as Video } from './Video'; export { default as VideoTrack } from './VideoTrack'; diff --git a/react/features/filmstrip/actionTypes.js b/react/features/filmstrip/actionTypes.js index 5a9ecc497..9551ff64e 100644 --- a/react/features/filmstrip/actionTypes.js +++ b/react/features/filmstrip/actionTypes.js @@ -27,7 +27,7 @@ export const SET_FILMSTRIP_VISIBLE = 'SET_FILMSTRIP_VISIBLE'; * gridDimensions: { * columns: number, * height: number, - * visibleRows: number, + * minVisibleRows: number, * width: number * }, * thumbnailSize: { @@ -49,3 +49,24 @@ export const SET_TILE_VIEW_DIMENSIONS = 'SET_TILE_VIEW_DIMENSIONS'; * } */ export const SET_HORIZONTAL_VIEW_DIMENSIONS = 'SET_HORIZONTAL_VIEW_DIMENSIONS'; + +/** + * The type of (redux) action which sets the dimensions of the thumbnails in vertical view. + * + * { + * type: SET_VERTICAL_VIEW_DIMENSIONS, + * dimensions: Object + * } + */ +export const SET_VERTICAL_VIEW_DIMENSIONS = 'SET_VERTICAL_VIEW_DIMENSIONS'; + +/** + * The type of (redux) action which sets the volume for a thumnail's audio. + * + * { + * type: SET_VOLUME, + * participantId: string, + * volume: number + * } + */ +export const SET_VOLUME = 'SET_VOLUME'; diff --git a/react/features/filmstrip/actions.web.js b/react/features/filmstrip/actions.web.js index c9e21e93c..9bfe043b0 100644 --- a/react/features/filmstrip/actions.web.js +++ b/react/features/filmstrip/actions.web.js @@ -1,64 +1,120 @@ // @flow +import type { Dispatch } from 'redux'; import { pinParticipant } from '../base/participants'; -import { toState } from '../base/redux'; -import { SET_HORIZONTAL_VIEW_DIMENSIONS, SET_TILE_VIEW_DIMENSIONS } from './actionTypes'; -import { calculateThumbnailSizeForHorizontalView, calculateThumbnailSizeForTileView } from './functions'; - -/** - * The size of the side margins for the entire tile view area. - */ -const TILE_VIEW_SIDE_MARGINS = 20; +import { + SET_HORIZONTAL_VIEW_DIMENSIONS, + SET_TILE_VIEW_DIMENSIONS, + SET_VERTICAL_VIEW_DIMENSIONS, + SET_VOLUME +} from './actionTypes'; +import { + HORIZONTAL_FILMSTRIP_MARGIN, + SCROLL_SIZE, + STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER, + STAGE_VIEW_THUMBNAIL_VERTICAL_BORDER, + TILE_HORIZONTAL_MARGIN, + TILE_VERTICAL_MARGIN, + VERTICAL_FILMSTRIP_VERTICAL_MARGIN +} from './constants'; +import { + calculateThumbnailSizeForHorizontalView, + calculateThumbnailSizeForTileView, + calculateThumbnailSizeForVerticalView +} from './functions'; /** * Sets the dimensions of the tile view grid. * * @param {Object} dimensions - Whether the filmstrip is visible. - * @param {Object} windowSize - The size of the window. * @param {Object | Function} stateful - An object or function that can be * resolved to Redux state using the {@code toState} function. - * @returns {{ - * type: SET_TILE_VIEW_DIMENSIONS, - * dimensions: Object - * }} + * @returns {Function} */ -export function setTileViewDimensions(dimensions: Object, windowSize: Object, stateful: Object | Function) { - const state = toState(stateful); - const { clientWidth, clientHeight } = windowSize; - const { disableResponsiveTiles } = state['features/base/config']; +export function setTileViewDimensions(dimensions: Object) { + return (dispatch: Dispatch, getState: Function) => { + const state = getState(); + const { clientHeight, clientWidth } = state['features/base/responsive-ui']; + const { disableResponsiveTiles } = state['features/base/config']; + const { + height, + width + } = calculateThumbnailSizeForTileView({ + ...dimensions, + clientWidth, + clientHeight, + disableResponsiveTiles + }); + const { columns, rows } = dimensions; + const thumbnailsTotalHeight = rows * (TILE_VERTICAL_MARGIN + height); + const hasScroll = clientHeight < thumbnailsTotalHeight; + const filmstripWidth = (columns * (TILE_HORIZONTAL_MARGIN + width)) + (hasScroll ? SCROLL_SIZE : 0); + const filmstripHeight = Math.min(clientHeight, thumbnailsTotalHeight); - const thumbnailSize = calculateThumbnailSizeForTileView({ - ...dimensions, - clientWidth, - clientHeight, - disableResponsiveTiles - }); - const filmstripWidth = dimensions.columns * (TILE_VIEW_SIDE_MARGINS + thumbnailSize.width); + dispatch({ + type: SET_TILE_VIEW_DIMENSIONS, + dimensions: { + gridDimensions: dimensions, + thumbnailSize: { + height, + width + }, + filmstripHeight, + filmstripWidth + } + }); + }; +} - return { - type: SET_TILE_VIEW_DIMENSIONS, - dimensions: { - gridDimensions: dimensions, - thumbnailSize, - filmstripWidth - } +/** + * Sets the dimensions of the thumbnails in vertical view. + * + * @returns {Function} + */ +export function setVerticalViewDimensions() { + return (dispatch: Dispatch, getState: Function) => { + const state = getState(); + const { clientHeight = 0, clientWidth = 0 } = state['features/base/responsive-ui']; + const thumbnails = calculateThumbnailSizeForVerticalView(clientWidth); + + dispatch({ + type: SET_VERTICAL_VIEW_DIMENSIONS, + dimensions: { + ...thumbnails, + remoteVideosContainer: { + width: thumbnails?.local?.width + + TILE_HORIZONTAL_MARGIN + STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER + SCROLL_SIZE, + height: clientHeight - thumbnails?.local?.height - VERTICAL_FILMSTRIP_VERTICAL_MARGIN + } + } + + }); }; } /** * Sets the dimensions of the thumbnails in horizontal view. * - * @param {number} clientHeight - The height of the window. - * @returns {{ - * type: SET_HORIZONTAL_VIEW_DIMENSIONS, - * dimensions: Object - * }} + * @returns {Function} */ -export function setHorizontalViewDimensions(clientHeight: number = 0) { - return { - type: SET_HORIZONTAL_VIEW_DIMENSIONS, - dimensions: calculateThumbnailSizeForHorizontalView(clientHeight) +export function setHorizontalViewDimensions() { + return (dispatch: Dispatch, getState: Function) => { + const state = getState(); + const { clientHeight = 0, clientWidth = 0 } = state['features/base/responsive-ui']; + const thumbnails = calculateThumbnailSizeForHorizontalView(clientHeight); + + dispatch({ + type: SET_HORIZONTAL_VIEW_DIMENSIONS, + dimensions: { + ...thumbnails, + remoteVideosContainer: { + width: clientWidth - thumbnails?.local?.width - HORIZONTAL_FILMSTRIP_MARGIN, + height: thumbnails?.local?.height + + TILE_VERTICAL_MARGIN + STAGE_VIEW_THUMBNAIL_VERTICAL_BORDER + SCROLL_SIZE + } + } + }); }; } @@ -78,4 +134,23 @@ export function clickOnVideo(n: number) { }; } +/** + * Sets the volume for a thumnail's audio. + * + * @param {string} participantId - The participant ID asociated with the audio. + * @param {string} volume - The volume level. + * @returns {{ + * type: SET_VOLUME, + * participantId: string, + * volume: number + * }} + */ +export function setVolume(participantId: string, volume: number) { + return { + type: SET_VOLUME, + participantId, + volume + }; +} + export * from './actions.native'; diff --git a/react/features/filmstrip/components/web/AudioTracksContainer.js b/react/features/filmstrip/components/web/AudioTracksContainer.js new file mode 100644 index 000000000..8043b5c8c --- /dev/null +++ b/react/features/filmstrip/components/web/AudioTracksContainer.js @@ -0,0 +1,65 @@ +/* @flow */ +import React from 'react'; + +import { AudioTrack, MEDIA_TYPE } from '../../../base/media'; +import { connect } from '../../../base/redux'; + +/** + * The type of the React {@code Component} props of {@link AudioTracksContainer}. + */ +type Props = { + + /** + * All media tracks stored in redux. + */ + _tracks: Array +}; + +/** + * A container for the remote tracks audio elements. + * + * @param {Props} props - The props of the component. + * @returns {Array} + */ +function AudioTracksContainer(props: Props) { + const { _tracks } = props; + const remoteAudioTracks = _tracks.filter(t => !t.local && t.mediaType === MEDIA_TYPE.AUDIO); + + return ( +
+ { + remoteAudioTracks.map(t => { + const { jitsiTrack, participantId } = t; + const audioTrackId = jitsiTrack && jitsiTrack.getId(); + const id = `remoteAudio_${audioTrackId || ''}`; + + return ( + ); + }) + } +
); +} + +/** + * Maps (parts of) the Redux state to the associated {@code AudioTracksContainer}'s props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {Props} + */ +function _mapStateToProps(state) { + // NOTE: The disadvantage of this approach is that the component will re-render on any track change. + // One way to solve the problem would be to pass only the participant ID to the AudioTrack component and + // find the corresponding track inside the AudioTrack's mapStateToProps. But currently this will be very + // inefficient because features/base/tracks is an array and in order to find a track by participant ID + // we need to go trough the array. Introducing a map participantID -> track could be beneficial in this case. + return { + _tracks: state['features/base/tracks'] + }; +} + +export default connect(_mapStateToProps)(AudioTracksContainer); diff --git a/react/features/filmstrip/components/web/Filmstrip.js b/react/features/filmstrip/components/web/Filmstrip.js index 35f59a624..f6e3afce8 100644 --- a/react/features/filmstrip/components/web/Filmstrip.js +++ b/react/features/filmstrip/components/web/Filmstrip.js @@ -1,6 +1,7 @@ /* @flow */ -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; +import { FixedSizeList, FixedSizeGrid } from 'react-window'; import type { Dispatch } from 'redux'; import { @@ -11,15 +12,17 @@ import { import { getToolbarButtons } from '../../../base/config'; import { translate } from '../../../base/i18n'; import { Icon, IconMenuDown, IconMenuUp } from '../../../base/icons'; -import { getLocalParticipant } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { showToolbox } from '../../../toolbox/actions.web'; import { isButtonEnabled, isToolboxVisible } from '../../../toolbox/functions.web'; import { LAYOUTS, getCurrentLayout } from '../../../video-layout'; import { setFilmstripVisible } from '../../actions'; +import { TILE_HORIZONTAL_MARGIN, TILE_VERTICAL_MARGIN, TOOLBAR_HEIGHT } from '../../constants'; import { shouldRemoteVideosBeVisible } from '../../functions'; +import AudioTracksContainer from './AudioTracksContainer'; import Thumbnail from './Thumbnail'; +import ThumbnailWrapper from './ThumbnailWrapper'; declare var APP: Object; declare var interfaceConfig: Object; @@ -50,14 +53,9 @@ type Props = { _filmstripWidth: number, /** - * Whether the filmstrip scrollbar should be hidden or not. + * The height of the filmstrip. */ - _hideScrollbar: boolean, - - /** - * Whether the filmstrip toolbar should be hidden or not. - */ - _hideToolbar: boolean, + _filmstripHeight: number, /** * Whether the filmstrip button is enabled. @@ -67,13 +65,29 @@ type Props = { /** * The participants in the call. */ - _participants: Array, + _remoteParticipants: Array, + + + /** + * The length of the remote participants array. + */ + _remoteParticipantsLength: number, /** * The number of rows in tile view. */ _rows: number, + /** + * The height of the thumbnail. + */ + _thumbnailHeight: number, + + /** + * The width of the thumbnail. + */ + _thumbnailWidth: number, + /** * Additional CSS class names to add to the container of all the thumbnails. */ @@ -106,7 +120,7 @@ type Props = { * * @extends Component */ -class Filmstrip extends Component { +class Filmstrip extends PureComponent { /** * Initializes a new {@code Filmstrip} instance. @@ -121,6 +135,8 @@ class Filmstrip extends Component { this._onShortcutToggleFilmstrip = this._onShortcutToggleFilmstrip.bind(this); this._onToolbarToggleFilmstrip = this._onToolbarToggleFilmstrip.bind(this); this._onTabIn = this._onTabIn.bind(this); + this._gridItemKey = this._gridItemKey.bind(this); + this._listItemKey = this._listItemKey.bind(this); } /** @@ -154,11 +170,7 @@ class Filmstrip extends Component { */ render() { const filmstripStyle = { }; - const filmstripRemoteVideosContainerStyle = {}; - let remoteVideoContainerClassName = 'remote-videos-container'; - const { _currentLayout, _participants } = this.props; - const remoteParticipants = _participants.filter(p => !p.local); - const localParticipant = getLocalParticipant(_participants); + const { _currentLayout } = this.props; const tileViewActive = _currentLayout === LAYOUTS.TILE_VIEW; switch (_currentLayout) { @@ -167,28 +179,11 @@ class Filmstrip extends Component { // Also adding 7px for the scrollbar. filmstripStyle.maxWidth = (interfaceConfig.FILM_STRIP_MAX_HEIGHT || 120) + 25; break; - case LAYOUTS.TILE_VIEW: { - // The size of the side margins for each tile as set in CSS. - const { _columns, _rows, _filmstripWidth } = this.props; - - if (_rows > _columns) { - remoteVideoContainerClassName += ' has-overflow'; - } - - filmstripRemoteVideosContainerStyle.width = _filmstripWidth; - break; - } - } - - let remoteVideosWrapperClassName = 'filmstrip__videos'; - - if (this.props._hideScrollbar) { - remoteVideosWrapperClassName += ' hide-scrollbar'; } let toolbar = null; - if (!this.props._hideToolbar && this.props._isFilmstripButtonEnabled) { + if (this.props._isFilmstripButtonEnabled) { toolbar = this._renderToggleButton(); } @@ -206,41 +201,15 @@ class Filmstrip extends Component {
{ !tileViewActive && + key = 'local' /> }
-
- {/* - * XXX This extra video container is needed for - * scrolling thumbnails in Firefox; otherwise, the flex - * thumbnails resize instead of causing overflow. - */} -
- { - remoteParticipants.map( - p => ( - - )) - } -
- { - tileViewActive && - } -
-
-
+ { + this._renderRemoteParticipants() + } + ); } @@ -258,6 +227,135 @@ class Filmstrip extends Component { } } + _listItemKey: number => string; + + /** + * The key to be used for every ThumbnailWrapper element in stage view. + * + * @param {number} index - The index of the ThumbnailWrapper instance. + * @returns {string} - The key. + */ + _listItemKey(index) { + const { _remoteParticipants, _remoteParticipantsLength } = this.props; + + if (typeof index !== 'number' || _remoteParticipantsLength <= index) { + return `empty-${index}`; + } + + return _remoteParticipants[_remoteParticipantsLength - index - 1]; + } + + _gridItemKey: Object => string; + + /** + * The key to be used for every ThumbnailWrapper element in tile views. + * + * @param {Object} data - An object with the indexes identifying the ThumbnailWrapper instance. + * @returns {string} - The key. + */ + _gridItemKey({ columnIndex, rowIndex }) { + const { _columns, _remoteParticipants, _remoteParticipantsLength } = this.props; + const index = (rowIndex * _columns) + columnIndex; + + if (index > _remoteParticipantsLength) { + return `empty-${index}`; + } + + if (index === _remoteParticipantsLength) { + return 'local'; + } + + return _remoteParticipants[index]; + } + + /** + * Renders the thumbnails for remote participants. + * + * @returns {ReactElement} + */ + _renderRemoteParticipants() { + const { + _columns, + _currentLayout, + _filmstripHeight, + _filmstripWidth, + _remoteParticipantsLength, + _rows, + _thumbnailHeight, + _thumbnailWidth + } = this.props; + + if (!_thumbnailWidth || isNaN(_thumbnailWidth) || !_thumbnailHeight + || isNaN(_thumbnailHeight) || !_filmstripHeight || isNaN(_filmstripHeight) || !_filmstripWidth + || isNaN(_filmstripWidth)) { + return null; + } + + if (_currentLayout === LAYOUTS.TILE_VIEW) { + return ( + + { + ThumbnailWrapper + } + + ); + } + + + const props = { + itemCount: _remoteParticipantsLength, + className: 'filmstrip__videos remote-videos', + height: _filmstripHeight, + itemKey: this._listItemKey, + itemSize: 0, + width: _filmstripWidth, + style: { + willChange: 'auto' + } + }; + + if (_currentLayout === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW) { + const itemSize = _thumbnailWidth + TILE_HORIZONTAL_MARGIN; + const isNotOverflowing = (_remoteParticipantsLength * itemSize) <= _filmstripWidth; + + props.itemSize = itemSize; + + // $FlowFixMe + props.layout = 'horizontal'; + if (isNotOverflowing) { + props.className += ' is-not-overflowing'; + } + + } else if (_currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW) { + const itemSize = _thumbnailHeight + TILE_VERTICAL_MARGIN; + const isNotOverflowing = (_remoteParticipantsLength * itemSize) <= _filmstripHeight; + + if (isNotOverflowing) { + props.className += ' is-not-overflowing'; + } + + props.itemSize = itemSize; + } + + return ( + + { + ThumbnailWrapper + } + + ); + } + /** * Dispatches an action to change the visibility of the filmstrip. * @@ -344,29 +442,60 @@ class Filmstrip extends Component { * @returns {Props} */ function _mapStateToProps(state) { - const { iAmSipGateway } = state['features/base/config']; const toolbarButtons = getToolbarButtons(state); - const { visible } = state['features/filmstrip']; - const reduceHeight - = state['features/toolbox'].visible && toolbarButtons.length; + const { visible, remoteParticipants } = state['features/filmstrip']; + const reduceHeight = state['features/toolbox'].visible && toolbarButtons.length; const remoteVideosVisible = shouldRemoteVideosBeVisible(state); const { isOpen: shiftRight } = state['features/chat']; const className = `${remoteVideosVisible ? '' : 'hide-videos'} ${ reduceHeight ? 'reduce-height' : '' } ${shiftRight ? 'shift-right' : ''}`.trim(); const videosClassName = `filmstrip__videos${visible ? '' : ' hidden'}`; - const { gridDimensions = {}, filmstripWidth } = state['features/filmstrip'].tileViewDimensions; + const { + gridDimensions = {}, + filmstripHeight, + filmstripWidth, + thumbnailSize: tileViewThumbnailSize + } = state['features/filmstrip'].tileViewDimensions; + const _currentLayout = getCurrentLayout(state); + let _thumbnailSize, remoteFilmstripHeight, remoteFilmstripWidth; + + switch (_currentLayout) { + case LAYOUTS.TILE_VIEW: + _thumbnailSize = tileViewThumbnailSize; + remoteFilmstripHeight = filmstripHeight; + remoteFilmstripWidth = filmstripWidth; + break; + case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: { + const { remote, remoteVideosContainer } = state['features/filmstrip'].verticalViewDimensions; + + _thumbnailSize = remote; + remoteFilmstripHeight = remoteVideosContainer?.height - (reduceHeight ? TOOLBAR_HEIGHT : 0); + remoteFilmstripWidth = remoteVideosContainer?.width; + break; + } + case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: { + const { remote, remoteVideosContainer } = state['features/filmstrip'].horizontalViewDimensions; + + _thumbnailSize = remote; + remoteFilmstripHeight = remoteVideosContainer?.height; + remoteFilmstripWidth = remoteVideosContainer?.width; + break; + } + } return { _className: className, _columns: gridDimensions.columns, - _currentLayout: getCurrentLayout(state), - _filmstripWidth: filmstripWidth, - _hideScrollbar: Boolean(iAmSipGateway), - _hideToolbar: Boolean(iAmSipGateway), + _currentLayout, + _filmstripHeight: remoteFilmstripHeight, + _filmstripWidth: remoteFilmstripWidth, _isFilmstripButtonEnabled: isButtonEnabled('filmstrip', state), - _participants: state['features/base/participants'], + _remoteParticipantsLength: remoteParticipants.length, + _remoteParticipants: remoteParticipants, _rows: gridDimensions.rows, + _thumbnailWidth: _thumbnailSize?.width, + _thumbnailHeight: _thumbnailSize?.height, _videosClassName: videosClassName, _visible: visible, _isToolboxVisible: isToolboxVisible(state) diff --git a/react/features/filmstrip/components/web/Thumbnail.js b/react/features/filmstrip/components/web/Thumbnail.js index 0017a7941..9630a47b7 100644 --- a/react/features/filmstrip/components/web/Thumbnail.js +++ b/react/features/filmstrip/components/web/Thumbnail.js @@ -7,7 +7,6 @@ import { AudioLevelIndicator } from '../../../audio-level-indicator'; import { Avatar } from '../../../base/avatar'; import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; import { MEDIA_TYPE, VideoTrack } from '../../../base/media'; -import AudioTrack from '../../../base/media/components/web/AudioTrack'; import { getLocalParticipant, getParticipantById, @@ -28,6 +27,7 @@ import { StatusIndicators, RaisedHandIndicator, DominantSpeakerIndicator } from import { PresenceLabel } from '../../../presence-status'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { LocalVideoMenuTriggerButton, RemoteVideoMenuTriggerButton } from '../../../video-menu'; +import { setVolume } from '../../actions.web'; import { DISPLAY_MODE_TO_CLASS_NAME, DISPLAY_MODE_TO_STRING, @@ -65,12 +65,7 @@ export type State = {| /** * Indicates whether the thumbnail is hovered or not. */ - isHovered: boolean, - - /** - * The current volume setting for the Thumbnail. - */ - volume: ?number + isHovered: boolean |}; /** @@ -179,9 +174,9 @@ export type Props = {| _participant: Object, /** - * The number of participants in the call. + * True if there are more than 2 participants in the call. */ - _participantCount: number, + _participantCountMoreThan2: boolean, /** * Indicates whether the "start silent" mode is enabled. @@ -193,6 +188,11 @@ export type Props = {| */ _videoTrack: ?Object, + /** + * The volume level for the thumbnail. + */ + _volume?: ?number, + /** * The width of the thumbnail. */ @@ -203,10 +203,20 @@ export type Props = {| */ dispatch: Function, + /** + * The horizontal offset in px for the thumbnail. Used to center the thumbnails from the last row in tile view. + */ + horizontalOffset: number, + /** * The ID of the participant related to the thumbnail. */ - participantID: ?string + participantID: ?string, + + /** + * Styles that will be set to the Thumbnail's main span element. + */ + style?: ?Object |}; /** @@ -240,7 +250,6 @@ class Thumbnail extends Component { audioLevel: 0, canPlayEventReceived: false, isHovered: false, - volume: undefined, displayMode: DISPLAY_VIDEO }; @@ -253,7 +262,6 @@ class Thumbnail extends Component { this._onCanPlay = this._onCanPlay.bind(this); this._onClick = this._onClick.bind(this); this._onVolumeChange = this._onVolumeChange.bind(this); - this._onInitialVolumeSet = this._onInitialVolumeSet.bind(this); this._onMouseEnter = this._onMouseEnter.bind(this); this._onMouseLeave = this._onMouseLeave.bind(this); this._onTestingEvent = this._onTestingEvent.bind(this); @@ -457,7 +465,7 @@ class Thumbnail extends Component { * @returns {Object} - The styles for the thumbnail. */ _getStyles(): Object { - const { _height, _heightToWidthPercent, _currentLayout, _isHidden, _width } = this.props; + const { _height, _isHidden, _width, style, horizontalOffset } = this.props; let styles: { thumbnail: Object, avatar: Object @@ -466,39 +474,28 @@ class Thumbnail extends Component { avatar: {} }; - switch (_currentLayout) { - case LAYOUTS.TILE_VIEW: - case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: { - const avatarSize = _height / 2; + const avatarSize = _height / 2; + let { left } = style || {}; - styles = { - thumbnail: { - height: `${_height}px`, - minHeight: `${_height}px`, - minWidth: `${_width}px`, - width: `${_width}px` - }, - avatar: { - height: `${avatarSize}px`, - width: `${avatarSize}px` - } - }; - break; - } - case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: { - styles = { - thumbnail: { - paddingTop: `${_heightToWidthPercent}%` - }, - avatar: { - height: '50%', - width: `${_heightToWidthPercent / 2}%` - } - }; - break; - } + if (typeof left === 'number' && horizontalOffset) { + left += horizontalOffset; } + styles = { + thumbnail: { + ...style, + left, + height: `${_height}px`, + minHeight: `${_height}px`, + minWidth: `${_width}px`, + width: `${_width}px` + }, + avatar: { + height: `${avatarSize}px`, + width: `${avatarSize}px` + } + }; + if (_isHidden) { styles.thumbnail.display = 'none'; } @@ -584,7 +581,7 @@ class Thumbnail extends Component { _isDominantSpeakerDisabled, _indicatorIconSize: iconSize, _participant, - _participantCount + _participantCountMoreThan2 } = this.props; const { isHovered } = this.state; const showConnectionIndicator = isHovered || !_connectionIndicatorAutoHideEnabled; @@ -621,7 +618,7 @@ class Thumbnail extends Component { iconSize = { iconSize } participantId = { id } tooltipPosition = { tooltipPosition } /> - { showDominantSpeaker && _participantCount > 2 + { showDominantSpeaker && _participantCountMoreThan2 && @@ -793,21 +790,19 @@ class Thumbnail extends Component { */ _renderRemoteParticipant() { const { - _audioTrack, _isTestModeEnabled, _participant, _startSilent, - _videoTrack + _videoTrack, + _volume = 1 } = this.props; const { id } = _participant; - const { audioLevel, canPlayEventReceived, volume } = this.state; + const { audioLevel, canPlayEventReceived } = this.state; const styles = this._getStyles(); const containerClassName = this._getContainerClassName(); // hide volume when in silent mode const onVolumeChange = _startSilent ? undefined : this._onVolumeChange; - const jitsiAudioTrack = _audioTrack?.jitsiTrack; - const audioTrackId = jitsiAudioTrack && jitsiAudioTrack.getId(); const jitsiVideoTrack = _videoTrack?.jitsiTrack; const videoTrackId = jitsiVideoTrack && jitsiVideoTrack.getId(); const videoEventListeners = {}; @@ -840,14 +835,6 @@ class Thumbnail extends Component { style = { videoElementStyle } videoTrack = { _videoTrack } /> } - { - _audioTrack && - }
{ this._renderTopIndicators() } @@ -872,7 +859,7 @@ class Thumbnail extends Component { @@ -880,20 +867,6 @@ class Thumbnail extends Component { ); } - _onInitialVolumeSet: Object => void; - - /** - * A handler for the initial volume value of the audio element. - * - * @param {number} volume - Properties of the audio element. - * @returns {void} - */ - _onInitialVolumeSet(volume) { - if (this.state.volume !== volume) { - this.setState({ volume }); - } - } - _onVolumeChange: number => void; /** @@ -903,7 +876,10 @@ class Thumbnail extends Component { * @returns {void} */ _onVolumeChange(value) { - this.setState({ volume: value }); + const { _participant, dispatch } = this.props; + const { id } = _participant; + + dispatch(setVolume(id, value)); } /** @@ -949,6 +925,7 @@ function _mapStateToProps(state, ownProps): Object { const { id } = participant; const isLocal = participant?.local ?? true; const tracks = state['features/base/tracks']; + const { participantsVolume } = state['features/filmstrip']; const _videoTrack = isLocal ? getLocalVideoTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantID); const _audioTrack = isLocal @@ -967,14 +944,21 @@ function _mapStateToProps(state, ownProps): Object { switch (_currentLayout) { + case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: { const { horizontalViewDimensions = { local: {}, remote: {} + }, + verticalViewDimensions = { + local: {}, + remote: {} } } = state['features/filmstrip']; - const { local, remote } = horizontalViewDimensions; + const { local, remote } + = _currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW + ? verticalViewDimensions : horizontalViewDimensions; const { width, height } = isLocal ? local : remote; size = { @@ -984,13 +968,6 @@ function _mapStateToProps(state, ownProps): Object { break; } - case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: - size = { - _heightToWidthPercent: isLocal - ? 100 / interfaceConfig.LOCAL_THUMBNAIL_RATIO - : 100 / interfaceConfig.REMOTE_THUMBNAIL_RATIO - }; - break; case LAYOUTS.TILE_VIEW: { const { width, height } = state['features/filmstrip'].tileViewDimensions.thumbnailSize; @@ -1020,9 +997,10 @@ function _mapStateToProps(state, ownProps): Object { _indicatorIconSize: NORMAL, _localFlipX: Boolean(localFlipX), _participant: participant, - _participantCount: getParticipantCount(state), + _participantCountMoreThan2: getParticipantCount(state) > 2, _startSilent: Boolean(startSilent), _videoTrack, + _volume: isLocal ? undefined : participantsVolume[id], ...size }; } diff --git a/react/features/filmstrip/components/web/ThumbnailWrapper.js b/react/features/filmstrip/components/web/ThumbnailWrapper.js new file mode 100644 index 000000000..33e7e67a4 --- /dev/null +++ b/react/features/filmstrip/components/web/ThumbnailWrapper.js @@ -0,0 +1,155 @@ +/* @flow */ +import React, { Component } from 'react'; +import { shouldComponentUpdate } from 'react-window'; + +import { connect } from '../../../base/redux'; +import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; + +import Thumbnail from './Thumbnail'; + +/** + * The type of the React {@code Component} props of {@link ThumbnailWrapper}. + */ +type Props = { + + /** + * The ID of the participant associated with the Thumbnail. + */ + _participantID: ?string, + + /** + * The horizontal offset in px for the thumbnail. Used to center the thumbnails in the last row in tile view. + */ + _horizontalOffset: number, + + /** + * The index of the column in tile view. + */ + columnIndex?: number, + + /** + * The index of the ThumbnailWrapper in stage view. + */ + index?: number, + + /** + * The index of the row in tile view. + */ + rowIndex?: number, + + /** + * The styles comming from react-window. + */ + style: Object +}; + +/** + * A wrapper Component for the Thumbnail that translates the react-window specific props + * to the Thumbnail Component's props. + */ +class ThumbnailWrapper extends Component { + + /** + * Creates new ThumbnailWrapper instance. + * + * @param {Props} props - The props of the component. + */ + constructor(props: Props) { + super(props); + + this.shouldComponentUpdate = shouldComponentUpdate.bind(this); + } + + shouldComponentUpdate: Props => boolean; + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { _participantID, style, _horizontalOffset = 0 } = this.props; + + if (typeof _participantID !== 'string') { + return null; + } + + if (_participantID === 'local') { + return ( + ); + } + + return ( + ); + } +} + +/** + * Maps (parts of) the Redux state to the associated {@code ThumbnailWrapper}'s props. + * + * @param {Object} state - The Redux state. + * @param {Object} ownProps - The props passed to the component. + * @private + * @returns {Props} + */ +function _mapStateToProps(state, ownProps) { + const _currentLayout = getCurrentLayout(state); + const { remoteParticipants } = state['features/filmstrip']; + const remoteParticipantsLength = remoteParticipants.length; + + if (_currentLayout === LAYOUTS.TILE_VIEW) { + const { columnIndex, rowIndex } = ownProps; + const { gridDimensions = {}, thumbnailSize } = state['features/filmstrip'].tileViewDimensions; + const { columns, rows } = gridDimensions; + const index = (rowIndex * columns) + columnIndex; + let horizontalOffset; + + if (rowIndex === rows - 1) { // center the last row + const { width: thumbnailWidth } = thumbnailSize; + const participantsInTheLastRow = (remoteParticipantsLength + 1) % columns; + + if (participantsInTheLastRow > 0) { + horizontalOffset = Math.floor((columns - participantsInTheLastRow) * (thumbnailWidth + 4) / 2); + } + + } + + if (index > remoteParticipantsLength) { + return {}; + } + + if (index === remoteParticipantsLength) { + return { + _participantID: 'local', + _horizontalOffset: horizontalOffset + }; + } + + + return { + _participantID: remoteParticipants[index], + _horizontalOffset: horizontalOffset + }; + + } + + const { index } = ownProps; + + if (typeof index !== 'number' || remoteParticipantsLength <= index) { + return {}; + } + + return { + _participantID: remoteParticipants[index] + }; +} + +export default connect(_mapStateToProps)(ThumbnailWrapper); diff --git a/react/features/filmstrip/constants.js b/react/features/filmstrip/constants.js index 0534b6dc2..4cab13208 100644 --- a/react/features/filmstrip/constants.js +++ b/react/features/filmstrip/constants.js @@ -143,3 +143,68 @@ export const DISPLAY_MODE_TO_STRING = [ 'video-with-name', 'avatar-with-name' ]; + +/** + * The vertical margin of a tile. + * + * @type {number} + */ +export const TILE_VERTICAL_MARGIN = 4; + +/** + * The horizontal margin of a tile. + * + * @type {number} + */ +export const TILE_HORIZONTAL_MARGIN = 4; + +/** + * The height of the whole toolbar. + */ +export const TOOLBAR_HEIGHT = 72; + +/** + * The size of the horizontal border of a thumbnail. + * + * @type {number} + */ +export const STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER = 4; + +/** + * The size of the vertical border of a thumbnail. + * + * @type {number} + */ +export const STAGE_VIEW_THUMBNAIL_VERTICAL_BORDER = 4; + +/** + * The size of the scroll. + * + * @type {number} + */ +export const SCROLL_SIZE = 7; + +/** + * The total vertical space between the thumbnails container and the edges of the window. + * + * NOTE: This will include margins, paddings and the space for the 'hide filmstrip' icon. + * + * @type {number} + */ +export const VERTICAL_FILMSTRIP_VERTICAL_MARGIN = 60; + +/** + * The min horizontal space between the thumbnails container and the edges of the window. + * + * @type {number} + */ +export const VERTICAL_FILMSTRIP_MIN_HORIZONTAL_MARGIN = 10; + +/** + * The total horizontal space between the thumbnails container and the edges of the window. + * + * NOTE: This will include margins, paddings and the space for the 'hide filmstrip' icon. + * + * @type {number} + */ +export const HORIZONTAL_FILMSTRIP_MARGIN = 39; diff --git a/react/features/filmstrip/functions.web.js b/react/features/filmstrip/functions.web.js index 9d4932ff4..0fe651d0d 100644 --- a/react/features/filmstrip/functions.web.js +++ b/react/features/filmstrip/functions.web.js @@ -23,16 +23,17 @@ import { DISPLAY_BLACKNESS_WITH_NAME, DISPLAY_VIDEO, DISPLAY_VIDEO_WITH_NAME, + SCROLL_SIZE, SQUARE_TILE_ASPECT_RATIO, - TILE_ASPECT_RATIO + STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER, + TILE_ASPECT_RATIO, + TILE_HORIZONTAL_MARGIN, + TILE_VERTICAL_MARGIN, + VERTICAL_FILMSTRIP_MIN_HORIZONTAL_MARGIN } from './constants'; declare var interfaceConfig: Object; -// Minimum space to keep between the sides of the tiles and the sides -// of the window. -const TILE_VIEW_SIDE_MARGINS = 20; - /** * Returns true if the filmstrip on mobile is visible, false otherwise. * @@ -139,15 +140,42 @@ export function calculateThumbnailSizeForHorizontalView(clientHeight: number = 0 }; } +/** + * Calculates the size for thumbnails when in vertical view layout. + * + * @param {number} clientWidth - The height of the app window. + * @returns {{local: {height, width}, remote: {height, width}}} + */ +export function calculateThumbnailSizeForVerticalView(clientWidth: number = 0) { + const horizontalMargin + = VERTICAL_FILMSTRIP_MIN_HORIZONTAL_MARGIN + SCROLL_SIZE + + TILE_HORIZONTAL_MARGIN + STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER; + const availableWidth = Math.min( + Math.max(clientWidth - horizontalMargin, 0), + interfaceConfig.FILM_STRIP_MAX_HEIGHT || 120); + + return { + local: { + height: Math.floor(availableWidth / interfaceConfig.LOCAL_THUMBNAIL_RATIO), + width: availableWidth + }, + remote: { + height: Math.floor(availableWidth / interfaceConfig.REMOTE_THUMBNAIL_RATIO), + width: availableWidth + } + }; +} + /** * Calculates the size for thumbnails when in tile view layout. * * @param {Object} dimensions - The desired dimensions of the tile view grid. - * @returns {{height, width}} + * @returns {{hasScroll, height, width}} */ export function calculateThumbnailSizeForTileView({ columns, - visibleRows, + minVisibleRows, + rows, clientWidth, clientHeight, disableResponsiveTiles @@ -158,12 +186,29 @@ export function calculateThumbnailSizeForTileView({ aspectRatio = SQUARE_TILE_ASPECT_RATIO; } - const viewWidth = clientWidth - TILE_VIEW_SIDE_MARGINS; - const viewHeight = clientHeight - TILE_VIEW_SIDE_MARGINS; + const viewWidth = clientWidth - (columns * TILE_HORIZONTAL_MARGIN); + const viewHeight = clientHeight - (minVisibleRows * TILE_VERTICAL_MARGIN); const initialWidth = viewWidth / columns; + const initialHeight = viewHeight / minVisibleRows; const aspectRatioHeight = initialWidth / aspectRatio; - const height = Math.floor(Math.min(aspectRatioHeight, viewHeight / visibleRows)); - const width = Math.floor(aspectRatio * height); + const noScrollHeight = (clientHeight / rows) - TILE_VERTICAL_MARGIN; + const scrollInitialWidth = (viewWidth - SCROLL_SIZE) / columns; + let height = Math.floor(Math.min(aspectRatioHeight, initialHeight)); + let width = Math.floor(aspectRatio * height); + + if (height > noScrollHeight && width > scrollInitialWidth) { // we will have scroll and we need more space for it. + const scrollAspectRatioHeight = scrollInitialWidth / aspectRatio; + + // Recalculating width/height to fit the available space when a scroll is displayed. + // NOTE: Math.min(scrollAspectRatioHeight, initialHeight) would be enough to recalculate but since the new + // height value can theoretically be dramatically smaller and the scroll may not be neccessary anymore we need + // to compare it with noScrollHeight( the optimal height to fit all thumbnails without scroll) and get the + // bigger one. This way we ensure that we always strech the thumbnails as close as we can to the edges of the + // window. + height = Math.floor(Math.max(Math.min(scrollAspectRatioHeight, initialHeight), noScrollHeight)); + width = Math.floor(aspectRatio * height); + } + return { height, diff --git a/react/features/filmstrip/middleware.web.js b/react/features/filmstrip/middleware.web.js index c01b326d4..e90713d7a 100644 --- a/react/features/filmstrip/middleware.web.js +++ b/react/features/filmstrip/middleware.web.js @@ -9,7 +9,7 @@ import { LAYOUTS } from '../video-layout'; -import { setHorizontalViewDimensions, setTileViewDimensions } from './actions.web'; +import { setHorizontalViewDimensions, setTileViewDimensions, setVerticalViewDimensions } from './actions.web'; import './subscriber.web'; @@ -27,22 +27,16 @@ MiddlewareRegistry.register(store => next => action => { switch (layout) { case LAYOUTS.TILE_VIEW: { const { gridDimensions } = state['features/filmstrip'].tileViewDimensions; - const { clientHeight, clientWidth } = state['features/base/responsive-ui']; - store.dispatch( - setTileViewDimensions( - gridDimensions, - { - clientHeight, - clientWidth - }, - store - ) - ); + store.dispatch(setTileViewDimensions(gridDimensions)); break; } case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: - store.dispatch(setHorizontalViewDimensions(state['features/base/responsive-ui'].clientHeight)); + store.dispatch(setHorizontalViewDimensions()); + break; + + case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: + store.dispatch(setVerticalViewDimensions()); break; } break; diff --git a/react/features/filmstrip/reducer.js b/react/features/filmstrip/reducer.js index 8a2d231f2..40881cdec 100644 --- a/react/features/filmstrip/reducer.js +++ b/react/features/filmstrip/reducer.js @@ -1,12 +1,15 @@ // @flow +import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../base/participants'; import { ReducerRegistry } from '../base/redux'; import { SET_FILMSTRIP_ENABLED, SET_FILMSTRIP_VISIBLE, SET_HORIZONTAL_VIEW_DIMENSIONS, - SET_TILE_VIEW_DIMENSIONS + SET_TILE_VIEW_DIMENSIONS, + SET_VERTICAL_VIEW_DIMENSIONS, + SET_VOLUME } from './actionTypes'; const DEFAULT_STATE = { @@ -26,6 +29,21 @@ const DEFAULT_STATE = { */ horizontalViewDimensions: {}, + /** + * The custom audio volume levels per perticipant. + * + * @type {Object} + */ + participantsVolume: {}, + + /** + * The ordered IDs of the remote participants displayed in the filmstrip. + * + * NOTE: Currently the order will match the one from the base/participants array. But this is good initial step for + * reordering the remote participants. + */ + remoteParticipants: [], + /** * The tile view dimensions. * @@ -34,6 +52,14 @@ const DEFAULT_STATE = { */ tileViewDimensions: {}, + /** + * The vertical view dimensions. + * + * @public + * @type {Object} + */ + verticalViewDimensions: {}, + /** * The indicator which determines whether the {@link Filmstrip} is visible. * @@ -69,6 +95,44 @@ ReducerRegistry.register( ...state, tileViewDimensions: action.dimensions }; + case SET_VERTICAL_VIEW_DIMENSIONS: + return { + ...state, + verticalViewDimensions: action.dimensions + }; + case SET_VOLUME: + return { + ...state, + participantsVolume: { + ...state.participantsVolume, + + // NOTE: This would fit better in the features/base/participants. But currently we store + // the participants as an array which will make it expensive to search for the volume for + // every participant separately. + [action.participantId]: action.volume + } + }; + case PARTICIPANT_JOINED: { + const { id, local } = action.participant; + + if (!local) { + state.remoteParticipants = [ ...state.remoteParticipants, id ]; + } + + return state; + } + case PARTICIPANT_LEFT: { + const { id, local } = action.participant; + + if (local) { + return state; + } + + state.remoteParticipants = state.remoteParticipants.filter(participantId => participantId !== id); + delete state.participantsVolume[id]; + + return state; + } } return state; diff --git a/react/features/filmstrip/subscriber.web.js b/react/features/filmstrip/subscriber.web.js index cb3ee30c3..a2c4639f6 100644 --- a/react/features/filmstrip/subscriber.web.js +++ b/react/features/filmstrip/subscriber.web.js @@ -7,7 +7,7 @@ import { getParticipantsPaneOpen } from '../participants-pane/functions'; import { setOverflowDrawer } from '../toolbox/actions.web'; import { getCurrentLayout, getTileViewGridDimensions, shouldDisplayTileView, LAYOUTS } from '../video-layout'; -import { setHorizontalViewDimensions, setTileViewDimensions } from './actions.web'; +import { setHorizontalViewDimensions, setTileViewDimensions, setVerticalViewDimensions } from './actions.web'; import { ASPECT_RATIO_BREAKPOINT, DISPLAY_DRAWER_THRESHOLD, @@ -28,18 +28,7 @@ StateListenerRegistry.register( const oldGridDimensions = state['features/filmstrip'].tileViewDimensions.gridDimensions; if (!equals(gridDimensions, oldGridDimensions)) { - const { clientHeight, clientWidth } = state['features/base/responsive-ui']; - - store.dispatch( - setTileViewDimensions( - gridDimensions, - { - clientHeight, - clientWidth - }, - store - ) - ); + store.dispatch(setTileViewDimensions(gridDimensions)); } } }); @@ -53,23 +42,14 @@ StateListenerRegistry.register( const state = store.getState(); switch (layout) { - case LAYOUTS.TILE_VIEW: { - const { clientHeight, clientWidth } = state['features/base/responsive-ui']; - - store.dispatch( - setTileViewDimensions( - getTileViewGridDimensions(state), - { - clientHeight, - clientWidth - }, - store - ) - ); + case LAYOUTS.TILE_VIEW: + store.dispatch(setTileViewDimensions(getTileViewGridDimensions(state))); break; - } case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: - store.dispatch(setHorizontalViewDimensions(state['features/base/responsive-ui'].clientHeight)); + store.dispatch(setHorizontalViewDimensions()); + break; + case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: + store.dispatch(setVerticalViewDimensions()); break; } }); @@ -168,17 +148,7 @@ StateListenerRegistry.register( if (shouldDisplayTileView(state)) { const gridDimensions = getTileViewGridDimensions(state); - const { clientHeight, clientWidth } = state['features/base/responsive-ui']; - store.dispatch( - setTileViewDimensions( - gridDimensions, - { - clientHeight, - clientWidth - }, - store - ) - ); + store.dispatch(setTileViewDimensions(gridDimensions)); } }); diff --git a/react/features/video-layout/functions.js b/react/features/video-layout/functions.js index 78b3d8372..2568325bf 100644 --- a/react/features/video-layout/functions.js +++ b/react/features/video-layout/functions.js @@ -106,11 +106,12 @@ export function getTileViewGridDimensions(state: Object) { const columnsToMaintainASquare = Math.ceil(Math.sqrt(numberOfParticipants)); const columns = Math.min(columnsToMaintainASquare, maxColumns); const rows = Math.ceil(numberOfParticipants / columns); - const visibleRows = Math.min(maxColumns, rows); + const minVisibleRows = Math.min(maxColumns, rows); return { columns, - visibleRows + minVisibleRows, + rows }; }