From 7dd43d93b6ba8c358f2023c5d6f5f9800b1aca92 Mon Sep 17 00:00:00 2001 From: Hristo Terezov Date: Fri, 20 Aug 2021 18:32:38 -0500 Subject: [PATCH] feat(filmstrip-pagination): mobile support. --- .../base/conference/middleware.any.js | 2 +- react/features/base/responsive-ui/actions.js | 7 +- react/features/filmstrip/actions.any.js | 27 +- react/features/filmstrip/actions.native.js | 50 ++- react/features/filmstrip/actions.web.js | 21 -- .../filmstrip/components/native/Filmstrip.js | 200 ++++++++--- .../filmstrip/components/native/Thumbnail.js | 288 ++++++++++------ .../filmstrip/components/native/TileView.js | 320 ++++++++---------- .../filmstrip/components/native/styles.js | 38 ++- .../components/web/ThumbnailWrapper.js | 2 +- react/features/filmstrip/constants.js | 11 + react/features/filmstrip/functions.native.js | 29 ++ react/features/filmstrip/middleware.native.js | 15 +- react/features/filmstrip/reducer.js | 12 +- react/features/filmstrip/subscriber.native.js | 39 +++ react/features/invite/functions.js | 2 +- .../components/native/RemoteVideoMenu.js | 28 +- .../components/native/SharedVideoMenu.js | 24 +- react/features/video-quality/subscriber.js | 7 +- 19 files changed, 675 insertions(+), 447 deletions(-) diff --git a/react/features/base/conference/middleware.any.js b/react/features/base/conference/middleware.any.js index e864f203f..5c46c01d2 100644 --- a/react/features/base/conference/middleware.any.js +++ b/react/features/base/conference/middleware.any.js @@ -551,7 +551,7 @@ function _updateLocalParticipantInConference({ dispatch, getState }, next, actio const localParticipant = getLocalParticipant(getState); - if (conference && participant.id === localParticipant.id) { + if (conference && participant.id === localParticipant?.id) { if ('name' in participant) { conference.setDisplayName(participant.name); } diff --git a/react/features/base/responsive-ui/actions.js b/react/features/base/responsive-ui/actions.js index ed4cc4784..884d8007f 100644 --- a/react/features/base/responsive-ui/actions.js +++ b/react/features/base/responsive-ui/actions.js @@ -29,12 +29,13 @@ const REDUCED_UI_THRESHOLD = 300; */ export function clientResized(clientWidth: number, clientHeight: number) { return (dispatch: Dispatch, getState: Function) => { - const state = getState(); - const { isOpen: isChatOpen } = state['features/chat']; - const isParticipantsPaneOpen = getParticipantsPaneOpen(state); let availableWidth = clientWidth; if (navigator.product !== 'ReactNative') { + const state = getState(); + const { isOpen: isChatOpen } = state['features/chat']; + const isParticipantsPaneOpen = getParticipantsPaneOpen(state); + if (isChatOpen) { availableWidth -= CHAT_SIZE; } diff --git a/react/features/filmstrip/actions.any.js b/react/features/filmstrip/actions.any.js index f3c3d9cf7..ba89ef23f 100644 --- a/react/features/filmstrip/actions.any.js +++ b/react/features/filmstrip/actions.any.js @@ -1,6 +1,11 @@ // @flow -import { SET_FILMSTRIP_ENABLED, SET_FILMSTRIP_VISIBLE, SET_REMOTE_PARTICIPANTS } from './actionTypes'; +import { + SET_FILMSTRIP_ENABLED, + SET_FILMSTRIP_VISIBLE, + SET_REMOTE_PARTICIPANTS, + SET_VISIBLE_REMOTE_PARTICIPANTS +} from './actionTypes'; /** * Sets whether the filmstrip is enabled. @@ -50,3 +55,23 @@ export function setRemoteParticipants(participants: Array) { participants }; } + +/** + * Sets the list of the visible participants in the filmstrip by storing the start and end index from the remote + * participants array. + * + * @param {number} startIndex - The start index from the remote participants array. + * @param {number} endIndex - The end index from the remote participants array. + * @returns {{ + * type: SET_VISIBLE_REMOTE_PARTICIPANTS, + * startIndex: number, + * endIndex: number + * }} + */ +export function setVisibleRemoteParticipants(startIndex: number, endIndex: number) { + return { + type: SET_VISIBLE_REMOTE_PARTICIPANTS, + startIndex, + endIndex + }; +} diff --git a/react/features/filmstrip/actions.native.js b/react/features/filmstrip/actions.native.js index a70d6d951..065c42b04 100644 --- a/react/features/filmstrip/actions.native.js +++ b/react/features/filmstrip/actions.native.js @@ -1,6 +1,10 @@ // @flow +import { getParticipantCountWithFake } from '../base/participants'; + import { SET_TILE_VIEW_DIMENSIONS } from './actionTypes'; +import { SQUARE_TILE_ASPECT_RATIO, TILE_MARGIN } from './constants'; +import { getColumnCount } from './functions.native'; export * from './actions.any'; @@ -9,20 +13,40 @@ export * from './actions.any'; * of the values are currently used. Check the description of {@link SET_TILE_VIEW_DIMENSIONS} for the full set * of properties. * - * @param {Object} dimensions - The tile view dimensions. - * @param {Object} thumbnailSize - The size of an individual video thumbnail. - * @param {number} thumbnailSize.height - The height of an individual video thumbnail. - * @param {number} thumbnailSize.width - The width of an individual video thumbnail. - * @returns {{ - * type: SET_TILE_VIEW_DIMENSIONS, - * dimensions: Object - * }} + * @returns {Function} */ -export function setTileViewDimensions({ thumbnailSize }: Object) { - return { - type: SET_TILE_VIEW_DIMENSIONS, - dimensions: { - thumbnailSize +export function setTileViewDimensions() { + return (dispatch: Function, getState: Function) => { + const state = getState(); + const participantCount = getParticipantCountWithFake(state); + const { clientHeight: height, clientWidth: width } = state['features/base/responsive-ui']; + const columns = getColumnCount(state); + const heightToUse = height - (TILE_MARGIN * 2); + const widthToUse = width - (TILE_MARGIN * 2); + let tileWidth; + + // If there is going to be at least two rows, ensure that at least two + // rows display fully on screen. + if (participantCount / columns > 1) { + tileWidth = Math.min(widthToUse / columns, heightToUse / 2); + } else { + tileWidth = Math.min(widthToUse / columns, heightToUse); } + + const tileHeight = Math.floor(tileWidth / SQUARE_TILE_ASPECT_RATIO); + + tileWidth = Math.floor(tileWidth); + + + dispatch({ + type: SET_TILE_VIEW_DIMENSIONS, + dimensions: { + columns, + thumbnailSize: { + height: tileHeight, + width: tileWidth + } + } + }); }; } diff --git a/react/features/filmstrip/actions.web.js b/react/features/filmstrip/actions.web.js index aad326016..d153f9a40 100644 --- a/react/features/filmstrip/actions.web.js +++ b/react/features/filmstrip/actions.web.js @@ -7,7 +7,6 @@ import { SET_HORIZONTAL_VIEW_DIMENSIONS, SET_TILE_VIEW_DIMENSIONS, SET_VERTICAL_VIEW_DIMENSIONS, - SET_VISIBLE_REMOTE_PARTICIPANTS, SET_VOLUME } from './actionTypes'; import { @@ -159,23 +158,3 @@ export function setVolume(participantId: string, volume: number) { volume }; } - -/** - * Sets the list of the visible participants in the filmstrip by storing the start and end index from the remote - * participants array. - * - * @param {number} startIndex - The start index from the remote participants array. - * @param {number} endIndex - The end index from the remote participants array. - * @returns {{ - * type: SET_VISIBLE_REMOTE_PARTICIPANTS, - * startIndex: number, - * endIndex: number - * }} - */ -export function setVisibleRemoteParticipants(startIndex: number, endIndex: number) { - return { - type: SET_VISIBLE_REMOTE_PARTICIPANTS, - startIndex, - endIndex - }; -} diff --git a/react/features/filmstrip/components/native/Filmstrip.js b/react/features/filmstrip/components/native/Filmstrip.js index ff4a4b26b..9635e9c0f 100644 --- a/react/features/filmstrip/components/native/Filmstrip.js +++ b/react/features/filmstrip/components/native/Filmstrip.js @@ -1,11 +1,13 @@ // @flow -import React, { Component } from 'react'; -import { SafeAreaView, ScrollView } from 'react-native'; +import React, { PureComponent } from 'react'; +import { FlatList, SafeAreaView } from 'react-native'; +import { getLocalParticipant } from '../../../base/participants'; import { Platform } from '../../../base/react'; import { connect } from '../../../base/redux'; import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants'; +import { setVisibleRemoteParticipants } from '../../actions'; import { isFilmstripVisible, shouldRemoteVideosBeVisible } from '../../functions'; import LocalThumbnail from './LocalThumbnail'; @@ -25,6 +27,12 @@ type Props = { */ _aspectRatio: Symbol, + _clientWidth: number, + + _clientHeight: number, + + _localParticipantId: string, + /** * The participants in the conference. */ @@ -33,7 +41,12 @@ type Props = { /** * The indicator which determines whether the filmstrip is visible. */ - _visible: boolean + _visible: boolean, + + /** + * Invoked to trigger state changes in Redux. + */ + dispatch: Function, }; /** @@ -42,13 +55,18 @@ type Props = { * * @extends Component */ -class Filmstrip extends Component { +class Filmstrip extends PureComponent { /** * Whether the local participant should be rendered separately from the * remote participants i.e. outside of their {@link ScrollView}. */ _separateLocalThumbnail: boolean; + /** + * The FlatList's viewabilityConfig. + */ + _viewabilityConfig: Object; + /** * Constructor of the component. * @@ -75,6 +93,107 @@ class Filmstrip extends Component { // do not have much of a choice but to continue rendering LocalThumbnail // as any other remote Thumbnail on Android. this._separateLocalThumbnail = Platform.OS !== 'android'; + + this._viewabilityConfig = { + itemVisiblePercentThreshold: 30 + }; + this._keyExtractor = this._keyExtractor.bind(this); + this._getItemLayout = this._getItemLayout.bind(this); + this._onViewableItemsChanged = this._onViewableItemsChanged.bind(this); + this._renderThumbnail = this._renderThumbnail.bind(this); + } + + _keyExtractor: string => string; + + /** + * Returns a key for a passed item of the list. + * + * @param {string} item - The user ID. + * @returns {string} - The user ID. + */ + _keyExtractor(item) { + return item; + } + + /** + * Calculates the width and height of the filmstrip based on the screen size and aspect ratio. + * + * @returns {Object} - The width and the height. + */ + _getDimensions() { + const { _aspectRatio, _clientWidth, _clientHeight } = this.props; + const { height, width, margin } = styles.thumbnail; + + if (_aspectRatio === ASPECT_RATIO_NARROW) { + return { + height, + width: this._separateLocalThumbnail ? _clientWidth - width - (margin * 2) : _clientWidth + }; + } + + return { + height: this._separateLocalThumbnail ? _clientHeight - height - (margin * 2) : _clientHeight, + width + }; + } + + _getItemLayout: (?Array, number) => {length: number, offset: number, index: number}; + + /** + * Optimization for FlatList. Returns the length, offset and index for an item. + * + * @param {Array} data - The data array with user IDs. + * @param {number} index - The index number of the item. + * @returns {Object} + */ + _getItemLayout(data, index) { + const { _aspectRatio } = this.props; + const isNarrowAspectRatio = _aspectRatio === ASPECT_RATIO_NARROW; + const length = isNarrowAspectRatio ? styles.thumbnail.width : styles.thumbnail.height; + + return { + length, + offset: length * index, + index + }; + } + + _onViewableItemsChanged: Object => void; + + /** + * A handler for visible items changes. + * + * @param {Object} data - The visible items data. + * @param {Array} data.viewableItems - The visible items array. + * @returns {void} + */ + _onViewableItemsChanged({ viewableItems = [] }) { + const indexArray: Array = viewableItems.map(i => i.index); + + // If the local video placed at the beginning we need to shift the start index of the remoteParticipants array + // with 1 because and in the same time we don't need to adjust the end index because the end index will not be + // included. + const startIndex + = this._separateLocalThumbnail ? Math.min(...indexArray) : Math.max(Math.min(...indexArray) - 1, 0); + const endIndex = Math.max(...indexArray) + (this._separateLocalThumbnail ? 1 : 0); + + this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex)); + } + + _renderThumbnail: Object => Object; + + /** + * Creates React Element to display each participant in a thumbnail. + * + * @private + * @returns {ReactElement} + */ + _renderThumbnail({ item /* , index , separators */ }) { + return ( + ) + ; } /** @@ -84,7 +203,7 @@ class Filmstrip extends Component { * @returns {ReactElement} */ render() { - const { _aspectRatio, _participants, _visible } = this.props; + const { _aspectRatio, _localParticipantId, _participants, _visible } = this.props; if (!_visible) { return null; @@ -92,6 +211,13 @@ class Filmstrip extends Component { const isNarrowAspectRatio = _aspectRatio === ASPECT_RATIO_NARROW; const filmstripStyle = isNarrowAspectRatio ? styles.filmstripNarrow : styles.filmstripWide; + const { height, width } = this._getDimensions(); + const { height: thumbnailHeight, width: thumbnailWidth, margin } = styles.thumbnail; + const initialNumToRender = Math.ceil(isNarrowAspectRatio + ? width / (thumbnailWidth + (2 * margin)) + : height / (thumbnailHeight + (2 * margin)) + ); + const participants = this._separateLocalThumbnail ? _participants : [ _localParticipantId, ..._participants ]; return ( @@ -100,29 +226,20 @@ class Filmstrip extends Component { && !isNarrowAspectRatio && } - - { - !this._separateLocalThumbnail && !isNarrowAspectRatio - && - } - { - - this._sort(_participants, isNarrowAspectRatio) - .map(id => ( - )) - - } - { - !this._separateLocalThumbnail && isNarrowAspectRatio - && - } - + style = { styles.scrollView } + viewabilityConfig = { this._viewabilityConfig } + windowSize = { 2 } /> { this._separateLocalThumbnail && isNarrowAspectRatio && @@ -130,35 +247,6 @@ class Filmstrip extends Component { ); } - - /** - * Sorts a specific array of {@code Participant}s in display order. - * - * @param {Participant[]} participants - The array of {@code Participant}s - * to sort in display order. - * @param {boolean} isNarrowAspectRatio - Indicates if the aspect ratio is - * wide or narrow. - * @private - * @returns {Participant[]} A new array containing the elements of the - * specified {@code participants} array sorted in display order. - */ - _sort(participants, isNarrowAspectRatio) { - // XXX Array.prototype.sort() is not appropriate because (1) it operates - // in place and (2) it is not necessarily stable. - - const sortedParticipants = [ - ...participants - ]; - - if (isNarrowAspectRatio) { - // When the narrow aspect ratio is used, we want to have the remote - // participants from right to left with the newest added/joined to - // the leftmost side. The local participant is the leftmost item. - sortedParticipants.reverse(); - } - - return sortedParticipants; - } } /** @@ -171,9 +259,13 @@ class Filmstrip extends Component { function _mapStateToProps(state) { const { enabled, remoteParticipants } = state['features/filmstrip']; const showRemoteVideos = shouldRemoteVideosBeVisible(state); + const responsiveUI = state['features/base/responsive-ui']; return { _aspectRatio: state['features/base/responsive-ui'].aspectRatio, + _clientHeight: responsiveUI.clientHeight, + _clientWidth: responsiveUI.clientWidth, + _localParticipantId: getLocalParticipant(state)?.id, _participants: showRemoteVideos ? remoteParticipants : NO_REMOTE_VIDEOS, _visible: enabled && isFilmstripVisible(state) }; diff --git a/react/features/filmstrip/components/native/Thumbnail.js b/react/features/filmstrip/components/native/Thumbnail.js index f7c2f075d..ce085c6a8 100644 --- a/react/features/filmstrip/components/native/Thumbnail.js +++ b/react/features/filmstrip/components/native/Thumbnail.js @@ -1,6 +1,6 @@ // @flow -import React, { useCallback } from 'react'; +import React, { PureComponent } from 'react'; import { View } from 'react-native'; import type { Dispatch } from 'redux'; @@ -25,8 +25,8 @@ import { DisplayNameLabel } from '../../../display-name'; import { toggleToolboxVisible } from '../../../toolbox/actions.native'; import { RemoteVideoMenu } from '../../../video-menu'; import ConnectionStatusComponent from '../../../video-menu/components/native/ConnectionStatusComponent'; -import SharedVideoMenu - from '../../../video-menu/components/native/SharedVideoMenu'; +import SharedVideoMenu from '../../../video-menu/components/native/SharedVideoMenu'; +import { SQUARE_TILE_ASPECT_RATIO } from '../../constants'; import AudioMutedIndicator from './AudioMutedIndicator'; import DominantSpeakerIndicator from './DominantSpeakerIndicator'; @@ -47,9 +47,19 @@ type Props = { _audioMuted: boolean, /** - * The Redux representation of the state "features/large-video". + * Indicates whether the participant is fake. */ - _largeVideo: Object, + _isFakeParticipant: boolean, + + /** + * Indicates whether the participant is fake. + */ + _isScreenShare: boolean, + + /** + * Indicates whether the participant is local. + */ + _local: boolean, /** * Shared video local participant owner. @@ -57,9 +67,22 @@ type Props = { _localVideoOwner: boolean, /** - * The Redux representation of the participant to display. + * The ID of the participant obtain from the participant object in Redux. + * + * NOTE: Generally it should be the same as the participantID prop except the case where the passed + * participantID doesn't corespond to any of the existing participants. */ - _participant: Object, + _participantId: string, + + /** + * Indicates whether the participant is displayed on the large video. + */ + _participantInLargeVideo: boolean, + + /** + * Indicates whether the participant is pinned or not. + */ + _pinned: boolean, /** * Whether to show the dominant speaker indicator or not. @@ -77,9 +100,9 @@ type Props = { _styles: StyleType, /** - * The Redux representation of the participant's video track. + * Indicates whether the participant is video muted. */ - _videoTrack: Object, + _videoMuted: boolean, /** * If true, there will be no color overlay (tint) on the thumbnail @@ -93,6 +116,11 @@ type Props = { */ dispatch: Dispatch, + /** + * The height of the thumnail. + */ + height: ?number, + /** * The ID of the participant related to the thumbnail. */ @@ -103,11 +131,6 @@ type Props = { */ renderDisplayName: ?boolean, - /** - * Optional styling to add or override on the Thumbnail component root. - */ - styleOverrides?: Object, - /** * If true, it tells the thumbnail that it needs to behave differently. E.g. react differently to a single tap. */ @@ -116,121 +139,159 @@ type Props = { /** * React component for video thumbnail. - * - * @param {Props} props - Properties passed to this functional component. - * @returns {Component} - A React component. */ -function Thumbnail(props: Props) { - const { - _audioMuted: audioMuted, - _largeVideo: largeVideo, - _localVideoOwner, - _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, - _renderModeratorIndicator: renderModeratorIndicator, - _participant: participant, - _styles, - _videoTrack: videoTrack, - dispatch, - disableTint, - renderDisplayName, - tileView - } = props; +class Thumbnail extends PureComponent { - // It seems that on leave the Thumbnail for the left participant can be re-rendered. - // This will happen when mapStateToProps is executed before the remoteParticipants list in redux is updated. - if (typeof participant === 'undefined') { + /** + * Creates new Thumbnail component. + * + * @param {Props} props - The props of the component. + * @returns {Thumbnail} + */ + constructor(props: Props) { + super(props); - return null; + this._onClick = this._onClick.bind(this); + this._onThumbnailLongPress = this._onThumbnailLongPress.bind(this); } - const participantId = participant.id; - const participantInLargeVideo - = participantId === largeVideo.participantId; - const videoMuted = !videoTrack || videoTrack.muted; - const isScreenShare = videoTrack && videoTrack.videoType === VIDEO_TYPE.DESKTOP; - const onClick = useCallback(() => { + _onClick: () => void; + + /** + * Thumbnail click handler. + * + * @returns {void} + */ + _onClick() { + const { _participantId, _pinned, dispatch, tileView } = this.props; + if (tileView) { dispatch(toggleToolboxVisible()); } else { - dispatch(pinParticipant(participant.pinned ? null : participant.id)); + dispatch(pinParticipant(_pinned ? null : _participantId)); } - }, [ participant, tileView, dispatch ]); - const onThumbnailLongPress = useCallback(() => { - if (participant.local) { + } + + _onThumbnailLongPress: () => void; + + /** + * Thumbnail long press handler. + * + * @returns {void} + */ + _onThumbnailLongPress() { + const { _participantId, _local, _isFakeParticipant, _localVideoOwner, dispatch } = this.props; + + if (_local) { dispatch(openDialog(ConnectionStatusComponent, { - participantID: participant.id + participantID: _participantId })); - } else if (participant.isFakeParticipant) { + } else if (_isFakeParticipant) { if (_localVideoOwner) { dispatch(openDialog(SharedVideoMenu, { - participant + _participantId })); } } else { dispatch(openDialog(RemoteVideoMenu, { - participant + participantId: _participantId })); } - }, [ participant, dispatch ]); + } - return ( - + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + _audioMuted: audioMuted, + _isScreenShare: isScreenShare, + _isFakeParticipant, + _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, + _renderModeratorIndicator: renderModeratorIndicator, + _participantId: participantId, + _participantInLargeVideo: participantInLargeVideo, + _pinned, + _styles, + _videoMuted: videoMuted, + disableTint, + height, + renderDisplayName, + tileView + } = this.props; + const styleOverrides = tileView ? { + aspectRatio: SQUARE_TILE_ASPECT_RATIO, + flex: 0, + height, + maxHeight: null, + maxWidth: null, + width: null + } : null; - - - { renderDisplayName && - - } - - { renderModeratorIndicator - && - - } - - { !participant.isFakeParticipant && - - { renderDominantSpeakerIndicator && } - } - - { !participant.isFakeParticipant && - - } - - { !participant.isFakeParticipant && - { audioMuted - && } - { videoMuted - && } - { isScreenShare - && } - } - - - ); + styles.thumbnail, + _pinned && !tileView ? _styles.thumbnailPinned : null, + styleOverrides + ] } + touchFeedback = { false }> + + { + renderDisplayName + && + + + } + { renderModeratorIndicator + && + + + } + { + !_isFakeParticipant + && + + { renderDominantSpeakerIndicator && } + + } + { + !_isFakeParticipant + && + + + } + { + !_isFakeParticipant + && + { audioMuted && } + { videoMuted && } + { isScreenShare && } + + } + + ); + } } /** @@ -255,21 +316,28 @@ function _mapStateToProps(state, ownProps) { = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, id); const videoTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, id); + const videoMuted = !videoTrack || videoTrack.muted; + const isScreenShare = videoTrack && videoTrack.videoType === VIDEO_TYPE.DESKTOP; const participantCount = getParticipantCount(state); const renderDominantSpeakerIndicator = participant && participant.dominantSpeaker && participantCount > 2; const _isEveryoneModerator = isEveryoneModerator(state); const renderModeratorIndicator = !_isEveryoneModerator && participant && participant.role === PARTICIPANT_ROLE.MODERATOR; + const participantInLargeVideo = id === largeVideo.participantId; return { _audioMuted: audioTrack?.muted ?? true, - _largeVideo: largeVideo, + _isScreenShare: isScreenShare, + _isFakeParticipant: participant?.isFakeParticipant, + _local: participant?.local, _localVideoOwner: Boolean(ownerId === localParticipantId), - _participant: participant, + _participantInLargeVideo: participantInLargeVideo, + _participantId: id, + _pinned: participant?.pinned, _renderDominantSpeakerIndicator: renderDominantSpeakerIndicator, _renderModeratorIndicator: renderModeratorIndicator, _styles: ColorSchemeRegistry.get(state, 'Thumbnail'), - _videoTrack: videoTrack + _videoMuted: videoMuted }; } diff --git a/react/features/filmstrip/components/native/TileView.js b/react/features/filmstrip/components/native/TileView.js index 7ed8fed53..ece276a63 100644 --- a/react/features/filmstrip/components/native/TileView.js +++ b/react/features/filmstrip/components/native/TileView.js @@ -1,8 +1,8 @@ // @flow -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; import { - ScrollView, + FlatList, TouchableWithoutFeedback, View } from 'react-native'; @@ -10,13 +10,11 @@ import type { Dispatch } from 'redux'; import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants'; import { connect } from '../../../base/redux'; -import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants'; -import { setTileViewDimensions } from '../../actions.native'; +import { setVisibleRemoteParticipants } from '../../actions.web'; import Thumbnail from './Thumbnail'; import styles from './styles'; - /** * The type of the React {@link Component} props of {@link TileView}. */ @@ -27,6 +25,11 @@ type Props = { */ _aspectRatio: Symbol, + /** + * The number of columns. + */ + _columns: number, + /** * Application's viewport height. */ @@ -47,6 +50,11 @@ type Props = { */ _remoteParticipants: Array, + /** + * The thumbnail height. + */ + _thumbnailHeight: number, + /** * Application's viewport height. */ @@ -64,45 +72,81 @@ type Props = { }; /** - * The margin for each side of the tile view. Taken away from the available - * height and width for the tile container to display in. - * - * @private - * @type {number} - */ -const MARGIN = 10; - -/** - * The aspect ratio the tiles should display in. - * - * @private - * @type {number} - */ -const TILE_ASPECT_RATIO = 1; - -/** - * Implements a React {@link Component} which displays thumbnails in a two + * Implements a React {@link PureComponent} which displays thumbnails in a two * dimensional grid. * - * @extends Component + * @extends PureComponent */ -class TileView extends Component { - /** - * Implements React's {@link Component#componentDidMount}. - * - * @inheritdoc - */ - componentDidMount() { - this._updateReceiverQuality(); - } +class TileView extends PureComponent { /** - * Implements React's {@link Component#componentDidUpdate}. - * - * @inheritdoc + * The FlatList's viewabilityConfig. */ - componentDidUpdate() { - this._updateReceiverQuality(); + _viewabilityConfig: Object; + + /** + * The styles for the FlatList. + */ + _flatListStyles: Object; + + /** + * The styles for the content container of the FlatList. + */ + _contentContainerStyles: Object; + + /** + * Creates new TileView component. + * + * @param {Props} props - The props of the component. + */ + constructor(props: Props) { + super(props); + + this._keyExtractor = this._keyExtractor.bind(this); + this._onViewableItemsChanged = this._onViewableItemsChanged.bind(this); + this._renderThumbnail = this._renderThumbnail.bind(this); + this._viewabilityConfig = { + itemVisiblePercentThreshold: 30 + }; + this._flatListStyles = { + ...styles.flatList + }; + this._contentContainerStyles = { + ...styles.contentContainer + }; + } + + _keyExtractor: string => string; + + /** + * Returns a key for a passed item of the list. + * + * @param {string} item - The user ID. + * @returns {string} - The user ID. + */ + _keyExtractor(item) { + return item; + } + + _onViewableItemsChanged: Object => void; + + /** + * A handler for visible items changes. + * + * @param {Object} data - The visible items data. + * @param {Array} data.viewableItems - The visible items array. + * @returns {void} + */ + _onViewableItemsChanged({ viewableItems = [] }: { viewableItems: Array }) { + const indexArray = viewableItems.map(i => i.index); + + // We need to shift the start index of the remoteParticipants array with 1 because of the local video placed + // at the beginning and in the same time we don't need to adjust the end index because the end index will not be + // included. + const startIndex = Math.max(Math.min(...indexArray) - 1, 0); + const endIndex = Math.max(...indexArray); + + this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex)); } /** @@ -112,54 +156,49 @@ class TileView extends Component { * @returns {ReactElement} */ render() { - const { _height, _width, onClick } = this.props; - const rowElements = this._groupIntoRows(this._renderThumbnails(), this._getColumnCount()); + const { _columns, _height, _thumbnailHeight, _width, onClick } = this.props; + const participants = this._getSortedParticipants(); + const initialRowsToRender = Math.ceil(_height / (_thumbnailHeight + (2 * styles.thumbnail.margin))); + + if (this._flatListStyles.minHeight !== _height || this._flatListStyles.minWidth !== _width) { + this._flatListStyles = { + ...styles.flatList, + minHeight: _height, + minWidth: _width + }; + } + + if (this._contentContainerStyles.minHeight !== _height || this._contentContainerStyles.minWidth !== _width) { + this._contentContainerStyles = { + ...styles.contentContainer, + minHeight: _height, + minWidth: _width + }; + } return ( - - - - { rowElements } - - - + + + + + ); } - /** - * Returns how many columns should be displayed for tile view. - * - * @returns {number} - * @private - */ - _getColumnCount() { - const participantCount = this.props._participantCount; - - // For narrow view, tiles should stack on top of each other for a lonely - // call and a 1:1 call. Otherwise tiles should be grouped into rows of - // two. - if (this.props._aspectRatio === ASPECT_RATIO_NARROW) { - return participantCount >= 3 ? 2 : 1; - } - - if (participantCount === 4) { - // In wide view, a four person call should display as a 2x2 grid. - return 2; - } - - return Math.min(3, participantCount); - } - /** * Returns all participants with the local participant at the end. * @@ -168,114 +207,33 @@ class TileView extends Component { */ _getSortedParticipants() { const { _localParticipant, _remoteParticipants } = this.props; - const participants = [ ..._remoteParticipants ]; + const participants = []; _localParticipant && participants.push(_localParticipant.id); - return participants; + return [ ...participants, ..._remoteParticipants ]; } - /** - * Calculate the height and width for the tiles. - * - * @private - * @returns {Object} - */ - _getTileDimensions() { - const { _height, _participantCount, _width } = this.props; - const columns = this._getColumnCount(); - const heightToUse = _height - (MARGIN * 2); - const widthToUse = _width - (MARGIN * 2); - let tileWidth; - - // If there is going to be at least two rows, ensure that at least two - // rows display fully on screen. - if (_participantCount / columns > 1) { - tileWidth = Math.min(widthToUse / columns, heightToUse / 2); - } else { - tileWidth = Math.min(widthToUse / columns, heightToUse); - } - - return { - height: tileWidth / TILE_ASPECT_RATIO, - width: tileWidth - }; - } + _renderThumbnail: Object => Object; /** - * Splits a list of thumbnails into React Elements with a maximum of - * {@link rowLength} thumbnails in each. - * - * @param {Array} thumbnails - The list of thumbnails that should be split - * into separate row groupings. - * @param {number} rowLength - How many thumbnails should be in each row. - * @private - * @returns {ReactElement[]} - */ - _groupIntoRows(thumbnails, rowLength) { - const rowElements = []; - - for (let i = 0; i < thumbnails.length; i++) { - if (i % rowLength === 0) { - const thumbnailsInRow = thumbnails.slice(i, i + rowLength); - - rowElements.push( - - { thumbnailsInRow } - - ); - } - } - - return rowElements; - } - - /** - * Creates React Elements to display each participant in a thumbnail. Each - * tile will be. + * Creates React Element to display each participant in a thumbnail. * * @private - * @returns {ReactElement[]} + * @returns {ReactElement} */ - _renderThumbnails() { - const styleOverrides = { - aspectRatio: TILE_ASPECT_RATIO, - flex: 0, - height: this._getTileDimensions().height, - maxHeight: null, - maxWidth: null, - width: null - }; + _renderThumbnail({ item/* , index , separators */ }) { + const { _thumbnailHeight } = this.props; - return this._getSortedParticipants() - .map(id => ( - )); - } - - /** - * Sets the receiver video quality based on the dimensions of the thumbnails - * that are displayed. - * - * @private - * @returns {void} - */ - _updateReceiverQuality() { - const { height, width } = this._getTileDimensions(); - - this.props.dispatch(setTileViewDimensions({ - thumbnailSize: { - height, - width - } - })); + return ( + ) + ; } } @@ -288,14 +246,18 @@ class TileView extends Component { */ function _mapStateToProps(state) { const responsiveUi = state['features/base/responsive-ui']; - const { remoteParticipants } = state['features/filmstrip']; + const { remoteParticipants, tileViewDimensions } = state['features/filmstrip']; + const { height } = tileViewDimensions.thumbnailSize; + const { columns } = tileViewDimensions; return { _aspectRatio: responsiveUi.aspectRatio, + _columns: columns, _height: responsiveUi.clientHeight, _localParticipant: getLocalParticipant(state), _participantCount: getParticipantCountWithFake(state), _remoteParticipants: remoteParticipants, + _thumbnailHeight: height, _width: responsiveUi.clientWidth }; } diff --git a/react/features/filmstrip/components/native/styles.js b/react/features/filmstrip/components/native/styles.js index a28a0b11b..36cc082fd 100644 --- a/react/features/filmstrip/components/native/styles.js +++ b/react/features/filmstrip/components/native/styles.js @@ -14,6 +14,15 @@ export const AVATAR_SIZE = 50; */ export default { + /** + * The FlatList content container styles + */ + contentContainer: { + alignItems: 'center', + justifyContent: 'center', + flex: 0 + }, + /** * The display name container. */ @@ -52,6 +61,22 @@ export default { top: 0 }, + /** + * The styles for the FlatList container. + */ + flatListContainer: { + flexGrow: 1, + flexShrink: 1, + flex: 0 + }, + + /** + * The styles for the FlatList. + */ + flatList: { + flex: 0 + }, + /** * Container of the {@link LocalThumbnail}. */ @@ -122,19 +147,6 @@ export default { thumbnailTopRightIndicatorContainer: { right: 0 - }, - - tileView: { - alignSelf: 'center' - }, - - tileViewRows: { - justifyContent: 'center' - }, - - tileViewRow: { - flexDirection: 'row', - justifyContent: 'center' } }; diff --git a/react/features/filmstrip/components/web/ThumbnailWrapper.js b/react/features/filmstrip/components/web/ThumbnailWrapper.js index 2c436bcac..b5af35b59 100644 --- a/react/features/filmstrip/components/web/ThumbnailWrapper.js +++ b/react/features/filmstrip/components/web/ThumbnailWrapper.js @@ -15,7 +15,7 @@ type Props = { /** * The horizontal offset in px for the thumbnail. Used to center the thumbnails in the last row in tile view. */ - _horizontalOffset: number, + _horizontalOffset: number, /** * The ID of the participant associated with the Thumbnail. diff --git a/react/features/filmstrip/constants.js b/react/features/filmstrip/constants.js index 0b83fd8de..596e4167f 100644 --- a/react/features/filmstrip/constants.js +++ b/react/features/filmstrip/constants.js @@ -220,3 +220,14 @@ export const HORIZONTAL_FILMSTRIP_MARGIN = 39; * @type {number} */ export const SHOW_TOOLBAR_CONTEXT_MENU_AFTER = 600; + +/** + * The margin for each side of the tile view. Taken away from the available + * height and width for the tile container to display in. + * + * NOTE: Mobile specific. + * + * @private + * @type {number} + */ +export const TILE_MARGIN = 10; diff --git a/react/features/filmstrip/functions.native.js b/react/features/filmstrip/functions.native.js index c6df2e714..a7b53ec1a 100644 --- a/react/features/filmstrip/functions.native.js +++ b/react/features/filmstrip/functions.native.js @@ -3,6 +3,7 @@ import { getFeatureFlag, FILMSTRIP_ENABLED } from '../base/flags'; import { getParticipantCountWithFake, getPinnedParticipant } from '../base/participants'; import { toState } from '../base/redux'; +import { ASPECT_RATIO_NARROW } from '../base/responsive-ui/constants'; export * from './functions.any'; @@ -59,3 +60,31 @@ export function shouldRemoteVideosBeVisible(state: Object) { || disable1On1Mode); } + +/** + * Returns how many columns should be displayed for tile view. + * + * @param {Object | Function} stateful - The Object or Function that can be + * resolved to a Redux state object with the toState function. + * @returns {number} - The number of columns to be rendered in tile view. + * @private + */ +export function getColumnCount(stateful: Object | Function) { + const state = toState(stateful); + const participantCount = getParticipantCountWithFake(state); + const { aspectRatio } = state['features/base/responsive-ui']; + + // For narrow view, tiles should stack on top of each other for a lonely + // call and a 1:1 call. Otherwise tiles should be grouped into rows of + // two. + if (aspectRatio === ASPECT_RATIO_NARROW) { + return participantCount >= 3 ? 2 : 1; + } + + if (participantCount === 4) { + // In wide view, a four person call should display as a 2x2 grid. + return 2; + } + + return Math.min(3, participantCount); +} diff --git a/react/features/filmstrip/middleware.native.js b/react/features/filmstrip/middleware.native.js index 71dc82f5c..7f450e9a4 100644 --- a/react/features/filmstrip/middleware.native.js +++ b/react/features/filmstrip/middleware.native.js @@ -2,7 +2,9 @@ import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../base/participants'; import { MiddlewareRegistry } from '../base/redux'; +import { CLIENT_RESIZED, SET_ASPECT_RATIO } from '../base/responsive-ui'; +import { setTileViewDimensions } from './actions'; import { updateRemoteParticipants, updateRemoteParticipantsOnLeave } from './functions'; import './subscriber'; @@ -10,19 +12,22 @@ import './subscriber'; * The middleware of the feature Filmstrip. */ MiddlewareRegistry.register(store => next => action => { + if (action.type === PARTICIPANT_LEFT) { + updateRemoteParticipantsOnLeave(store, action.participant?.id); + } + const result = next(action); switch (action.type) { + case CLIENT_RESIZED: + case SET_ASPECT_RATIO: + store.dispatch(setTileViewDimensions()); + break; case PARTICIPANT_JOINED: { updateRemoteParticipants(store); break; } - case PARTICIPANT_LEFT: { - updateRemoteParticipantsOnLeave(store, action.participant?.id); - break; - } } return result; }); - diff --git a/react/features/filmstrip/reducer.js b/react/features/filmstrip/reducer.js index 9c8a5daae..40ccd2955 100644 --- a/react/features/filmstrip/reducer.js +++ b/react/features/filmstrip/reducer.js @@ -118,13 +118,9 @@ ReducerRegistry.register( }; case SET_REMOTE_PARTICIPANTS: { state.remoteParticipants = action.participants; + const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state; - // TODO: implement this on mobile. - if (navigator.product !== 'ReactNative') { - const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state; - - state.visibleRemoteParticipants = new Set(state.remoteParticipants.slice(startIndex, endIndex + 1)); - } + state.visibleRemoteParticipants = new Set(state.remoteParticipants.slice(startIndex, endIndex + 1)); return { ...state }; } @@ -167,7 +163,9 @@ ReducerRegistry.register( } delete state.participantsVolume[id]; - return state; + return { + ...state + }; } } diff --git a/react/features/filmstrip/subscriber.native.js b/react/features/filmstrip/subscriber.native.js index 2c1f69b2d..2e63bcc80 100644 --- a/react/features/filmstrip/subscriber.native.js +++ b/react/features/filmstrip/subscriber.native.js @@ -1,3 +1,42 @@ // @flow +import { getParticipantCountWithFake } from '../base/participants'; +import { StateListenerRegistry } from '../base/redux'; +import { getTileViewGridDimensions, shouldDisplayTileView } from '../video-layout'; + +import { setTileViewDimensions } from './actions'; import './subscriber.any'; + +/** + * Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles. + */ +StateListenerRegistry.register( + /* selector */ state => { + const participantCount = getParticipantCountWithFake(state); + + if (participantCount < 5) { // the dimensions are updated only when the participant count is lower than 5. + return participantCount; + } + + return 4; // make sure we don't update the dimensions. + }, + /* listener */ (_, store) => { + const state = store.getState(); + + if (shouldDisplayTileView(state)) { + store.dispatch(setTileViewDimensions()); + } + }); + +/** + * Listens for changes in the selected layout to calculate the dimensions of the tile view grid and horizontal view. + */ +StateListenerRegistry.register( + /* selector */ state => shouldDisplayTileView(state), + /* listener */ (isTileView, store) => { + const state = store.getState(); + + if (isTileView) { + store.dispatch(setTileViewDimensions(getTileViewGridDimensions(state))); + } + }); diff --git a/react/features/invite/functions.js b/react/features/invite/functions.js index d2436ff21..6c8965c81 100644 --- a/react/features/invite/functions.js +++ b/react/features/invite/functions.js @@ -439,7 +439,7 @@ export function isDialOutEnabled(state: Object): boolean { */ export function isSipInviteEnabled(state: Object): boolean { const { sipInviteUrl } = state['features/base/config']; - const { features = {} } = getLocalParticipant(state); + const { features = {} } = getLocalParticipant(state) || {}; return state['features/base/jwt'].jwt && Boolean(sipInviteUrl) diff --git a/react/features/video-menu/components/native/RemoteVideoMenu.js b/react/features/video-menu/components/native/RemoteVideoMenu.js index 84bfa9237..4f0a55678 100644 --- a/react/features/video-menu/components/native/RemoteVideoMenu.js +++ b/react/features/video-menu/components/native/RemoteVideoMenu.js @@ -42,9 +42,9 @@ type Props = { dispatch: Function, /** - * The participant for which this menu opened for. + * The ID of the participant for which this menu opened for. */ - participant: Object, + participantId: String, /** * The color-schemed stylesheet of the BottomSheet. @@ -79,12 +79,7 @@ type Props = { /** * Display name of the participant retrieved from Redux. */ - _participantDisplayName: string, - - /** - * The ID of the participant. - */ - _participantID: ?string, + _participantDisplayName: string } // eslint-disable-next-line prefer-const @@ -117,12 +112,12 @@ class RemoteVideoMenu extends PureComponent { _disableRemoteMute, _disableGrantModerator, _isParticipantAvailable, - participant + participantId } = this.props; const buttonProps = { afterClick: this._onCancel, showLabel: true, - participantID: participant.id, + participantID: participantId, styles: this.props._bottomSheetStyles.buttons }; @@ -141,7 +136,7 @@ class RemoteVideoMenu extends PureComponent { {/* */} - {/* */} + {/* */} ); } @@ -172,7 +167,7 @@ class RemoteVideoMenu extends PureComponent { * @returns {React$Element} */ _renderMenuHeader() { - const { _bottomSheetStyles, participant } = this.props; + const { _bottomSheetStyles, participantId } = this.props; return ( { _bottomSheetStyles.sheet, styles.participantNameContainer ] }> { this.props._participantDisplayName } @@ -200,9 +195,9 @@ class RemoteVideoMenu extends PureComponent { */ function _mapStateToProps(state, ownProps) { const kickOutEnabled = getFeatureFlag(state, KICK_OUT_ENABLED, true); - const { participant } = ownProps; + const { participantId } = ownProps; const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config']; - const isParticipantAvailable = getParticipantById(state, participant.id); + const isParticipantAvailable = getParticipantById(state, participantId); let { disableKick } = remoteVideoMenu; disableKick = disableKick || !kickOutEnabled; @@ -213,8 +208,7 @@ function _mapStateToProps(state, ownProps) { _disableRemoteMute: Boolean(disableRemoteMute), _isOpen: isDialogOpen(state, RemoteVideoMenu_), _isParticipantAvailable: Boolean(isParticipantAvailable), - _participantDisplayName: getParticipantDisplayName(state, participant.id), - _participantID: participant.id + _participantDisplayName: getParticipantDisplayName(state, participantId) }; } diff --git a/react/features/video-menu/components/native/SharedVideoMenu.js b/react/features/video-menu/components/native/SharedVideoMenu.js index 507144f19..c7855206e 100644 --- a/react/features/video-menu/components/native/SharedVideoMenu.js +++ b/react/features/video-menu/components/native/SharedVideoMenu.js @@ -32,9 +32,9 @@ type Props = { dispatch: Function, /** - * The participant for which this menu opened for. + * The ID of the participant for which this menu opened for. */ - participant: Object, + participantId: string, /** * The color-schemed stylesheet of the BottomSheet. @@ -55,11 +55,6 @@ type Props = { * Display name of the participant retrieved from Redux. */ _participantDisplayName: string, - - /** - * The ID of the participant. - */ - _participantID: ?string, } // eslint-disable-next-line prefer-const @@ -89,13 +84,13 @@ class SharedVideoMenu extends PureComponent { render() { const { _isParticipantAvailable, - participant + participantId } = this.props; const buttonProps = { afterClick: this._onCancel, showLabel: true, - participantID: participant.id, + participantID: participantId, styles: this.props._bottomSheetStyles.buttons }; @@ -136,7 +131,7 @@ class SharedVideoMenu extends PureComponent { * @returns {React$Element} */ _renderMenuHeader() { - const { _bottomSheetStyles, participant } = this.props; + const { _bottomSheetStyles, participantId } = this.props; return ( { _bottomSheetStyles.sheet, styles.participantNameContainer ] }> { this.props._participantDisplayName } @@ -163,15 +158,14 @@ class SharedVideoMenu extends PureComponent { * @returns {Props} */ function _mapStateToProps(state, ownProps) { - const { participant } = ownProps; - const isParticipantAvailable = getParticipantById(state, participant.id); + const { participantId } = ownProps; + const isParticipantAvailable = getParticipantById(state, participantId); return { _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'), _isOpen: isDialogOpen(state, SharedVideoMenu_), _isParticipantAvailable: Boolean(isParticipantAvailable), - _participantDisplayName: getParticipantDisplayName(state, participant.id), - _participantID: participant.id + _participantDisplayName: getParticipantDisplayName(state, participantId) }; } diff --git a/react/features/video-quality/subscriber.js b/react/features/video-quality/subscriber.js index c8c1071d3..5143949fb 100644 --- a/react/features/video-quality/subscriber.js +++ b/react/features/video-quality/subscriber.js @@ -191,12 +191,7 @@ function _updateReceiverVideoConstraints({ getState }) { const { maxReceiverVideoQuality, preferredVideoQuality } = state['features/video-quality']; const { participantId: largeVideoParticipantId } = state['features/large-video']; const maxFrameHeight = Math.min(maxReceiverVideoQuality, preferredVideoQuality); - let { visibleRemoteParticipants } = state['features/filmstrip']; - - // TODO: implement this on mobile. - if (navigator.product === 'ReactNative') { - visibleRemoteParticipants = new Set(Array.from(state['features/base/participants'].remote.keys())); - } + const { visibleRemoteParticipants } = state['features/filmstrip']; const receiverConstraints = { constraints: {},