feat: auto tile view

This commit is contained in:
Bettenbuk Zoltan 2020-07-23 15:12:25 +02:00 committed by Zoltan Bettenbuk
parent 00b41dbb41
commit 240b033e76
22 changed files with 189 additions and 84 deletions

View File

@ -17,6 +17,7 @@ import {
getToolboxHeight,
showToolbox
} from '../../../react/features/toolbox';
import { YOUTUBE_PARTICIPANT_NAME } from '../../../react/features/youtube-player';
import UIEvents from '../../../service/UI/UIEvents';
import UIUtil from '../util/UIUtil';
import Filmstrip from '../videolayout/Filmstrip';
@ -305,7 +306,7 @@ export default class SharedVideoManager {
conference: APP.conference._room,
id: self.url,
isFakeParticipant: true,
name: 'YouTube'
name: YOUTUBE_PARTICIPANT_NAME
}));
APP.store.dispatch(pinParticipant(self.url));

View File

@ -451,7 +451,7 @@ export default class SmallVideo {
*/
selectDisplayMode(input) {
// Display name is always and only displayed when user is on the stage
if (input.isCurrentlyOnLargeVideo && !input.tileViewEnabled) {
if (input.isCurrentlyOnLargeVideo && !input.tileViewActive) {
return input.isVideoPlayable && !input.isAudioOnly ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
} else if (input.isVideoPlayable && input.hasVideo && !input.isAudioOnly) {
// check hovering and change state to video with name
@ -472,7 +472,7 @@ export default class SmallVideo {
isCurrentlyOnLargeVideo: this.isCurrentlyOnLargeVideo(),
isHovered: this._isHovered(),
isAudioOnly: APP.conference.isAudioOnly(),
tileViewEnabled: shouldDisplayTileView(APP.store.getState()),
tileViewActive: shouldDisplayTileView(APP.store.getState()),
isVideoPlayable: this.isVideoPlayable(),
hasVideo: Boolean(this.selectVideoElement().length),
connectionStatus: APP.conference.getParticipantConnectionStatus(this.id),

View File

@ -4,6 +4,7 @@ import { SET_FILMSTRIP_ENABLED } from '../../filmstrip/actionTypes';
import { SELECT_LARGE_VIDEO_PARTICIPANT } from '../../large-video/actionTypes';
import { APP_STATE_CHANGED } from '../../mobile/background/actionTypes';
import { SCREEN_SHARE_PARTICIPANTS_UPDATED, SET_TILE_VIEW } from '../../video-layout/actionTypes';
import { shouldDisplayTileView } from '../../video-layout/functions';
import { SET_AUDIO_ONLY } from '../audio-only/actionTypes';
import { CONFERENCE_JOINED } from '../conference/actionTypes';
import { getParticipantById } from '../participants/functions';
@ -59,7 +60,8 @@ function _updateLastN({ getState }) {
if (typeof appState !== 'undefined' && appState !== 'active') {
lastN = 0;
} else if (audioOnly) {
const { screenShares, tileViewEnabled } = state['features/video-layout'];
const { screenShares } = state['features/video-layout'];
const tileViewEnabled = shouldDisplayTileView(state);
const largeVideoParticipantId = state['features/large-video'].participantId;
const largeVideoParticipant
= largeVideoParticipantId ? getParticipantById(state, largeVideoParticipantId) : undefined;

View File

@ -11,6 +11,7 @@ import {
isButtonEnabled,
isToolboxVisible
} from '../../../toolbox';
import { shouldDisplayTileView } from '../../../video-layout/functions';
declare var interfaceConfig: Object;
@ -83,7 +84,7 @@ function mapStateToProps(state) {
const hide = interfaceConfig.HIDE_INVITE_MORE_HEADER;
return {
_tileViewEnabled: state['features/video-layout'].tileViewEnabled,
_tileViewEnabled: shouldDisplayTileView(state),
_visible: isToolboxVisible(state) && isButtonEnabled('invite') && isAlone && !hide
};
}

View File

@ -152,17 +152,14 @@ function _onFollowMeCommand(attributes = {}, id, store) {
}
}
const pinnedParticipant
= getPinnedParticipant(state, attributes.nextOnStage);
const pinnedParticipant = getPinnedParticipant(state);
const idOfParticipantToPin = attributes.nextOnStage;
if (typeof idOfParticipantToPin !== 'undefined'
&& (!pinnedParticipant
|| idOfParticipantToPin !== pinnedParticipant.id)
&& (!pinnedParticipant || idOfParticipantToPin !== pinnedParticipant.id)
&& oldState.nextOnStage !== attributes.nextOnStage) {
_pinVideoThumbnailById(store, idOfParticipantToPin);
} else if (typeof idOfParticipantToPin === 'undefined'
&& pinnedParticipant) {
} else if (typeof idOfParticipantToPin === 'undefined' && pinnedParticipant) {
store.dispatch(pinParticipant(null));
}
}

View File

@ -6,6 +6,7 @@ import {
isLocalParticipantModerator
} from '../base/participants';
import { StateListenerRegistry } from '../base/redux';
import { shouldDisplayTileView } from '../video-layout/functions';
import { FOLLOW_ME_COMMAND } from './constants';
@ -72,7 +73,7 @@ function _getFollowMeState(state) {
filmstripVisible: state['features/filmstrip'].visible,
nextOnStage: pinnedParticipant && pinnedParticipant.id,
sharedDocumentVisible: state['features/etherpad'].editing,
tileViewEnabled: state['features/video-layout'].tileViewEnabled
tileViewEnabled: shouldDisplayTileView(state)
};
}

View File

@ -5,6 +5,7 @@ import { IconPin } from '../../../base/icons';
import { pinParticipant } from '../../../base/participants';
import { connect } from '../../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox';
import { shouldDisplayTileView } from '../../../video-layout/functions';
export type Props = AbstractButtonProps & {
@ -59,7 +60,7 @@ class PinButton extends AbstractButton<Props, *> {
*/
function _mapStateToProps(state) {
return {
visible: state['features/video-layout'].tileViewEnabled
visible: shouldDisplayTileView(state)
};
}

View File

@ -60,6 +60,7 @@ import {
} from '../../../subtitles';
import {
TileViewButton,
shouldDisplayTileView,
toggleTileView
} from '../../../video-layout';
import {
@ -1416,7 +1417,7 @@ function _mapStateToProps(state) {
_feedbackConfigured: Boolean(callStatsID),
_isGuest: state['features/base/jwt'].isGuest,
_fullScreen: fullScreen,
_tileViewEnabled: state['features/video-layout'].tileViewEnabled,
_tileViewEnabled: shouldDisplayTileView(state),
_localParticipantID: localParticipant.id,
_localRecState: localRecordingStates,
_locked: locked,

View File

@ -6,6 +6,7 @@ import {
SCREEN_SHARE_PARTICIPANTS_UPDATED,
SET_TILE_VIEW
} from './actionTypes';
import { shouldDisplayTileView } from './functions';
/**
* Creates a (redux) action which signals that the list of known participants
@ -32,10 +33,10 @@ export function setParticipantsWithScreenShare(participantIds: Array<string>) {
* @param {boolean} enabled - Whether or not tile view should be shown.
* @returns {{
* type: SET_TILE_VIEW,
* enabled: boolean
* enabled: ?boolean
* }}
*/
export function setTileView(enabled: boolean) {
export function setTileView(enabled: ?boolean) {
return {
type: SET_TILE_VIEW,
enabled
@ -50,8 +51,8 @@ export function setTileView(enabled: boolean) {
*/
export function toggleTileView() {
return (dispatch: Dispatch<any>, getState: Function) => {
const { tileViewEnabled } = getState()['features/video-layout'];
const tileViewActive = shouldDisplayTileView(getState());
dispatch(setTileView(!tileViewEnabled));
dispatch(setTileView(!tileViewActive));
};
}

View File

@ -9,12 +9,14 @@ import {
import { TILE_VIEW_ENABLED, getFeatureFlag } from '../../base/flags';
import { translate } from '../../base/i18n';
import { IconTileView } from '../../base/icons';
import { getParticipantCount } from '../../base/participants';
import { connect } from '../../base/redux';
import {
AbstractButton,
type AbstractButtonProps
} from '../../base/toolbox';
import { setTileView } from '../actions';
import { shouldDisplayTileView } from '../functions';
import logger from '../logger';
/**
@ -88,10 +90,11 @@ class TileViewButton<P: Props> extends AbstractButton<P, *> {
*/
function _mapStateToProps(state, ownProps) {
const enabled = getFeatureFlag(state, TILE_VIEW_ENABLED, true);
const { visible = enabled } = ownProps;
const lonelyMeeting = getParticipantCount(state) < 2;
const { visible = enabled && !lonelyMeeting } = ownProps;
return {
_tileViewEnabled: state['features/video-layout'].tileViewEnabled,
_tileViewEnabled: shouldDisplayTileView(state),
visible
};
}

View File

@ -1,6 +1,7 @@
// @flow
import { getPinnedParticipant } from '../base/participants';
import { getPinnedParticipant, getParticipantCount } from '../base/participants';
import { isYoutubeVideoPlaying } from '../youtube-player';
import { LAYOUTS } from './constants';
@ -72,17 +73,44 @@ export function getTileViewGridDimensions(state: Object, maxColumns: number = ge
* @returns {boolean} True if tile view should be displayed.
*/
export function shouldDisplayTileView(state: Object = {}) {
return Boolean(
state['features/video-layout']
&& state['features/video-layout'].tileViewEnabled
&& (!state['features/etherpad']
|| !state['features/etherpad'].editing)
const participantCount = getParticipantCount(state);
// Truthy check is needed for interfaceConfig to prevent errors on
// mobile which does not have interfaceConfig. On web, tile view
// should never be enabled for filmstrip only mode.
&& (typeof interfaceConfig === 'undefined'
|| !interfaceConfig.filmStripOnly)
&& !getPinnedParticipant(state)
// In case of a lonely meeting, we don't allow tile view.
// But it's a special case too, as we don't even render the button,
// see TileViewButton component.
if (participantCount < 2) {
return false;
}
const { tileViewEnabled } = state['features/video-layout'];
if (tileViewEnabled !== undefined) {
// If the user explicitly requested a view mode, we
// do that.
return tileViewEnabled;
}
// None tile view mode is easier to calculate (no need for many negations), so we do
// that and negate it only once.
const shouldDisplayNormalMode = Boolean(
// Reasons for normal mode:
// Editing etherpad
state['features/etherpad']?.editing
// We're in filmstrip-only mode
|| (typeof interfaceConfig === 'object' && interfaceConfig?.filmStripOnly)
// We pinned a participant
|| getPinnedParticipant(state)
// It's a 1-on-1 meeting
|| participantCount < 3
// There is a shared YouTube video in the meeting
|| isYoutubeVideoPlaying(state)
);
return !shouldDisplayNormalMode;
}

View File

@ -1,16 +1,17 @@
import {
PIN_PARTICIPANT,
getPinnedParticipant,
pinParticipant
} from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import { SET_DOCUMENT_EDITING_STATUS, toggleDocument } from '../etherpad';
// @flow
import { getCurrentConference } from '../base/conference';
import { PIN_PARTICIPANT, pinParticipant, getPinnedParticipant } from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { SET_DOCUMENT_EDITING_STATUS } from '../etherpad';
import { SET_TILE_VIEW } from './actionTypes';
import { setTileView } from './actions';
import './subscriber';
let previousTileViewEnabled;
/**
* Middleware which intercepts actions and updates tile view related state.
*
@ -18,41 +19,82 @@ import './subscriber';
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
switch (action.type) {
// Actions that temporarily clear the user preferred state of tile view,
// then re-set it when needed.
case PIN_PARTICIPANT: {
const isPinning = Boolean(action.participant.id);
const { tileViewEnabled } = store.getState()['features/video-layout'];
const pinnedParticipant = getPinnedParticipant(store.getState());
if (isPinning && tileViewEnabled) {
store.dispatch(setTileView(false));
if (pinnedParticipant) {
_storeTileViewStateAndClear(store);
} else {
_restoreTileViewState(store);
}
break;
}
case SET_DOCUMENT_EDITING_STATUS:
if (action.editing) {
store.dispatch(setTileView(false));
_storeTileViewStateAndClear(store);
} else {
_restoreTileViewState(store);
}
break;
case SET_TILE_VIEW: {
const state = store.getState();
if (action.enabled) {
if (getPinnedParticipant(state)) {
store.dispatch(pinParticipant(null));
}
if (state['features/etherpad'].editing) {
store.dispatch(toggleDocument());
}
// Things to update when tile view state changes
case SET_TILE_VIEW:
if (action.enabled && getPinnedParticipant(store)) {
store.dispatch(pinParticipant(null));
}
break;
}
}
return next(action);
return result;
});
/**
* Set up state change listener to perform maintenance tasks when the conference
* is left or failed.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference) => {
if (conference !== previousConference) {
// conference changed, left or failed...
// Clear tile view state.
dispatch(setTileView());
}
});
/**
* Respores tile view state, if it wasn't updated since then.
*
* @param {Object} store - The Redux Store.
* @returns {void}
*/
function _restoreTileViewState({ dispatch, getState }) {
const { tileViewEnabled } = getState()['features/video-layout'];
if (tileViewEnabled === undefined && previousTileViewEnabled !== undefined) {
dispatch(setTileView(previousTileViewEnabled));
}
previousTileViewEnabled = undefined;
}
/**
* Stores the current tile view state and clears it.
*
* @param {Object} store - The Redux Store.
* @returns {void}
*/
function _storeTileViewStateAndClear({ dispatch, getState }) {
const { tileViewEnabled } = getState()['features/video-layout'];
if (tileViewEnabled !== undefined) {
previousTileViewEnabled = tileViewEnabled;
dispatch(setTileView(undefined));
}
}

View File

@ -69,7 +69,7 @@ MiddlewareRegistry.register(store => next => action => {
break;
case PIN_PARTICIPANT:
VideoLayout.onPinChange(action.participant.id);
VideoLayout.onPinChange(action.participant?.id);
break;
case SET_FILMSTRIP_VISIBLE:

View File

@ -1,6 +1,6 @@
// @flow
import { PersistenceRegistry, ReducerRegistry } from '../base/redux';
import { ReducerRegistry } from '../base/redux';
import {
SCREEN_SHARE_PARTICIPANTS_UPDATED,
@ -14,18 +14,17 @@ const DEFAULT_STATE = {
* The indicator which determines whether the video layout should display
* video thumbnails in a tiled layout.
*
* Note: undefined means that the user hasn't requested anything in particular yet, so
* we use our auto switching rules.
*
* @public
* @type {boolean}
*/
tileViewEnabled: false
tileViewEnabled: undefined
};
const STORE_NAME = 'features/video-layout';
PersistenceRegistry.register(STORE_NAME, {
tileViewEnabled: true
});
ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
switch (action.type) {
case SCREEN_SHARE_PARTICIPANTS_UPDATED: {

View File

@ -2,16 +2,12 @@
import debounce from 'lodash/debounce';
import {
getPinnedParticipant,
pinParticipant
} from '../base/participants';
import { pinParticipant, getPinnedParticipant } from '../base/participants';
import { StateListenerRegistry, equals } from '../base/redux';
import { isFollowMeActive } from '../follow-me';
import { selectParticipant } from '../large-video';
import { setParticipantsWithScreenShare } from './actions';
import { shouldDisplayTileView } from './functions';
declare var APP: Object;
declare var interfaceConfig: Object;
@ -21,17 +17,11 @@ declare var interfaceConfig: Object;
* preferred layout state and dispatching additional actions.
*/
StateListenerRegistry.register(
/* selector */ state => shouldDisplayTileView(state),
/* listener */ (displayTileView, store) => {
/* selector */ state => state['features/video-layout'].tileViewEnabled,
/* listener */ (tileViewEnabled, store) => {
const { dispatch } = store;
dispatch(selectParticipant());
if (!displayTileView) {
if (_getAutoPinSetting()) {
_updateAutoPinnedParticipant(store);
}
}
}
);
@ -115,9 +105,11 @@ function _updateAutoPinnedParticipant({ dispatch, getState }) {
const latestScreenshareParticipantId
= screenShares[screenShares.length - 1];
const pinned = getPinnedParticipant(getState);
if (latestScreenshareParticipantId) {
dispatch(pinParticipant(latestScreenshareParticipantId));
} else if (getPinnedParticipant(state['features/base/participants'])) {
} else if (pinned) {
dispatch(pinParticipant(null));
}
}

View File

@ -0,0 +1,6 @@
// @flow
import { Component } from 'react';
export { Component as EnterVideoLinkPrompt };
export { Component as YoutubeLargeVideo };

View File

@ -1,3 +1,5 @@
// @flow
export { default as VideoShareButton } from './VideoShareButton';
export * from './_';

View File

@ -1,2 +1,4 @@
// @flow
export { default as EnterVideoLinkPrompt } from './EnterVideoLinkPrompt';
export { default as YoutubeLargeVideo } from './YoutubeLargeVideo';

View File

@ -0,0 +1,6 @@
// @flow
/**
* Fixed name of the YouTube player fake participant.
*/
export const YOUTUBE_PARTICIPANT_NAME = 'YouTube';

View File

@ -0,0 +1,15 @@
// @flow
import { getParticipants } from '../base/participants';
import { YOUTUBE_PARTICIPANT_NAME } from './constants';
/**
* Returns true if there is a youtube video being shaerd in the meeting.
*
* @param {Object | Function} stateful - The Redux state or a function that gets resolved to the Redux state.
* @returns {boolean}
*/
export function isYoutubeVideoPlaying(stateful: Object | Function): boolean {
return Boolean(getParticipants(stateful).find(p => p.isFakeParticipant && p.name === YOUTUBE_PARTICIPANT_NAME));
}

View File

@ -1,3 +1,7 @@
// @flow
export * from './actions';
export * from './actionTypes';
export * from './components';
export * from './constants';
export * from './functions';

View File

@ -12,6 +12,7 @@ import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { TOGGLE_SHARED_VIDEO, SET_SHARED_VIDEO_STATUS } from './actionTypes';
import { setSharedVideoStatus, showEnterVideoLinkPrompt } from './actions';
import { YOUTUBE_PARTICIPANT_NAME } from './constants';
const SHARED_VIDEO = 'shared-video';
@ -105,7 +106,7 @@ function handleSharingVideoStatus(store, videoId, { state, time, from }, confere
id: videoId,
isFakeParticipant: true,
avatarURL: `https://img.youtube.com/vi/${videoId}/0.jpg`,
name: 'YouTube'
name: YOUTUBE_PARTICIPANT_NAME
}));
dispatch(pinParticipant(videoId));