[RN] Implement tile view

* feat(tile-view): initial implementation for mobile

- Create a tile view component for displaying thumbnails in a
  two-dimensional grid.
- Update the existing TileViewButton so it shows a label in the
  overflow menu.
- Modify conference so it can display TileView while hiding
  Filmstrip.
- Modify Thumbnail so its width/height can be set and to prevent
  pinning while in tile view mode.

* use style array for thumbnail styles

* change ternary to math.min for expressiveness

* use dimensiondetector

* pass explicit disableTint prop

* use makeAspectRatioAware instead of aspectRatio prop

* update docs

* fix docs again (fix laziest copy/paste job I've ever done)

* large-video: rename onPress prop to onClick

* change forEach to for...of

* use truthy check fallthrough logic instead of explicit if

* put tile view button second to last in menu

* move spacer to a constant

* the magical incantation to make flow shut up
This commit is contained in:
virtuacoplenny 2018-09-13 08:20:22 -07:00 committed by Zoltan Bettenbuk
parent 37ff77cd5b
commit c25d6eb9a8
12 changed files with 532 additions and 95 deletions

View File

@ -1,54 +1,71 @@
/* @flow */
import PropTypes from 'prop-types';
import React, { Component } from 'react';
/**
* {@code AbstractContainer} component's property types.
*/
export type Props = {
/**
* An optional accessibility label to apply to the container root.
*/
accessibilityLabel?: string,
/**
* Whether or not this element is an accessibility element.
*/
accessible?: boolean,
/**
* React Elements to display within the component.
*/
children: React$Node | Object,
/**
* The event handler/listener to be invoked when this
* {@code AbstractContainer} is clicked on Web or pressed on React
* Native. If {@code onClick} is defined and {@link touchFeedback} is
* undefined, {@code touchFeedback} is considered defined as
* {@code true}.
*/
onClick?: ?Function,
/**
* The style (as in stylesheet) to be applied to this
* {@code AbstractContainer}.
*/
style?: Array<?string> | Object,
/**
* If this instance is to provide visual feedback when touched, then
* {@code true}; otherwise, {@code false}. If {@code touchFeedback} is
* undefined and {@link onClick} is defined, {@code touchFeedback} is
* considered defined as {@code true}.
*/
touchFeedback?: ?Function,
/**
* Color to display when clicked.
*/
underlayColor?: string,
/**
* If this {@code AbstractContainer} is to be visible, then {@code true}
* or {@code false} if this instance is to be hidden or not rendered at
* all.
*/
visible?: ?boolean
};
/**
* Abstract (base) class for container of React {@link Component} children with
* a style.
*
* @extends Component
*/
export default class AbstractContainer extends Component<*> {
/**
* {@code AbstractContainer} component's property types.
*
* @static
*/
static propTypes = {
children: PropTypes.node,
/**
* The event handler/listener to be invoked when this
* {@code AbstractContainer} is clicked on Web or pressed on React
* Native. If {@code onClick} is defined and {@link touchFeedback} is
* undefined, {@code touchFeedback} is considered defined as
* {@code true}.
*/
onClick: PropTypes.func,
/**
* The style (as in stylesheet) to be applied to this
* {@code AbstractContainer}.
*/
style: PropTypes.object,
/**
* If this instance is to provide visual feedback when touched, then
* {@code true}; otherwise, {@code false}. If {@code touchFeedback} is
* undefined and {@link onClick} is defined, {@code touchFeedback} is
* considered defined as {@code true}.
*/
touchFeedback: PropTypes.bool,
/**
* If this {@code AbstractContainer} is to be visible, then {@code true}
* or {@code false} if this instance is to be hidden or not rendered at
* all.
*/
visible: PropTypes.bool
};
export default class AbstractContainer<P: Props> extends Component<P> {
/**
* Renders this {@code AbstractContainer} as a React {@code Component} of a
* specific type.
@ -61,7 +78,7 @@ export default class AbstractContainer extends Component<*> {
* @protected
* @returns {ReactElement}
*/
_render(type, props) {
_render(type, props?: P) {
const {
children,

View File

@ -8,20 +8,14 @@ import {
} from 'react-native';
import AbstractContainer from '../AbstractContainer';
import type { Props } from '../AbstractContainer';
/**
* Represents a container of React Native/mobile {@link Component} children.
*
* @extends AbstractContainer
*/
export default class Container extends AbstractContainer {
/**
* {@code Container} component's property types.
*
* @static
*/
static propTypes = AbstractContainer.propTypes;
export default class Container<P: Props> extends AbstractContainer<P> {
/**
* Implements React's {@link Component#render()}.
*

View File

@ -1,13 +1,14 @@
/* @flow */
import AbstractContainer from '../AbstractContainer';
import type { Props } from '../AbstractContainer';
/**
* Represents a container of React/Web {@link Component} children with a style.
*
* @extends AbstractContainer
*/
export default class Container extends AbstractContainer {
export default class Container<P: Props> extends AbstractContainer<P> {
/**
* {@code Container} component's property types.
*

View File

@ -17,12 +17,18 @@ import {
import { TestConnectionInfo } from '../../base/testing';
import { createDesiredLocalTracks } from '../../base/tracks';
import { ConferenceNotification } from '../../calendar-sync';
import { FILMSTRIP_SIZE, Filmstrip, isFilmstripVisible } from '../../filmstrip';
import {
FILMSTRIP_SIZE,
Filmstrip,
isFilmstripVisible,
TileView
} from '../../filmstrip';
import { LargeVideo } from '../../large-video';
import { CalleeInfoContainer } from '../../invite';
import { NotificationsContainer } from '../../notifications';
import { Captions } from '../../subtitles';
import { setToolboxVisible, Toolbox } from '../../toolbox';
import { shouldDisplayTileView } from '../../video-layout';
import styles from './styles';
@ -115,6 +121,13 @@ type Props = {
*/
_setToolboxVisible: Function,
/**
* Whether or not the layout should change to support tile view mode.
*
* @private
*/
_shouldDisplayTileView: boolean,
/**
* The indicator which determines whether the Toolbox is visible.
*
@ -252,6 +265,12 @@ class Conference extends Component<Props> {
* @returns {ReactElement}
*/
render() {
const {
_connecting,
_reducedUI,
_shouldDisplayTileView
} = this.props;
return (
<Container style = { styles.conference }>
<StatusBar
@ -261,20 +280,23 @@ class Conference extends Component<Props> {
{/*
* The LargeVideo is the lowermost stacking layer.
*/}
<LargeVideo onPress = { this._onClick } />
*/
_shouldDisplayTileView
? <TileView onClick = { this._onClick } />
: <LargeVideo onClick = { this._onClick } />
}
{/*
* If there is a ringing call, show the callee's info.
*/
this.props._reducedUI || <CalleeInfoContainer />
_reducedUI || <CalleeInfoContainer />
}
{/*
* The activity/loading indicator goes above everything, except
* the toolbox/toolbars and the dialogs.
*/
this.props._connecting
_connecting
&& <TintedView>
<LoadingIndicator />
</TintedView>
@ -304,8 +326,9 @@ class Conference extends Component<Props> {
* name and grouping stem from the fact that these two
* React Components depict the videos of the conference's
* participants.
*/}
<Filmstrip />
*/
_shouldDisplayTileView ? undefined : <Filmstrip />
}
</View>
<TestConnectionInfo />
@ -548,6 +571,14 @@ function _mapStateToProps(state) {
*/
_room: room,
/**
* Whether or not the layout should change to support tile view mode.
*
* @private
* @type {boolean}
*/
_shouldDisplayTileView: shouldDisplayTileView(state),
/**
* The indicator which determines whether the Toolbox is visible.
*

View File

@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
@ -18,31 +19,67 @@ import { AVATAR_SIZE } from '../styles';
import styles from './styles';
import VideoMutedIndicator from './VideoMutedIndicator';
/**
* Thumbnail component's property types.
*/
type Props = {
/**
* The Redux representation of the participant's audio track.
*/
_audioTrack: Object,
/**
* The Redux representation of the state "features/large-video".
*/
_largeVideo: Object,
/**
* The Redux representation of the participant's video track.
*/
_videoTrack: Object,
/**
* If true, tapping on the thumbnail will not pin the participant to large
* video. By default tapping does pin the participant.
*/
disablePin?: boolean,
/**
* If true, there will be no color overlay (tint) on the thumbnail
* indicating the participant associated with the thumbnail is displayed on
* large video. By default there will be a tint.
*/
disableTint?: boolean,
/**
* Invoked to trigger state changes in Redux.
*/
dispatch: Dispatch<*>,
/**
* The Redux representation of the participant to display.
*/
participant: Object,
/**
* Optional styling to add or override on the Thumbnail component root.
*/
styleOverrides?: Object
};
/**
* React component for video thumbnail.
*
* @extends Component
*/
class Thumbnail extends Component {
/**
* Thumbnail component's property types.
*
* @static
*/
static propTypes = {
_audioTrack: PropTypes.object,
_largeVideo: PropTypes.object,
_videoTrack: PropTypes.object,
dispatch: PropTypes.func,
participant: PropTypes.object
};
class Thumbnail extends Component<Props> {
/**
* Initializes new Video Thumbnail component.
*
* @param {Object} props - Component props.
*/
constructor(props) {
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once for every instance.
@ -56,19 +93,14 @@ class Thumbnail extends Component {
* @returns {ReactElement}
*/
render() {
const audioTrack = this.props._audioTrack;
const largeVideo = this.props._largeVideo;
const participant = this.props.participant;
const videoTrack = this.props._videoTrack;
let style = styles.thumbnail;
if (participant.pinned) {
style = {
...style,
...styles.thumbnailPinned
};
}
const {
_audioTrack: audioTrack,
_largeVideo: largeVideo,
_videoTrack: videoTrack,
disablePin,
disableTint,
participant
} = this.props;
// We don't render audio in any of the following:
// 1. The audio (source) is muted. There's no practical reason (that we
@ -85,8 +117,13 @@ class Thumbnail extends Component {
return (
<Container
onClick = { this._onClick }
style = { style }>
onClick = { disablePin ? undefined : this._onClick }
style = { [
styles.thumbnail,
participant.pinned && !disablePin
? styles.thumbnailPinned : null,
this.props.styleOverrides || null
] }>
{ renderAudio
&& <Audio
@ -96,7 +133,7 @@ class Thumbnail extends Component {
<ParticipantView
avatarSize = { AVATAR_SIZE }
participantId = { participantId }
tintEnabled = { participantInLargeVideo }
tintEnabled = { participantInLargeVideo && !disableTint }
zOrder = { 1 } />
{ participant.role === PARTICIPANT_ROLE.MODERATOR
@ -117,6 +154,8 @@ class Thumbnail extends Component {
);
}
_onClick: () => void;
/**
* Handles click/tap event on the thumbnail.
*

View File

@ -0,0 +1,337 @@
// @flow
import React, { Component } from 'react';
import {
ScrollView,
TouchableWithoutFeedback,
View
} from 'react-native';
import { connect } from 'react-redux';
import {
getNearestReceiverVideoQualityLevel,
setMaxReceiverVideoQuality
} from '../../../base/conference';
import {
DimensionsDetector,
isNarrowAspectRatio,
makeAspectRatioAware
} from '../../../base/responsive-ui';
import Thumbnail from './Thumbnail';
import styles from './styles';
/**
* The type of the React {@link Component} props of {@link TileView}.
*/
type Props = {
/**
* The participants in the conference.
*/
_participants: Array<Object>,
/**
* Invoked to update the receiver video quality.
*/
dispatch: Dispatch<*>,
/**
* Callback to invoke when tile view is tapped.
*/
onClick: Function
};
/**
* The type of the React {@link Component} state of {@link TileView}.
*/
type State = {
/**
* The available width for {@link TileView} to occupy.
*/
height: number,
/**
* The available height for {@link TileView} to occupy.
*/
width: number
};
/**
* 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
* dimensional grid.
*
* @extends Component
*/
class TileView extends Component<Props, State> {
state = {
height: 0,
width: 0
};
/**
* Initializes a new {@code TileView} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
// Bind event handler so it is only bound once per instance.
this._onDimensionsChanged = this._onDimensionsChanged.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount}.
*
* @inheritdoc
*/
componentDidMount() {
this._updateReceiverQuality();
}
/**
* Implements React's {@link Component#componentDidUpdate}.
*
* @inheritdoc
*/
componentDidUpdate() {
this._updateReceiverQuality();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const { onClick } = this.props;
const { height, width } = this.state;
const rowElements = this._groupIntoRows(
this._renderThumbnails(), this._getColumnCount());
return (
<DimensionsDetector
onDimensionsChanged = { this._onDimensionsChanged }>
<ScrollView
style = {{
...styles.tileView,
height,
width
}}>
<TouchableWithoutFeedback onPress = { onClick }>
<View
style = {{
...styles.tileViewRows,
minHeight: height,
minWidth: width
}}>
{ rowElements }
</View>
</TouchableWithoutFeedback>
</ScrollView>
</DimensionsDetector>
);
}
/**
* Returns how many columns should be displayed for tile view.
*
* @returns {number}
* @private
*/
_getColumnCount() {
const participantCount = this.props._participants.length;
// 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 (isNarrowAspectRatio(this)) {
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.
*
* @private
* @returns {Participant[]}
*/
_getSortedParticipants() {
const participants = [];
let localParticipant;
for (const participant of this.props._participants) {
if (participant.local) {
localParticipant = participant;
} else {
participants.push(participant);
}
}
localParticipant && participants.push(localParticipant);
return participants;
}
/**
* Calculate the height and width for the tiles.
*
* @private
* @returns {Object}
*/
_getTileDimensions() {
const { _participants } = this.props;
const { height, width } = this.state;
const columns = this._getColumnCount();
const participantCount = _participants.length;
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
};
}
/**
* 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(
<View
key = { rowElements.length }
style = { styles.tileViewRow }>
{ thumbnailsInRow }
</View>
);
}
}
return rowElements;
}
_onDimensionsChanged: (width: number, height: number) => void;
/**
* Updates the known available state for {@link TileView} to occupy.
*
* @param {number} width - The component's current width.
* @param {number} height - The component's current height.
* @private
* @returns {void}
*/
_onDimensionsChanged(width: number, height: number) {
this.setState({
height,
width
});
}
/**
* Creates React Elements to display each participant in a thumbnail. Each
* tile will be.
*
* @private
* @returns {ReactElement[]}
*/
_renderThumbnails() {
const styleOverrides = {
aspectRatio: TILE_ASPECT_RATIO,
flex: 0,
height: this._getTileDimensions().height,
width: null
};
return this._getSortedParticipants()
.map(participant => (
<Thumbnail
disablePin = { true }
disableTint = { true }
key = { participant.id }
participant = { participant }
styleOverrides = { styleOverrides } />));
}
/**
* Sets the receiver video quality based on the dimensions of the thumbnails
* that are displayed.
*
* @private
* @returns {void}
*/
_updateReceiverQuality() {
const { height } = this._getTileDimensions();
const qualityLevel = getNearestReceiverVideoQualityLevel(height);
this.props.dispatch(setMaxReceiverVideoQuality(qualityLevel));
}
}
/**
* Maps (parts of) the redux state to the associated {@code TileView}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _participants: Participant[]
* }}
*/
function _mapStateToProps(state) {
return {
_participants: state['features/base/participants']
};
}
export default connect(_mapStateToProps)(makeAspectRatioAware(TileView));

View File

@ -1,2 +1,3 @@
export { default as Filmstrip } from './Filmstrip';
export { default as TileView } from './TileView';
export { default as styles } from './styles';

View File

@ -145,5 +145,18 @@ export default {
width: 5
},
shadowRadius: 5
},
tileView: {
alignSelf: 'center'
},
tileViewRows: {
justifyContent: 'center'
},
tileViewRow: {
flexDirection: 'row',
justifyContent: 'center'
}
};

View File

@ -17,7 +17,7 @@ type Props = {
/**
* Callback to invoke when the {@code LargeVideo} is clicked/pressed.
*/
onPress: Function,
onClick: Function,
/**
* The ID of the participant (to be) depicted by LargeVideo.
@ -114,8 +114,8 @@ class LargeVideo extends Component<Props, State> {
useConnectivityInfoLabel
} = this.state;
const {
onPress,
_participantId
_participantId,
onClick
} = this.props;
return (
@ -123,7 +123,7 @@ class LargeVideo extends Component<Props, State> {
onDimensionsChanged = { this._onDimensionsChanged }>
<ParticipantView
avatarSize = { avatarSize }
onPress = { onPress }
onPress = { onClick }
participantId = { _participantId }
style = { styles.largeVideo }
testHintId = 'org.jitsi.meet.LargeVideo'

View File

@ -9,6 +9,7 @@ import { PictureInPictureButton } from '../../../mobile/picture-in-picture';
import { LiveStreamButton, RecordButton } from '../../../recording';
import { RoomLockButton } from '../../../room-lock';
import { ClosedCaptionButton } from '../../../subtitles';
import { TileViewButton } from '../../../video-layout';
import AudioOnlyButton from './AudioOnlyButton';
import { overflowMenuItemStyles } from './styles';
@ -73,6 +74,7 @@ class OverflowMenu extends Component<Props> {
<ClosedCaptionButton { ...buttonProps } />
<RecordButton { ...buttonProps } />
<LiveStreamButton { ...buttonProps } />
<TileViewButton { ...buttonProps } />
<PictureInPictureButton { ...buttonProps } />
</BottomSheet>
);

View File

@ -38,6 +38,7 @@ type Props = AbstractButtonProps & {
class TileViewButton<P: Props> extends AbstractButton<P, *> {
accessibilityLabel = 'toolbar.accessibilityLabel.tileView';
iconName = 'icon-tiles-many';
label = 'toolbar.tileViewToggle';
toggledIconName = 'icon-tiles-many toggled';
tooltip = 'toolbar.tileViewToggle';

View File

@ -74,7 +74,8 @@ export function shouldDisplayTileView(state: Object = {}) {
return Boolean(
state['features/video-layout']
&& state['features/video-layout'].tileViewEnabled
&& !state['features/etherpad'].editing
&& (!state['features/etherpad']
|| !state['features/etherpad'].editing)
// Truthy check is needed for interfaceConfig to prevent errors on
// mobile which does not have interfaceConfig. On web, tile view