init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
/**
* The type of (redux) action which enlarges the filmstrip.
*
* {
* type: RESIZE_FILMSTRIP,
* }
*/
export const RESIZE_FILMSTRIP = 'RESIZE_FILMSTRIP';
/**
* The type of (redux) action which sets whether the filmstrip is enabled.
*
* {
* type: SET_FILMSTRIP_ENABLED,
* enabled: boolean
* }
*/
export const SET_FILMSTRIP_ENABLED = 'SET_FILMSTRIP_ENABLED';
/**
* The type of (redux) action which sets whether the filmstrip is visible.
*
* {
* type: SET_FILMSTRIP_VISIBLE,
* visible: boolean
* }
*/
export const SET_FILMSTRIP_VISIBLE = 'SET_FILMSTRIP_VISIBLE';
/**
* The type of (redux) action which sets the dimensions of the tile view grid.
*
* {
* type: SET_TILE_VIEW_DIMENSIONS,
* dimensions: {
* gridDimensions: {
* columns: number,
* height: number,
* minVisibleRows: number,
* width: number
* },
* thumbnailSize: {
* height: number,
* width: number
* },
* filmstripWidth: number
* }
* }
*/
export const SET_TILE_VIEW_DIMENSIONS = 'SET_TILE_VIEW_DIMENSIONS';
/**
* The type of (redux) action which sets the dimensions of the thumbnails in horizontal view.
*
* {
* type: SET_HORIZONTAL_VIEW_DIMENSIONS,
* dimensions: Object
* }
*/
export const SET_HORIZONTAL_VIEW_DIMENSIONS = 'SET_HORIZONTAL_VIEW_DIMENSIONS';
/**
* The type of (redux) action which sets the reordered list of the remote participants in the filmstrip.
* {
* type: SET_REMOTE_PARTICIPANTS,
* participants: Array<string>
* }
*/
export const SET_REMOTE_PARTICIPANTS = 'SET_REMOTE_PARTICIPANTS';
/**
* 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';
/**
* The type of the action which sets the list of visible remote participants in the filmstrip by storing the start and
* end index in the remote participants array.
*
* {
* type: SET_VISIBLE_REMOTE_PARTICIPANTS,
* startIndex: number,
* endIndex: number
* }
*/
export const SET_VISIBLE_REMOTE_PARTICIPANTS = 'SET_VISIBLE_REMOTE_PARTICIPANTS';
/**
* The type of action which sets the height for the top panel filmstrip.
* {
* type: SET_FILMSTRIP_HEIGHT,
* height: number
* }
*/
export const SET_FILMSTRIP_HEIGHT = 'SET_FILMSTRIP_HEIGHT';
/**
* The type of action which sets the width for the vertical filmstrip.
* {
* type: SET_FILMSTRIP_WIDTH,
* width: number
* }
*/
export const SET_FILMSTRIP_WIDTH = 'SET_FILMSTRIP_WIDTH';
/**
* The type of action which sets the height for the top panel filmstrip (user resized).
* {
* type: SET_USER_FILMSTRIP_HEIGHT,
* height: number
* }
*/
export const SET_USER_FILMSTRIP_HEIGHT = 'SET_USER_FILMSTRIP_HEIGHT';
/**
* The type of action which sets the width for the vertical filmstrip (user resized).
* {
* type: SET_USER_FILMSTRIP_WIDTH,
* width: number
* }
*/
export const SET_USER_FILMSTRIP_WIDTH = 'SET_USER_FILMSTRIP_WIDTH';
/**
* The type of action which sets whether the user is resizing or not.
* {
* type: SET_USER_IS_RESIZING,
* resizing: boolean
* }
*/
export const SET_USER_IS_RESIZING = 'SET_USER_IS_RESIZING';
/**
* The type of (redux) action which sets the dimensions of the thumbnails in stage filmstrip view.
*
* {
* type: SET_STAGE_FILMSTRIP_DIMENSIONS,
* dimensions: Object
* }
*/
export const SET_STAGE_FILMSTRIP_DIMENSIONS = 'SET_STAGE_FILMSTRIP_DIMENSIONS';
/**
* The type of Redux action which adds a participant to the active list
* (the participants displayed on the stage filmstrip).
* {
* type: ADD_STAGE_PARTICIPANT,
* participantId: string,
* pinned: boolean
* }
*/
export const ADD_STAGE_PARTICIPANT = 'ADD_STAGE_PARTICIPANT';
/**
* The type of Redux action which removes a participant from the active list
* (the participants displayed on the stage filmstrip).
* {
* type: REMOVE_STAGE_PARTICIPANT,
* participantId: string,
* }
*/
export const REMOVE_STAGE_PARTICIPANT = 'REMOVE_STAGE_PARTICIPANT';
/**
* The type of Redux action which sets the active participants list
* (the participants displayed on the stage filmstrip).
* {
* type: SET_STAGE_PARTICIPANTS,
* queue: Array<Object>
* }
*/
export const SET_STAGE_PARTICIPANTS = 'SET_STAGE_PARTICIPANTS';
/**
* The type of Redux action which toggles the pin state of stage participants.
* {
* type: TOGGLE_PIN_STAGE_PARTICIPANT,
* participantId: String
* }
*/
export const TOGGLE_PIN_STAGE_PARTICIPANT = 'TOGGLE_PIN_STAGE_PARTICIPANT';
/**
* The type of Redux action which clears the list of stage participants.
* {
* type: CLEAR_STAGE_PARTICIPANTS
* }
*/
export const CLEAR_STAGE_PARTICIPANTS = 'CLEAR_STAGE_PARTICIPANTS';
/**
* The type of Redux action which sets the participant to be displayed
* on the screenshare filmstrip.
* {
* type: SET_SCREENSHARE_FILMSTRIP_PARTICIPANT,
* participantId: string|undefined
* }
*/
export const SET_SCREENSHARE_FILMSTRIP_PARTICIPANT = 'SET_SCREENSHARE_FILMSTRIP_PARTICIPANT';
/**
* The type of Redux action which sets the dimensions of the screenshare tile.
* {
* type: SET_SCREENSHARING_TILE_DIMENSIONS
* }
*/
export const SET_SCREENSHARING_TILE_DIMENSIONS = 'SET_SCREENSHARING_TILE_DIMENSIONS';
/**
* The type of Redux action which sets the visibility of the top panel.
* {
* type: SET_TOP_PANEL_VISIBILITY
* }
*/
export const SET_TOP_PANEL_VISIBILITY = 'SET_TOP_PANEL_VISIBILITY';

View File

@@ -0,0 +1,75 @@
import {
SET_FILMSTRIP_ENABLED,
SET_FILMSTRIP_VISIBLE,
SET_REMOTE_PARTICIPANTS,
SET_VISIBLE_REMOTE_PARTICIPANTS
} from './actionTypes';
/**
* Sets whether the filmstrip is enabled.
*
* @param {boolean} enabled - Whether the filmstrip is enabled.
* @returns {{
* type: SET_FILMSTRIP_ENABLED,
* enabled: boolean
* }}
*/
export function setFilmstripEnabled(enabled: boolean) {
return {
type: SET_FILMSTRIP_ENABLED,
enabled
};
}
/**
* Sets whether the filmstrip is visible.
*
* @param {boolean} visible - Whether the filmstrip is visible.
* @returns {{
* type: SET_FILMSTRIP_VISIBLE,
* visible: boolean
* }}
*/
export function setFilmstripVisible(visible: boolean) {
return {
type: SET_FILMSTRIP_VISIBLE,
visible
};
}
/**
* Sets the list of the reordered remote participants based on which the visible participants in the filmstrip will be
* determined.
*
* @param {Array<string>} participants - The list of the remote participant endpoint IDs.
* @returns {{
type: SET_REMOTE_PARTICIPANTS,
participants: Array<string>
}}
*/
export function setRemoteParticipants(participants: Array<string>) {
return {
type: SET_REMOTE_PARTICIPANTS,
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
};
}

View File

@@ -0,0 +1,85 @@
import { IStore } from '../app/types';
import conferenceStyles from '../conference/components/native/styles';
import { SET_TILE_VIEW_DIMENSIONS } from './actionTypes';
import styles from './components/native/styles';
import { SQUARE_TILE_ASPECT_RATIO, TILE_MARGIN } from './constants';
import { getColumnCount, getTileViewParticipantCount } from './functions.native';
export * from './actions.any';
/**
* Sets the dimensions of the tile view grid. The action is only partially implemented on native as not all
* of the values are currently used. Check the description of {@link SET_TILE_VIEW_DIMENSIONS} for the full set
* of properties.
*
* @returns {Function}
*/
export function setTileViewDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const participantCount = getTileViewParticipantCount(state);
const { clientHeight: height, clientWidth: width, safeAreaInsets = {
left: undefined,
right: undefined,
top: undefined,
bottom: undefined
} } = state['features/base/responsive-ui'];
const { left = 0, right = 0, top = 0, bottom = 0 } = safeAreaInsets;
const columns = getColumnCount(state);
const rows = Math.ceil(participantCount / columns); // @ts-ignore
const conferenceBorder = conferenceStyles.conference.borderWidth || 0;
const heightToUse = height - top - bottom - (2 * conferenceBorder);
const widthToUse = width - (TILE_MARGIN * 2) - left - right - (2 * conferenceBorder);
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);
// Adding safeAreaInsets.bottom to the total height of all thumbnails because we add it as a padding to the
// thumbnails container.
const hasScroll = heightToUse < ((tileHeight + (2 * styles.thumbnail.margin)) * rows) + bottom;
dispatch({
type: SET_TILE_VIEW_DIMENSIONS,
dimensions: {
columns,
thumbnailSize: {
height: tileHeight,
width: tileWidth
},
hasScroll
}
});
};
}
/**
* Add participant to the active participants list.
*
* @param {string} _participantId - The Id of the participant to be added.
* @param {boolean?} _pinned - Whether the participant is pinned or not.
* @returns {Object}
*/
export function addStageParticipant(_participantId: string, _pinned = false): any {
return {};
}
/**
* Remove participant from the active participants list.
*
* @param {string} _participantId - The Id of the participant to be removed.
* @returns {Object}
*/
export function removeStageParticipant(_participantId: string): any {
return {};
}

View File

@@ -0,0 +1,591 @@
import { IStore } from '../app/types';
import { pinParticipant } from '../base/participants/actions';
import {
getLocalParticipant,
getParticipantById,
getRemoteParticipantCountWithFake
} from '../base/participants/functions';
import { getHideSelfView } from '../base/settings/functions.any';
import { getMaxColumnCount } from '../video-layout/functions.web';
import {
ADD_STAGE_PARTICIPANT,
CLEAR_STAGE_PARTICIPANTS,
REMOVE_STAGE_PARTICIPANT,
RESIZE_FILMSTRIP,
SET_FILMSTRIP_HEIGHT,
SET_FILMSTRIP_WIDTH,
SET_HORIZONTAL_VIEW_DIMENSIONS,
SET_SCREENSHARE_FILMSTRIP_PARTICIPANT,
SET_SCREENSHARING_TILE_DIMENSIONS,
SET_STAGE_FILMSTRIP_DIMENSIONS,
SET_STAGE_PARTICIPANTS,
SET_TILE_VIEW_DIMENSIONS,
SET_TOP_PANEL_VISIBILITY,
SET_USER_FILMSTRIP_HEIGHT,
SET_USER_FILMSTRIP_WIDTH,
SET_USER_IS_RESIZING,
SET_VERTICAL_VIEW_DIMENSIONS,
SET_VOLUME,
TOGGLE_PIN_STAGE_PARTICIPANT
} from './actionTypes';
import {
HORIZONTAL_FILMSTRIP_MARGIN,
MAX_ACTIVE_PARTICIPANTS,
SCROLL_SIZE,
STAGE_VIEW_THUMBNAIL_VERTICAL_BORDER,
TILE_HORIZONTAL_MARGIN,
TILE_MIN_HEIGHT_SMALL,
TILE_VERTICAL_CONTAINER_HORIZONTAL_MARGIN,
TILE_VERTICAL_MARGIN,
TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES,
TILE_VIEW_GRID_HORIZONTAL_MARGIN,
TILE_VIEW_GRID_VERTICAL_MARGIN,
TOP_FILMSTRIP_HEIGHT,
VERTICAL_FILMSTRIP_VERTICAL_MARGIN
} from './constants';
import {
calculateNonResponsiveTileViewDimensions,
calculateResponsiveTileViewDimensions,
calculateThumbnailSizeForHorizontalView,
calculateThumbnailSizeForVerticalView,
getNumberOfPartipantsForTileView,
getVerticalViewMaxWidth,
isFilmstripResizable,
isStageFilmstripAvailable,
isStageFilmstripTopPanel
, showGridInVerticalView } from './functions.web';
export * from './actions.any';
/**
* Resize the filmstrip.
*
* @param {number} width - Width value for filmstrip.
*
* @returns {{
* type: RESIZE_FILMSTRIP,
* width: number,
* }}
*/
export function resizeFilmStrip(width: number) {
return {
type: RESIZE_FILMSTRIP,
width
};
}
/**
* Sets the dimensions of the tile view grid.
*
* @returns {Function}
*/
export function setTileViewDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const {
disableResponsiveTiles,
disableTileEnlargement,
tileView = {}
} = state['features/base/config'];
const { numberOfVisibleTiles = TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES } = tileView;
const numberOfParticipants = getNumberOfPartipantsForTileView(state);
const maxColumns = getMaxColumnCount(state);
const {
height,
width,
columns,
rows
} = disableResponsiveTiles
? calculateNonResponsiveTileViewDimensions(state)
: calculateResponsiveTileViewDimensions({
clientWidth: videoSpaceWidth,
clientHeight,
disableTileEnlargement,
maxColumns,
numberOfParticipants,
desiredNumberOfVisibleTiles: numberOfVisibleTiles
});
const thumbnailsTotalHeight = (rows ?? 1) * (TILE_VERTICAL_MARGIN + (height ?? 0));
const availableHeight = clientHeight - TILE_VIEW_GRID_VERTICAL_MARGIN;
const hasScroll = availableHeight < thumbnailsTotalHeight;
const filmstripWidth
= Math.min(videoSpaceWidth - TILE_VIEW_GRID_HORIZONTAL_MARGIN,
(columns ?? 1) * (TILE_HORIZONTAL_MARGIN + (width ?? 0)))
+ (hasScroll ? SCROLL_SIZE : 0);
const filmstripHeight = Math.min(availableHeight, thumbnailsTotalHeight);
dispatch({
type: SET_TILE_VIEW_DIMENSIONS,
dimensions: {
gridDimensions: {
columns,
rows
},
thumbnailSize: {
height,
width
},
filmstripHeight,
filmstripWidth,
hasScroll
}
});
};
}
/**
* Sets the dimensions of the thumbnails in vertical view.
*
* @returns {Function}
*/
export function setVerticalViewDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { clientHeight = 0, videoSpaceWidth = 0 } = state['features/base/responsive-ui'];
const { width: filmstripWidth } = state['features/filmstrip'];
const disableSelfView = getHideSelfView(state);
const resizableFilmstrip = isFilmstripResizable(state);
const _verticalViewGrid = showGridInVerticalView(state);
const numberOfRemoteParticipants = getRemoteParticipantCountWithFake(state);
const { localScreenShare } = state['features/base/participants'];
let gridView = {};
let thumbnails: any = {};
let filmstripDimensions = {};
let hasScroll = false;
let remoteVideosContainerWidth;
let remoteVideosContainerHeight;
// grid view in the vertical filmstrip
if (_verticalViewGrid) {
const { tileView = {} } = state['features/base/config'];
const { numberOfVisibleTiles = TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES } = tileView;
const numberOfParticipants = getNumberOfPartipantsForTileView(state);
const maxColumns = getMaxColumnCount(state, {
width: filmstripWidth.current,
disableResponsiveTiles: false,
disableTileEnlargement: false
});
const {
height,
width,
columns,
rows
} = calculateResponsiveTileViewDimensions({
clientWidth: filmstripWidth.current ?? 0,
clientHeight,
disableTileEnlargement: false,
maxColumns,
noHorizontalContainerMargin: true,
numberOfParticipants,
desiredNumberOfVisibleTiles: numberOfVisibleTiles
});
const thumbnailsTotalHeight = (rows ?? 1) * (TILE_VERTICAL_MARGIN + (height ?? 0));
hasScroll = clientHeight < thumbnailsTotalHeight;
const widthOfFilmstrip = ((columns ?? 1) * (TILE_HORIZONTAL_MARGIN + (width ?? 0)))
+ (hasScroll ? SCROLL_SIZE : 0);
const filmstripHeight = Math.min(clientHeight - TILE_VIEW_GRID_VERTICAL_MARGIN, thumbnailsTotalHeight);
gridView = {
gridDimensions: {
columns,
rows
},
thumbnailSize: {
height,
width
},
hasScroll
};
filmstripDimensions = {
height: filmstripHeight,
width: widthOfFilmstrip
};
} else {
thumbnails = calculateThumbnailSizeForVerticalView(videoSpaceWidth, filmstripWidth.current ?? 0,
resizableFilmstrip);
remoteVideosContainerWidth
= thumbnails?.local?.width + TILE_VERTICAL_CONTAINER_HORIZONTAL_MARGIN + SCROLL_SIZE;
remoteVideosContainerHeight
= clientHeight - (disableSelfView ? 0 : thumbnails?.local?.height) - VERTICAL_FILMSTRIP_VERTICAL_MARGIN;
// Account for the height of the local screen share thumbnail when calculating the height of the remote
// videos container.
const localCameraThumbnailHeight = thumbnails?.local?.height;
const localScreenShareThumbnailHeight
= localScreenShare && !disableSelfView ? thumbnails?.local?.height : 0;
remoteVideosContainerHeight = clientHeight
- localCameraThumbnailHeight
- localScreenShareThumbnailHeight
- VERTICAL_FILMSTRIP_VERTICAL_MARGIN;
hasScroll
= remoteVideosContainerHeight
< (thumbnails?.remote.height + TILE_VERTICAL_MARGIN) * numberOfRemoteParticipants;
}
dispatch({
type: SET_VERTICAL_VIEW_DIMENSIONS,
dimensions: {
...thumbnails,
remoteVideosContainer: _verticalViewGrid ? filmstripDimensions : {
width: remoteVideosContainerWidth,
height: remoteVideosContainerHeight
},
gridView,
hasScroll
}
});
};
}
/**
* Sets the dimensions of the thumbnails in horizontal view.
*
* @returns {Function}
*/
export function setHorizontalViewDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { clientHeight = 0, videoSpaceWidth = 0 } = state['features/base/responsive-ui'];
const disableSelfView = getHideSelfView(state);
const thumbnails = calculateThumbnailSizeForHorizontalView(clientHeight);
const remoteVideosContainerWidth
= videoSpaceWidth - (disableSelfView ? 0 : thumbnails?.local?.width) - HORIZONTAL_FILMSTRIP_MARGIN;
const remoteVideosContainerHeight
= thumbnails?.local?.height + TILE_VERTICAL_MARGIN + STAGE_VIEW_THUMBNAIL_VERTICAL_BORDER + SCROLL_SIZE;
const numberOfRemoteParticipants = getRemoteParticipantCountWithFake(state);
const hasScroll
= remoteVideosContainerHeight
< (thumbnails?.remote.width + TILE_HORIZONTAL_MARGIN) * numberOfRemoteParticipants;
dispatch({
type: SET_HORIZONTAL_VIEW_DIMENSIONS,
dimensions: {
...thumbnails,
remoteVideosContainer: {
width: remoteVideosContainerWidth,
height: remoteVideosContainerHeight
},
hasScroll
}
});
};
}
/**
* Sets the dimensions of the stage filmstrip tile view grid.
*
* @returns {Function}
*/
export function setStageFilmstripViewDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const {
tileView = {}
} = state['features/base/config'];
const { visible, topPanelHeight } = state['features/filmstrip'];
const verticalWidth = visible ? getVerticalViewMaxWidth(state) : 0;
const { numberOfVisibleTiles = MAX_ACTIVE_PARTICIPANTS } = tileView;
const numberOfParticipants = state['features/filmstrip'].activeParticipants.length;
const availableWidth = videoSpaceWidth - verticalWidth;
const maxColumns = getMaxColumnCount(state, {
width: availableWidth,
disableResponsiveTiles: false,
disableTileEnlargement: false
});
const topPanel = isStageFilmstripTopPanel(state);
const {
height,
width,
columns,
rows
} = calculateResponsiveTileViewDimensions({
clientWidth: availableWidth,
clientHeight: topPanel ? topPanelHeight?.current || TOP_FILMSTRIP_HEIGHT : clientHeight,
disableTileEnlargement: false,
maxColumns,
noHorizontalContainerMargin: verticalWidth > 0,
numberOfParticipants,
desiredNumberOfVisibleTiles: numberOfVisibleTiles,
minTileHeight: topPanel ? TILE_MIN_HEIGHT_SMALL : null
});
const thumbnailsTotalHeight = (rows ?? 1) * (TILE_VERTICAL_MARGIN + (height ?? 0));
const hasScroll = clientHeight < thumbnailsTotalHeight;
const filmstripWidth
= Math.min(videoSpaceWidth - TILE_VIEW_GRID_HORIZONTAL_MARGIN,
(columns ?? 1) * (TILE_HORIZONTAL_MARGIN + (width ?? 0)))
+ (hasScroll ? SCROLL_SIZE : 0);
const filmstripHeight = Math.min(clientHeight - TILE_VIEW_GRID_VERTICAL_MARGIN, thumbnailsTotalHeight);
dispatch({
type: SET_STAGE_FILMSTRIP_DIMENSIONS,
dimensions: {
gridDimensions: {
columns,
rows
},
thumbnailSize: {
height,
width
},
filmstripHeight,
filmstripWidth,
hasScroll
}
});
};
}
/**
* Emulates a click on the n-th video.
*
* @param {number} n - Number that identifies the video.
* @returns {Function}
*/
export function clickOnVideo(n: number) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { id: localId } = getLocalParticipant(state) ?? {};
// Use the list that correctly represents the current order of the participants as visible in the UI.
const { remoteParticipants } = state['features/filmstrip'];
const participants = [ localId, ...remoteParticipants ];
if (participants.length - 1 < n) {
return;
}
const { id, pinned } = getParticipantById(state, participants[n] ?? '') ?? {};
if (isStageFilmstripAvailable(state)) {
dispatch(togglePinStageParticipant(id ?? ''));
} else {
dispatch(pinParticipant(pinned ? null : id));
}
};
}
/**
* Sets the volume for a thumbnail's audio.
*
* @param {string} participantId - The participant ID associated 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
};
}
/**
* Sets the top filmstrip's height.
*
* @param {number} height - The new height of the filmstrip.
* @returns {{
* type: SET_FILMSTRIP_HEIGHT,
* height: number
* }}
*/
export function setFilmstripHeight(height: number) {
return {
type: SET_FILMSTRIP_HEIGHT,
height
};
}
/**
* Sets the filmstrip's width.
*
* @param {number} width - The new width of the filmstrip.
* @returns {{
* type: SET_FILMSTRIP_WIDTH,
* width: number
* }}
*/
export function setFilmstripWidth(width: number) {
return {
type: SET_FILMSTRIP_WIDTH,
width
};
}
/**
* Sets the filmstrip's height and the user preferred height.
*
* @param {number} height - The new height of the filmstrip.
* @returns {{
* type: SET_USER_FILMSTRIP_WIDTH,
* height: number
* }}
*/
export function setUserFilmstripHeight(height: number) {
return {
type: SET_USER_FILMSTRIP_HEIGHT,
height
};
}
/**
* Sets the filmstrip's width and the user preferred width.
*
* @param {number} width - The new width of the filmstrip.
* @returns {{
* type: SET_USER_FILMSTRIP_WIDTH,
* width: number
* }}
*/
export function setUserFilmstripWidth(width: number) {
return {
type: SET_USER_FILMSTRIP_WIDTH,
width
};
}
/**
* Sets whether the user is resizing or not.
*
* @param {boolean} resizing - Whether the user is resizing or not.
* @returns {Object}
*/
export function setUserIsResizing(resizing: boolean) {
return {
type: SET_USER_IS_RESIZING,
resizing
};
}
/**
* Add participant to the active participants list.
*
* @param {string} participantId - The Id of the participant to be added.
* @param {boolean?} pinned - Whether the participant is pinned or not.
* @returns {Object}
*/
export function addStageParticipant(participantId: string, pinned = false) {
return {
type: ADD_STAGE_PARTICIPANT,
participantId,
pinned
};
}
/**
* Remove participant from the active participants list.
*
* @param {string} participantId - The Id of the participant to be removed.
* @returns {Object}
*/
export function removeStageParticipant(participantId: string) {
return {
type: REMOVE_STAGE_PARTICIPANT,
participantId
};
}
/**
* Sets the active participants list.
*
* @param {Array<Object>} queue - The new list.
* @returns {Object}
*/
export function setStageParticipants(queue: Object[]) {
return {
type: SET_STAGE_PARTICIPANTS,
queue
};
}
/**
* Toggles the pin state of the given participant.
*
* @param {string} participantId - The id of the participant to be toggled.
* @returns {Object}
*/
export function togglePinStageParticipant(participantId: string) {
return {
type: TOGGLE_PIN_STAGE_PARTICIPANT,
participantId
};
}
/**
* Clears the stage participants list.
*
* @returns {Object}
*/
export function clearStageParticipants() {
return {
type: CLEAR_STAGE_PARTICIPANTS
};
}
/**
* Set the screensharing tile dimensions.
*
* @returns {Object}
*/
export function setScreensharingTileDimensions() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const { visible, topPanelHeight, topPanelVisible } = state['features/filmstrip'];
const verticalWidth = visible ? getVerticalViewMaxWidth(state) : 0;
const availableWidth = videoSpaceWidth - verticalWidth;
const topPanel = isStageFilmstripTopPanel(state) && topPanelVisible;
const availableHeight = clientHeight - (topPanel ? topPanelHeight?.current || TOP_FILMSTRIP_HEIGHT : 0);
dispatch({
type: SET_SCREENSHARING_TILE_DIMENSIONS,
dimensions: {
filmstripHeight: availableHeight,
filmstripWidth: availableWidth,
thumbnailSize: {
width: availableWidth - TILE_HORIZONTAL_MARGIN,
height: availableHeight - TILE_VERTICAL_MARGIN
}
}
});
};
}
/**
* Sets the visibility of the top panel.
*
* @param {boolean} visible - Whether it should be visible or not.
* @returns {Object}
*/
export function setTopPanelVisible(visible: boolean) {
return {
type: SET_TOP_PANEL_VISIBILITY,
visible
};
}
/**
* Sets the participant whose screenshare to be displayed on the filmstrip.
*
* @param {string|undefined} participantId - The id of the participant to be set.
* @returns {Object}
*/
export function setScreenshareFilmstripParticipant(participantId?: string) {
return {
type: SET_SCREENSHARE_FILMSTRIP_PARTICIPANT,
participantId
};
}

View File

@@ -0,0 +1,20 @@
import React, { Component } from 'react';
import { IconMicSlash } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
/**
* Thumbnail badge for displaying the audio mute status of a participant.
*/
export default class AudioMutedIndicator extends Component<{}> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
return (
<BaseIndicator icon = { IconMicSlash } />
);
}
}

View File

@@ -0,0 +1,337 @@
import React, { PureComponent } from 'react';
import { FlatList, ViewStyle, ViewToken } from 'react-native';
import { SafeAreaView, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import Platform from '../../../base/react/Platform.native';
import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import { setVisibleRemoteParticipants } from '../../actions.native';
import {
getFilmstripDimensions,
isFilmstripVisible,
shouldDisplayLocalThumbnailSeparately,
shouldRemoteVideosBeVisible
} from '../../functions.native';
import LocalThumbnail from './LocalThumbnail';
import Thumbnail from './Thumbnail';
import styles from './styles';
// Immutable reference to avoid re-renders.
const NO_REMOTE_VIDEOS: any[] = [];
/**
* Filmstrip component's property types.
*/
interface IProps {
/**
* Application's aspect ratio.
*/
_aspectRatio: Symbol;
_clientHeight: number;
_clientWidth: number;
/**
* Whether or not to hide the self view.
*/
_disableSelfView: boolean;
_localParticipantId: string;
/**
* The participants in the conference.
*/
_participants: Array<any>;
/**
* Whether or not the toolbox is displayed.
*/
_toolboxVisible: Boolean;
/**
* The indicator which determines whether the filmstrip is visible.
*/
_visible: boolean;
/**
* Invoked to trigger state changes in Redux.
*/
dispatch: IStore['dispatch'];
/**
* Object containing the safe area insets.
*/
insets?: Object;
}
/**
* Implements a React {@link Component} which represents the filmstrip on
* mobile/React Native.
*
* @augments Component
*/
class Filmstrip extends PureComponent<IProps> {
/**
* 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.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
// XXX Our current design is to have the local participant separate from
// the remote participants. Unfortunately, Android's Video
// implementation cannot accommodate that because remote participants'
// videos appear on top of the local participant's video at times.
// That's because Android's Video utilizes EGL and EGL gives us only two
// practical layers in which we can place our participants' videos:
// layer #0 sits behind the window, creates a hole in the window, and
// there we render the LargeVideo; layer #1 is known as media overlay in
// EGL terms, renders on top of layer #0, and, consequently, is for the
// Filmstrip. With the separate LocalThumbnail, we should have left the
// remote participants' Thumbnails in layer #1 and utilized layer #2 for
// LocalThumbnail. Unfortunately, layer #2 is not practical (that's why
// I said we had two practical layers only) because it renders on top of
// everything which in our case means on top of participant-related
// indicators such as moderator, audio and video muted, etc. For now we
// do not have much of a choice but to continue rendering LocalThumbnail
// as any other remote Thumbnail on Android.
this._separateLocalThumbnail = shouldDisplayLocalThumbnailSeparately();
this._viewabilityConfig = {
itemVisiblePercentThreshold: 30,
minimumViewTime: 500
};
this._keyExtractor = this._keyExtractor.bind(this);
this._getItemLayout = this._getItemLayout.bind(this);
this._onViewableItemsChanged = this._onViewableItemsChanged.bind(this);
this._renderThumbnail = this._renderThumbnail.bind(this);
}
/**
* Returns a key for a passed item of the list.
*
* @param {string} item - The user ID.
* @returns {string} - The user ID.
*/
_keyExtractor(item: string) {
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,
_disableSelfView,
_localParticipantId,
insets
} = this.props;
const localParticipantVisible = Boolean(_localParticipantId) && !_disableSelfView;
return getFilmstripDimensions({
aspectRatio: _aspectRatio,
clientHeight: _clientHeight,
clientWidth: _clientWidth,
insets,
localParticipantVisible
});
}
/**
* Optimization for FlatList. Returns the length, offset and index for an item.
*
* @param {Array<string>} _data - The data array with user IDs.
* @param {number} index - The index number of the item.
* @returns {Object}
*/
_getItemLayout(_data: string[] | null | undefined, index: number) {
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
};
}
/**
* A handler for visible items changes.
*
* @param {Object} data - The visible items data.
* @param {Array<Object>} data.viewableItems - The visible items array.
* @returns {void}
*/
_onViewableItemsChanged({ viewableItems = [] }: { viewableItems: ViewToken[]; }) {
const { _disableSelfView } = this.props;
if (!this._separateLocalThumbnail && !_disableSelfView && viewableItems[0]?.index === 0) {
// Skip the local thumbnail.
viewableItems.shift();
}
if (viewableItems.length === 0) {
// User might be fast-scrolling, it will stabilize.
return;
}
let startIndex = Number(viewableItems[0].index);
let endIndex = Number(viewableItems[viewableItems.length - 1].index);
if (!this._separateLocalThumbnail && !_disableSelfView) {
// We are off by one in the remote participants array.
startIndex -= 1;
endIndex -= 1;
}
this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex));
}
/**
* Creates React Element to display each participant in a thumbnail.
*
* @private
* @returns {ReactElement}
*/
_renderThumbnail({ item }: { item: string; }) {
return (
<Thumbnail
key = { item }
participantID = { item } />)
;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_aspectRatio,
_disableSelfView,
_toolboxVisible,
_localParticipantId,
_participants,
_visible
} = this.props;
if (!_visible) {
return null;
}
const bottomEdge = Platform.OS === 'ios' && !_toolboxVisible;
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.max(
0,
Math.ceil(
isNarrowAspectRatio
? width / (thumbnailWidth + (2 * margin))
: height / (thumbnailHeight + (2 * margin))
)
);
let participants;
if (this._separateLocalThumbnail || _disableSelfView) {
participants = _participants;
} else if (isNarrowAspectRatio) {
participants = [ ..._participants, _localParticipantId ];
} else {
participants = [ _localParticipantId, ..._participants ];
}
return (
<SafeAreaView // @ts-ignore
edges = { [ bottomEdge && 'bottom', 'left', 'right' ].filter(Boolean) }
style = { filmstripStyle as ViewStyle }>
{
this._separateLocalThumbnail
&& !isNarrowAspectRatio
&& !_disableSelfView
&& <LocalThumbnail />
}
<FlatList
bounces = { false }
data = { participants }
/* @ts-ignore */
getItemLayout = { this._getItemLayout }
horizontal = { isNarrowAspectRatio }
initialNumToRender = { initialNumToRender }
key = { isNarrowAspectRatio ? 'narrow' : 'wide' }
keyExtractor = { this._keyExtractor }
onViewableItemsChanged = { this._onViewableItemsChanged }
renderItem = { this._renderThumbnail }
showsHorizontalScrollIndicator = { false }
showsVerticalScrollIndicator = { false }
style = { styles.flatListStageView }
viewabilityConfig = { this._viewabilityConfig }
windowSize = { 2 } />
{
this._separateLocalThumbnail
&& isNarrowAspectRatio
&& !_disableSelfView
&& <LocalThumbnail />
}
</SafeAreaView>
);
}
}
/**
* Maps (parts of) the redux state to the associated {@code Filmstrip}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { enabled, remoteParticipants } = state['features/filmstrip'];
const disableSelfView = getHideSelfView(state);
const showRemoteVideos = shouldRemoteVideosBeVisible(state);
const responsiveUI = state['features/base/responsive-ui'];
return {
_aspectRatio: responsiveUI.aspectRatio,
_clientHeight: responsiveUI.clientHeight,
_clientWidth: responsiveUI.clientWidth,
_disableSelfView: disableSelfView,
_localParticipantId: getLocalParticipant(state)?.id ?? '',
_participants: showRemoteVideos ? remoteParticipants : NO_REMOTE_VIDEOS,
_toolboxVisible: isToolboxVisible(state),
_visible: enabled && isFilmstripVisible(state)
};
}
export default withSafeAreaInsets(connect(_mapStateToProps)(Filmstrip));

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { View, ViewStyle } from 'react-native';
import Thumbnail from './Thumbnail';
import styles from './styles';
/**
* Component to render a local thumbnail that can be separated from the
* remote thumbnails later.
*
* @returns {ReactElement}
*/
export default function LocalThumbnail() {
return (
<View style = { styles.localThumbnail as ViewStyle }>
<Thumbnail />
</View>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { IconModerator } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
/**
* Thumbnail badge showing that the participant is a conference moderator.
*
* @returns {JSX.Element}
*/
const ModeratorIndicator = (): JSX.Element => <BaseIndicator icon = { IconModerator } />;
export default ModeratorIndicator;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { IconPin } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
/**
* Thumbnail badge for displaying if a participant is pinned.
*
* @returns {React$Element<any>}
*/
export default function PinnedIndicator() {
return (
<BaseIndicator icon = { IconPin } />
);
}

View File

@@ -0,0 +1,78 @@
import React, { Component } from 'react';
import { View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconRaiseHand } from '../../../base/icons/svg';
import { getParticipantById, hasRaisedHand } from '../../../base/participants/functions';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
import styles from './styles';
export interface IProps {
/**
* True if the hand is raised for this participant.
*/
_raisedHand?: boolean;
/**
* The participant id who we want to render the raised hand indicator
* for.
*/
participantId: string;
}
/**
* Thumbnail badge showing that the participant would like to speak.
*
* @augments Component
*/
class RaisedHandIndicator extends Component<IProps> {
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
if (!this.props._raisedHand) {
return null;
}
return this._renderIndicator();
}
/**
* Renders the platform specific indicator element.
*
* @returns {React$Element<*>}
*/
_renderIndicator() {
return (
<View style = { styles.raisedHandIndicator as ViewStyle }>
<BaseIndicator
icon = { IconRaiseHand }
iconStyle = { styles.raisedHandIcon } />
</View>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {Object}
*/
function _mapStateToProps(state: IReduxState, ownProps: IProps) {
const participant = getParticipantById(state, ownProps.participantId);
return {
_raisedHand: hasRaisedHand(participant)
};
}
export default connect(_mapStateToProps)(RaisedHandIndicator);

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { IconScreenshare } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
/**
* Thumbnail badge for displaying if a participant is sharing their screen.
*
* @returns {React$Element<any>}
*/
export default function ScreenShareIndicator() {
return (
<BaseIndicator icon = { IconScreenshare } />
);
}

View File

@@ -0,0 +1,444 @@
import React, { PureComponent } from 'react';
import { Image, ImageStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
import { MEDIA_TYPE, VIDEO_TYPE } from '../../../base/media/constants';
import { pinParticipant } from '../../../base/participants/actions';
import ParticipantView from '../../../base/participants/components/ParticipantView.native';
import { PARTICIPANT_ROLE } from '../../../base/participants/constants';
import {
getLocalParticipant,
getParticipantByIdOrUndefined,
getParticipantCount,
hasRaisedHand,
isEveryoneModerator,
isScreenShareParticipant
} from '../../../base/participants/functions';
import { FakeParticipant } from '../../../base/participants/types';
import Container from '../../../base/react/components/native/Container';
import { StyleType } from '../../../base/styles/functions.any';
import { trackStreamingStatusChanged } from '../../../base/tracks/actions.native';
import {
getTrackByMediaTypeAndParticipant,
getVideoTrackByParticipant
} from '../../../base/tracks/functions.native';
import { ITrack } from '../../../base/tracks/types';
import ConnectionIndicator from '../../../connection-indicator/components/native/ConnectionIndicator';
import DisplayNameLabel from '../../../display-name/components/native/DisplayNameLabel';
import { getGifDisplayMode, getGifForParticipant } from '../../../gifs/functions.native';
import {
showConnectionStatus,
showContextMenuDetails,
showSharedVideoMenu
} from '../../../participants-pane/actions.native';
import { toggleToolboxVisible } from '../../../toolbox/actions.native';
import { shouldDisplayTileView } from '../../../video-layout/functions.native';
import { SQUARE_TILE_ASPECT_RATIO } from '../../constants';
import AudioMutedIndicator from './AudioMutedIndicator';
import ModeratorIndicator from './ModeratorIndicator';
import PinnedIndicator from './PinnedIndicator';
import RaisedHandIndicator from './RaisedHandIndicator';
import ScreenShareIndicator from './ScreenShareIndicator';
import styles, { AVATAR_SIZE } from './styles';
/**
* Thumbnail component's property types.
*/
interface IProps {
/**
* Whether local audio (microphone) is muted or not.
*/
_audioMuted: boolean;
/**
* The type of participant if the participant is fake.
*/
_fakeParticipant?: FakeParticipant;
/**
* URL of GIF sent by this participant, null if there's none.
*/
_gifSrc?: string;
/**
* Indicates whether the participant is screen sharing.
*/
_isScreenShare: boolean;
/**
* Indicates whether the thumbnail is for a virtual screenshare participant.
*/
_isVirtualScreenshare: boolean;
/**
* Indicates whether the participant is local.
*/
_local?: boolean;
/**
* Shared video local participant owner.
*/
_localVideoOwner: boolean;
/**
* 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 correspond to any of the existing participants.
*/
_participantId: string;
/**
* Indicates whether the participant is pinned or not.
*/
_pinned?: boolean;
/**
* Whether or not the participant has the hand raised.
*/
_raisedHand: boolean;
/**
* Whether to show the dominant speaker indicator or not.
*/
_renderDominantSpeakerIndicator?: boolean;
/**
* Whether to show the moderator indicator or not.
*/
_renderModeratorIndicator: boolean;
_shouldDisplayTileView: boolean;
/**
* The video track that will be displayed in the thumbnail.
*/
_videoTrack?: ITrack;
/**
* Invoked to trigger state changes in Redux.
*/
dispatch: IStore['dispatch'];
/**
* The height of the thumbnail.
*/
height?: number;
/**
* The ID of the participant related to the thumbnail.
*/
participantID?: string;
/**
* Whether to display or hide the display name of the participant in the thumbnail.
*/
renderDisplayName?: boolean;
/**
* If true, it tells the thumbnail that it needs to behave differently. E.g. React differently to a single tap.
*/
tileView?: boolean;
}
/**
* React component for video thumbnail.
*/
class Thumbnail extends PureComponent<IProps> {
/**
* Creates new Thumbnail component.
*
* @param {IProps} props - The props of the component.
* @returns {Thumbnail}
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
this._onThumbnailLongPress = this._onThumbnailLongPress.bind(this);
this.handleTrackStreamingStatusChanged = this.handleTrackStreamingStatusChanged.bind(this);
}
/**
* Thumbnail click handler.
*
* @returns {void}
*/
_onClick() {
const { _participantId, _pinned, dispatch, tileView } = this.props;
if (tileView) {
dispatch(toggleToolboxVisible());
} else {
dispatch(pinParticipant(_pinned ? null : _participantId));
}
}
/**
* Thumbnail long press handler.
*
* @returns {void}
*/
_onThumbnailLongPress() {
const { _fakeParticipant, _participantId, _local, _localVideoOwner, dispatch } = this.props;
if (_fakeParticipant && _localVideoOwner) {
dispatch(showSharedVideoMenu(_participantId));
} else if (!_fakeParticipant) {
if (_local) {
dispatch(showConnectionStatus(_participantId));
} else {
dispatch(showContextMenuDetails(_participantId));
}
} // else no-op
}
/**
* Renders the indicators for the thumbnail.
*
* @returns {ReactElement}
*/
_renderIndicators() {
const {
_audioMuted: audioMuted,
_fakeParticipant,
_isScreenShare: isScreenShare,
_isVirtualScreenshare,
_participantId: participantId,
_pinned,
_renderModeratorIndicator: renderModeratorIndicator,
_shouldDisplayTileView,
renderDisplayName,
tileView
} = this.props;
const indicators = [];
let bottomIndicatorsContainerStyle;
if (_shouldDisplayTileView) {
bottomIndicatorsContainerStyle = styles.bottomIndicatorsContainer;
} else if (audioMuted || renderModeratorIndicator) {
bottomIndicatorsContainerStyle = styles.bottomIndicatorsContainer;
} else {
bottomIndicatorsContainerStyle = null;
}
if (!_fakeParticipant || _isVirtualScreenshare) {
indicators.push(<View
key = 'top-left-indicators'
style = { styles.thumbnailTopLeftIndicatorContainer as ViewStyle }>
{ !_isVirtualScreenshare && <ConnectionIndicator participantId = { participantId } /> }
{ !_isVirtualScreenshare && <RaisedHandIndicator participantId = { participantId } /> }
{ tileView && (isScreenShare || _isVirtualScreenshare) && (
<View style = { styles.screenShareIndicatorContainer as ViewStyle }>
<ScreenShareIndicator />
</View>
) }
</View>);
indicators.push(<Container
key = 'bottom-indicators'
style = { styles.thumbnailIndicatorContainer as StyleType }>
<Container
style = { bottomIndicatorsContainerStyle as StyleType }>
{ audioMuted && !_isVirtualScreenshare && <AudioMutedIndicator /> }
{ !tileView && _pinned && <PinnedIndicator />}
{ renderModeratorIndicator && !_isVirtualScreenshare && <ModeratorIndicator />}
{ !tileView && (isScreenShare || _isVirtualScreenshare) && <ScreenShareIndicator /> }
</Container>
{
renderDisplayName && <DisplayNameLabel
contained = { true }
participantId = { participantId } />
}
</Container>);
}
return indicators;
}
/**
* Starts listening for track streaming status updates after the initial render.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
// Listen to track streaming status changed event to keep it updated.
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
/**
* Stops listening for track streaming status updates on the old track and starts listening instead on the new
* track.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (prevProps._videoTrack?.jitsiTrack?.getSourceName() !== _videoTrack?.jitsiTrack?.getSourceName()) {
if (prevProps._videoTrack && !prevProps._videoTrack.local) {
prevProps._videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(prevProps._videoTrack.jitsiTrack,
prevProps._videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.on(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
}
/**
* Remove listeners for track streaming status update.
*
* @inheritdoc
* @returns {void}
*/
override componentWillUnmount() {
// TODO: after converting this component to a react function component,
// use a custom hook to update local track streaming status.
const { _videoTrack, dispatch } = this.props;
if (_videoTrack && !_videoTrack.local) {
_videoTrack.jitsiTrack.off(JitsiTrackEvents.TRACK_STREAMING_STATUS_CHANGED,
this.handleTrackStreamingStatusChanged);
dispatch(trackStreamingStatusChanged(_videoTrack.jitsiTrack,
_videoTrack.jitsiTrack.getTrackStreamingStatus()));
}
}
/**
* Handle track streaming status change event by by dispatching an action to update track streaming status for the
* given track in app state.
*
* @param {JitsiTrack} jitsiTrack - The track with streaming status updated.
* @param {JitsiTrackStreamingStatus} streamingStatus - The updated track streaming status.
* @returns {void}
*/
handleTrackStreamingStatusChanged(jitsiTrack: any, streamingStatus: string) {
this.props.dispatch(trackStreamingStatusChanged(jitsiTrack, streamingStatus));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_fakeParticipant,
_gifSrc,
_isScreenShare: isScreenShare,
_isVirtualScreenshare,
_participantId: participantId,
_raisedHand,
_renderDominantSpeakerIndicator,
height,
tileView
} = this.props;
const styleOverrides = tileView ? {
aspectRatio: SQUARE_TILE_ASPECT_RATIO,
flex: 0,
height,
maxHeight: null,
maxWidth: null,
width: null
} : null;
return (
<Container
onClick = { this._onClick }
onLongPress = { this._onThumbnailLongPress }
style = { [
styles.thumbnail,
styleOverrides,
_raisedHand && !_isVirtualScreenshare ? styles.thumbnailRaisedHand : null,
_renderDominantSpeakerIndicator && !_isVirtualScreenshare ? styles.thumbnailDominantSpeaker : null
] as StyleType[] }
touchFeedback = { false }>
{ _gifSrc ? <Image
source = {{ uri: _gifSrc }}
style = { styles.thumbnailGif as ImageStyle } />
: <>
<ParticipantView
avatarSize = { tileView ? AVATAR_SIZE * 1.5 : AVATAR_SIZE }
disableVideo = { !tileView && (isScreenShare || _fakeParticipant) }
participantId = { participantId }
zOrder = { 1 } />
{
this._renderIndicators()
}
</>
}
</Container>
);
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {IProps} ownProps - Properties of component.
* @returns {Object}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { ownerId } = state['features/shared-video'];
const tracks = state['features/base/tracks'];
const { participantID, tileView } = ownProps;
const participant = getParticipantByIdOrUndefined(state, participantID);
const localParticipantId = getLocalParticipant(state)?.id;
const id = participant?.id;
const audioTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, id);
const videoTrack = getVideoTrackByParticipant(state, participant);
const isScreenShare = videoTrack?.videoType === VIDEO_TYPE.DESKTOP;
const participantCount = getParticipantCount(state);
const renderDominantSpeakerIndicator = participant?.dominantSpeaker && participantCount > 2;
const _isEveryoneModerator = isEveryoneModerator(state);
const renderModeratorIndicator = tileView && !_isEveryoneModerator
&& participant?.role === PARTICIPANT_ROLE.MODERATOR;
const { gifUrl: gifSrc } = getGifForParticipant(state, id ?? '');
const mode = getGifDisplayMode(state);
return {
_audioMuted: audioTrack?.muted ?? true,
_fakeParticipant: participant?.fakeParticipant,
_gifSrc: mode === 'chat' ? undefined : gifSrc,
_isScreenShare: isScreenShare,
_isVirtualScreenshare: isScreenShareParticipant(participant),
_local: participant?.local,
_localVideoOwner: Boolean(ownerId === localParticipantId),
_participantId: id ?? '',
_pinned: participant?.pinned,
_raisedHand: hasRaisedHand(participant),
_renderDominantSpeakerIndicator: renderDominantSpeakerIndicator,
_renderModeratorIndicator: renderModeratorIndicator,
_shouldDisplayTileView: shouldDisplayTileView(state),
_videoTrack: videoTrack
};
}
export default connect(_mapStateToProps)(Thumbnail);

View File

@@ -0,0 +1,299 @@
import React, { PureComponent } from 'react';
import {
FlatList,
GestureResponderEvent,
SafeAreaView,
TouchableWithoutFeedback,
ViewToken
} from 'react-native';
import { EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants/functions';
import { ILocalParticipant } from '../../../base/participants/types';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { setVisibleRemoteParticipants } from '../../actions.web';
import Thumbnail from './Thumbnail';
import styles from './styles';
/**
* The type of the React {@link Component} props of {@link TileView}.
*/
interface IProps {
/**
* Application's aspect ratio.
*/
_aspectRatio: Symbol;
/**
* The number of columns.
*/
_columns: number;
/**
* Whether or not to hide the self view.
*/
_disableSelfView: boolean;
/**
* Application's viewport height.
*/
_height: number;
/**
* The local participant.
*/
_localParticipant?: ILocalParticipant;
/**
* The number of participants in the conference.
*/
_participantCount: number;
/**
* An array with the IDs of the remote participants in the conference.
*/
_remoteParticipants: Array<string>;
/**
* The thumbnail height.
*/
_thumbnailHeight?: number;
/**
* Application's viewport height.
*/
_width: number;
/**
* Invoked to update the receiver video quality.
*/
dispatch: IStore['dispatch'];
/**
* Object containing the safe area insets.
*/
insets: EdgeInsets;
/**
* Callback to invoke when tile view is tapped.
*/
onClick: (e?: GestureResponderEvent) => void;
}
/**
* An empty array. The purpose of the constant is to use the same reference every time we need an empty array.
* This will prevent unnecessary re-renders.
*/
const EMPTY_ARRAY: any[] = [];
/**
* Implements a React {@link PureComponent} which displays thumbnails in a two
* dimensional grid.
*
* @augments PureComponent
*/
class TileView extends PureComponent<IProps> {
/**
* The styles for the content container of the FlatList.
*/
_contentContainerStyles: any;
/**
* The styles for the FlatList.
*/
_flatListStyles: any;
/**
* The FlatList's viewabilityConfig.
*/
_viewabilityConfig: Object;
/**
* Creates new TileView component.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._keyExtractor = this._keyExtractor.bind(this);
this._onViewableItemsChanged = this._onViewableItemsChanged.bind(this);
this._renderThumbnail = this._renderThumbnail.bind(this);
this._viewabilityConfig = {
itemVisiblePercentThreshold: 30,
minimumViewTime: 500
};
this._flatListStyles = {
...styles.flatListTileView
};
this._contentContainerStyles = {
...styles.contentContainer,
paddingBottom: this.props.insets?.bottom || 0
};
}
/**
* Returns a key for a passed item of the list.
*
* @param {string} item - The user ID.
* @returns {string} - The user ID.
*/
_keyExtractor(item: string) {
return item;
}
/**
* A handler for visible items changes.
*
* @param {Object} data - The visible items data.
* @param {Array<Object>} data.viewableItems - The visible items array.
* @returns {void}
*/
_onViewableItemsChanged({ viewableItems = [] }: { viewableItems: ViewToken[]; }) {
const { _disableSelfView } = this.props;
if (viewableItems[0]?.index === 0 && !_disableSelfView) {
// Skip the local thumbnail.
viewableItems.shift();
}
if (viewableItems.length === 0) {
// User might be fast-scrolling, it will stabilize.
return;
}
// We are off by one in the remote participants array.
const startIndex = Number(viewableItems[0].index) - (_disableSelfView ? 0 : 1);
const endIndex = Number(viewableItems[viewableItems.length - 1].index) - (_disableSelfView ? 0 : 1);
this.props.dispatch(setVisibleRemoteParticipants(startIndex, endIndex));
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _columns, _height, _thumbnailHeight, _width, onClick } = this.props;
const participants = this._getSortedParticipants();
const initialRowsToRender = Math.ceil(_height / (Number(_thumbnailHeight) + (2 * styles.thumbnail.margin)));
if (this._flatListStyles.minHeight !== _height || this._flatListStyles.minWidth !== _width) {
this._flatListStyles = {
...styles.flatListTileView,
minHeight: _height,
minWidth: _width
};
}
if (this._contentContainerStyles.minHeight !== _height || this._contentContainerStyles.minWidth !== _width) {
this._contentContainerStyles = {
...styles.contentContainer,
minHeight: _height,
minWidth: _width,
paddingBottom: this.props.insets?.bottom || 0
};
}
return (
<TouchableWithoutFeedback onPress = { onClick }>
<SafeAreaView style = { styles.flatListContainer }>
<FlatList
bounces = { false }
contentContainerStyle = { this._contentContainerStyles }
data = { participants }
horizontal = { false }
initialNumToRender = { initialRowsToRender }
key = { _columns }
keyExtractor = { this._keyExtractor }
numColumns = { _columns }
onViewableItemsChanged = { this._onViewableItemsChanged }
renderItem = { this._renderThumbnail }
showsHorizontalScrollIndicator = { false }
showsVerticalScrollIndicator = { false }
style = { this._flatListStyles }
viewabilityConfig = { this._viewabilityConfig }
windowSize = { 2 } />
</SafeAreaView>
</TouchableWithoutFeedback>
);
}
/**
* Returns all participants with the local participant at the end.
*
* @private
* @returns {Participant[]}
*/
_getSortedParticipants() {
const { _localParticipant, _remoteParticipants, _disableSelfView } = this.props;
if (!_localParticipant) {
return EMPTY_ARRAY;
}
if (_disableSelfView) {
return _remoteParticipants;
}
return [ _localParticipant?.id, ..._remoteParticipants ];
}
/**
* Creates React Element to display each participant in a thumbnail.
*
* @private
* @returns {ReactElement}
*/
_renderThumbnail({ item }: { item: string; }) {
const { _thumbnailHeight } = this.props;
return (
<Thumbnail
height = { _thumbnailHeight }
key = { item }
participantID = { item }
renderDisplayName = { true }
tileView = { true } />)
;
}
}
/**
* Maps (parts of) the redux state to the associated {@code TileView}'s props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - Component props.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const responsiveUi = state['features/base/responsive-ui'];
const { remoteParticipants, tileViewDimensions } = state['features/filmstrip'];
const disableSelfView = getHideSelfView(state);
const { height } = tileViewDimensions?.thumbnailSize ?? {};
const { columns } = tileViewDimensions ?? {};
return {
_aspectRatio: responsiveUi.aspectRatio,
_columns: columns ?? 1,
_disableSelfView: disableSelfView,
_height: responsiveUi.clientHeight - (ownProps.insets?.top || 0),
_insets: ownProps.insets,
_localParticipant: getLocalParticipant(state),
_participantCount: getParticipantCountWithFake(state),
_remoteParticipants: remoteParticipants,
_thumbnailHeight: height,
_width: responsiveUi.clientWidth - (ownProps.insets?.right || 0) - (ownProps.insets?.left || 0)
};
}
export default withSafeAreaInsets(connect(_mapStateToProps)(TileView));

View File

@@ -0,0 +1,182 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import { SMALL_THUMBNAIL_SIZE } from '../../constants';
/**
* Size for the Avatar.
*/
export const AVATAR_SIZE = 50;
const indicatorContainer = {
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: BaseTheme.shape.borderRadius,
height: 24,
margin: 2,
padding: 2
};
/**
* The styles of the feature filmstrip.
*/
export default {
/**
* The FlatList content container styles.
*/
contentContainer: {
alignItems: 'center',
justifyContent: 'center',
flex: 0
},
/**
* The display name container.
*/
displayNameContainer: {
padding: 2
},
/**
* The style of the narrow {@link Filmstrip} version which displays
* thumbnails in a row at the bottom of the screen.
*/
filmstripNarrow: {
flexDirection: 'row',
flexGrow: 0,
justifyContent: 'flex-end',
margin: 6
},
/**
* The style of the wide {@link Filmstrip} version which displays thumbnails
* in a column on the short size of the screen.
*
* NOTE: width is calculated based on the children, but it should also align
* to {@code FILMSTRIP_SIZE}.
*/
filmstripWide: {
bottom: BaseTheme.spacing[0],
flexDirection: 'column',
flexGrow: 0,
position: 'absolute',
right: BaseTheme.spacing[0],
top: BaseTheme.spacing[0]
},
/**
* The styles for the FlatList container.
*/
flatListContainer: {
flexGrow: 1,
flexShrink: 1,
flex: 0
},
/**
* The styles for the FlatList component in stage view.
*/
flatListStageView: {
flexGrow: 0
},
/**
* The styles for the FlatList component in tile view.
*/
flatListTileView: {
flex: 0
},
/**
* Container of the {@link LocalThumbnail}.
*/
localThumbnail: {
alignContent: 'stretch',
alignSelf: 'stretch',
aspectRatio: 1,
flexShrink: 0,
flexDirection: 'row'
},
/**
* The style of a participant's Thumbnail which renders either the video or
* the avatar of the associated participant.
*/
thumbnail: {
alignItems: 'stretch',
backgroundColor: BaseTheme.palette.ui02,
borderColor: BaseTheme.palette.ui03,
borderRadius: BaseTheme.shape.borderRadius,
borderStyle: 'solid',
borderWidth: 1,
flex: 1,
height: SMALL_THUMBNAIL_SIZE,
justifyContent: 'center',
margin: 2,
maxHeight: SMALL_THUMBNAIL_SIZE,
maxWidth: SMALL_THUMBNAIL_SIZE,
overflow: 'hidden',
position: 'relative',
width: SMALL_THUMBNAIL_SIZE
},
indicatorContainer: {
...indicatorContainer
},
screenShareIndicatorContainer: {
...indicatorContainer
},
/**
* The thumbnail indicator container.
*/
thumbnailIndicatorContainer: {
...indicatorContainer,
bottom: 3,
flex: 1,
flexDirection: 'row',
left: 3,
position: 'absolute',
maxWidth: '95%',
overflow: 'hidden',
padding: BaseTheme.spacing[0]
},
bottomIndicatorsContainer: {
flexDirection: 'row',
padding: BaseTheme.spacing[1]
},
thumbnailTopLeftIndicatorContainer: {
...indicatorContainer,
backgroundColor: 'unset',
flexDirection: 'row',
position: 'absolute',
top: BaseTheme.spacing[1]
},
raisedHandIndicator: {
...indicatorContainer,
backgroundColor: BaseTheme.palette.warning02
},
raisedHandIcon: {
color: BaseTheme.palette.uiBackground
},
thumbnailRaisedHand: {
borderWidth: 2,
borderColor: BaseTheme.palette.warning02
},
thumbnailDominantSpeaker: {
borderWidth: 2,
borderColor: BaseTheme.palette.action01Hover
},
thumbnailGif: {
flexGrow: 1,
resizeMode: 'contain'
}
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { IconMicSlash } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/web/BaseIndicator';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
/**
* The type of the React {@code Component} props of {@link AudioMutedIndicator}.
*/
interface IProps {
/**
* From which side of the indicator the tooltip should appear from.
*/
tooltipPosition: TOOLTIP_POSITION;
}
/**
* React {@code Component} for showing an audio muted icon with a tooltip.
*
* @returns {Component}
*/
const AudioMutedIndicator = ({ tooltipPosition }: IProps) => (
<BaseIndicator
icon = { IconMicSlash }
iconId = 'mic-disabled'
iconSize = { 16 }
id = 'audioMuted'
tooltipKey = 'videothumbnail.mute'
tooltipPosition = { tooltipPosition } />
);
export default AudioMutedIndicator;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import AudioTrack from '../../../base/media/components/web/AudioTrack';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { ITrack } from '../../../base/tracks/types';
/**
* The type of the React {@code Component} props of {@link AudioTracksContainer}.
*/
interface IProps {
/**
* All media tracks stored in redux.
*/
_tracks: ITrack[];
}
/**
* A container for the remote tracks audio elements.
*
* @param {IProps} props - The props of the component.
* @returns {Array<ReactElement>}
*/
function AudioTracksContainer(props: IProps) {
const { _tracks } = props;
const remoteAudioTracks = _tracks.filter(t => !t.local && t.mediaType === MEDIA_TYPE.AUDIO);
return (
<div>
{
remoteAudioTracks.map(t => {
const { jitsiTrack, participantId } = t;
const audioTrackId = jitsiTrack?.getId();
const id = `remoteAudio_${audioTrackId || ''}`;
return (
<AudioTrack
audioTrack = { t }
id = { id }
key = { id }
participantId = { participantId } />
);
})
}
</div>
);
}
/**
* Maps (parts of) the Redux state to the associated {@code AudioTracksContainer}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
// 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 through the array. Introducing a map participantID -> track could be beneficial in this case.
return {
_tracks: state['features/base/tracks']
};
}
export default connect(_mapStateToProps)(AudioTracksContainer);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,209 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { LAYOUTS } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import {
ASPECT_RATIO_BREAKPOINT,
FILMSTRIP_BREAKPOINT,
FILMSTRIP_BREAKPOINT_OFFSET,
FILMSTRIP_TYPE,
TOOLBAR_HEIGHT,
TOOLBAR_HEIGHT_MOBILE } from '../../constants';
import { isFilmstripResizable, showGridInVerticalView } from '../../functions.web';
import Filmstrip from './Filmstrip';
interface IProps {
/**
* The number of columns in tile view.
*/
_columns: number;
/**
* The height of the filmstrip.
*/
_filmstripHeight?: number;
/**
* The width of the filmstrip.
*/
_filmstripWidth?: number;
/**
* Whether the filmstrip has scroll or not.
*/
_hasScroll: boolean;
/**
* Whether or not the current layout is vertical filmstrip.
*/
_isVerticalFilmstrip: boolean;
/**
* The participants in the call.
*/
_remoteParticipants: Array<Object>;
/**
* The length of the remote participants array.
*/
_remoteParticipantsLength: number;
/**
* Whether or not the filmstrip should be user-resizable.
*/
_resizableFilmstrip: boolean;
/**
* The number of rows in tile view.
*/
_rows: number;
/**
* The height of the thumbnail.
*/
_thumbnailHeight?: number;
/**
* The width of the thumbnail.
*/
_thumbnailWidth?: number;
/**
* Whether or not the vertical filmstrip should have a background color.
*/
_verticalViewBackground: boolean;
/**
* Whether or not the vertical filmstrip should be displayed as grid.
*/
_verticalViewGrid: boolean;
/**
* Additional CSS class names to add to the container of all the thumbnails.
*/
_videosClassName: string;
}
const MainFilmstrip = (props: IProps) => (
<span>
<Filmstrip
{ ...props }
filmstripType = { FILMSTRIP_TYPE.MAIN } />
</span>
);
/**
* Maps (parts of) the Redux state to the associated {@code Filmstrip}'s props.
*
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Components' own props.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { toolbarButtons } = state['features/toolbox'];
const { remoteParticipants, width: verticalFilmstripWidth } = state['features/filmstrip'];
const reduceHeight = state['features/toolbox'].visible && toolbarButtons?.length;
const {
gridDimensions: dimensions = { columns: undefined,
rows: undefined },
filmstripHeight,
filmstripWidth,
hasScroll: tileViewHasScroll,
thumbnailSize: tileViewThumbnailSize
} = state['features/filmstrip'].tileViewDimensions ?? {};
const _currentLayout = getCurrentLayout(state);
const _resizableFilmstrip = isFilmstripResizable(state);
const _verticalViewGrid = showGridInVerticalView(state);
let gridDimensions = dimensions;
let _hasScroll = false;
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const availableSpace = clientHeight - Number(filmstripHeight);
let filmstripPadding = 0;
if (availableSpace > 0) {
const paddingValue = TOOLBAR_HEIGHT_MOBILE - availableSpace;
if (paddingValue > 0) {
filmstripPadding = paddingValue;
}
} else {
filmstripPadding = TOOLBAR_HEIGHT_MOBILE;
}
const collapseTileView = reduceHeight
&& isMobileBrowser()
&& videoSpaceWidth <= ASPECT_RATIO_BREAKPOINT;
const shouldReduceHeight = reduceHeight && (
isMobileBrowser() || (_currentLayout !== LAYOUTS.VERTICAL_FILMSTRIP_VIEW
&& _currentLayout !== LAYOUTS.STAGE_FILMSTRIP_VIEW));
let _thumbnailSize, remoteFilmstripHeight, remoteFilmstripWidth;
switch (_currentLayout) {
case LAYOUTS.TILE_VIEW:
_hasScroll = Boolean(tileViewHasScroll);
_thumbnailSize = tileViewThumbnailSize;
remoteFilmstripHeight = Number(filmstripHeight) - (
collapseTileView && filmstripPadding > 0 ? filmstripPadding : 0);
remoteFilmstripWidth = filmstripWidth;
break;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
case LAYOUTS.STAGE_FILMSTRIP_VIEW: {
const {
remote,
remoteVideosContainer,
gridView,
hasScroll
} = state['features/filmstrip'].verticalViewDimensions;
_hasScroll = Boolean(hasScroll);
remoteFilmstripHeight = Number(remoteVideosContainer?.height) - (!_verticalViewGrid && shouldReduceHeight
? TOOLBAR_HEIGHT : 0);
remoteFilmstripWidth = remoteVideosContainer?.width;
if (_verticalViewGrid) {
gridDimensions = gridView?.gridDimensions ?? { columns: undefined,
rows: undefined };
_thumbnailSize = gridView?.thumbnailSize;
_hasScroll = Boolean(gridView?.hasScroll);
} else {
_thumbnailSize = remote;
}
break;
}
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: {
const { remote, remoteVideosContainer, hasScroll } = state['features/filmstrip'].horizontalViewDimensions;
_hasScroll = Boolean(hasScroll);
_thumbnailSize = remote;
remoteFilmstripHeight = remoteVideosContainer?.height;
remoteFilmstripWidth = remoteVideosContainer?.width;
break;
}
}
return {
_columns: gridDimensions.columns ?? 1,
_filmstripHeight: remoteFilmstripHeight,
_filmstripWidth: remoteFilmstripWidth,
_hasScroll,
_remoteParticipants: remoteParticipants,
_resizableFilmstrip,
_rows: gridDimensions.rows ?? 1,
_thumbnailWidth: _thumbnailSize?.width,
_thumbnailHeight: _thumbnailSize?.height,
_verticalViewGrid,
_verticalViewBackground: Number(verticalFilmstripWidth.current)
+ FILMSTRIP_BREAKPOINT_OFFSET >= FILMSTRIP_BREAKPOINT
};
}
export default connect(_mapStateToProps)(MainFilmstrip);

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { IconModerator } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/web/BaseIndicator';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
/**
* The type of the React {@code Component} props of {@link ModeratorIndicator}.
*/
interface IProps {
/**
* From which side of the indicator the tooltip should appear from.
*/
tooltipPosition: TOOLTIP_POSITION;
}
/**
* React {@code Component} for showing a moderator icon with a tooltip.
*
* @returns {JSX.Element}
*/
const ModeratorIndicator = ({ tooltipPosition }: IProps): JSX.Element => (
<BaseIndicator
icon = { IconModerator }
iconSize = { 16 }
tooltipKey = 'videothumbnail.moderator'
tooltipPosition = { tooltipPosition } />
);
export default ModeratorIndicator;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { IconPin } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import BaseIndicator from '../../../base/react/components/web/BaseIndicator';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
import { getPinnedActiveParticipants, isStageFilmstripAvailable } from '../../functions.web';
/**
* The type of the React {@code Component} props of {@link PinnedIndicator}.
*/
interface IProps {
/**
* The font-size for the icon.
*/
iconSize: number;
/**
* The participant id who we want to render the raised hand indicator
* for.
*/
participantId: string;
/**
* From which side of the indicator the tooltip should appear from.
*/
tooltipPosition: TOOLTIP_POSITION;
}
const useStyles = makeStyles()(() => {
return {
pinnedIndicator: {
backgroundColor: 'rgba(0, 0, 0, .7)',
padding: '4px',
zIndex: 3,
display: 'inline-block',
borderRadius: '4px',
boxSizing: 'border-box'
}
};
});
/**
* Thumbnail badge showing that the participant would like to speak.
*
* @returns {ReactElement}
*/
const PinnedIndicator = ({
iconSize,
participantId,
tooltipPosition
}: IProps) => {
const stageFilmstrip = useSelector(isStageFilmstripAvailable);
const pinned = useSelector((state: IReduxState) => getParticipantById(state, participantId))?.pinned;
const activePinnedParticipants: Array<{ participantId: string; pinned?: boolean; }>
= useSelector(getPinnedActiveParticipants);
const isPinned = activePinnedParticipants.find(p => p.participantId === participantId);
const { classes: styles } = useStyles();
if ((stageFilmstrip && !isPinned) || (!stageFilmstrip && !pinned)) {
return null;
}
return (
<div
className = { styles.pinnedIndicator }
id = { `pin-indicator-${participantId}` }>
<BaseIndicator
icon = { IconPin }
iconSize = { iconSize }
tooltipKey = 'pinnedParticipant'
tooltipPosition = { tooltipPosition } />
</div>
);
};
export default PinnedIndicator;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { IconRaiseHand } from '../../../base/icons/svg';
import { getParticipantById, hasRaisedHand } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import BaseIndicator from '../../../base/react/components/web/BaseIndicator';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
/**
* The type of the React {@code Component} props of {@link RaisedHandIndicator}.
*/
interface IProps {
/**
* The font-size for the icon.
*/
iconSize: number;
/**
* The participant id who we want to render the raised hand indicator
* for.
*/
participantId: string;
/**
* From which side of the indicator the tooltip should appear from.
*/
tooltipPosition: TOOLTIP_POSITION;
}
const useStyles = makeStyles()(theme => {
return {
raisedHandIndicator: {
backgroundColor: theme.palette.warning02,
padding: '4px',
zIndex: 3,
display: 'inline-block',
borderRadius: '4px',
boxSizing: 'border-box'
}
};
});
/**
* Thumbnail badge showing that the participant would like to speak.
*
* @returns {ReactElement}
*/
const RaisedHandIndicator = ({
iconSize,
participantId,
tooltipPosition
}: IProps) => {
const participant: IParticipant | undefined = useSelector((state: IReduxState) =>
getParticipantById(state, participantId));
const _raisedHand = hasRaisedHand(participant);
const { classes: styles, theme } = useStyles();
if (!_raisedHand) {
return null;
}
return (
<div className = { styles.raisedHandIndicator }>
<BaseIndicator
icon = { IconRaiseHand }
iconColor = { theme.palette.uiBackground }
iconSize = { iconSize }
tooltipKey = 'raisedHand'
tooltipPosition = { tooltipPosition } />
</div>
);
};
export default RaisedHandIndicator;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { IconScreenshare } from '../../../base/icons/svg';
import BaseIndicator from '../../../base/react/components/web/BaseIndicator';
import { TOOLTIP_POSITION } from '../../../base/ui/constants.any';
interface IProps {
/**
* From which side of the indicator the tooltip should appear from.
*/
tooltipPosition: TOOLTIP_POSITION;
}
/**
* React {@code Component} for showing a screen-sharing icon with a tooltip.
*
* @param {IProps} props - React props passed to this component.
* @returns {React$Element<any>}
*/
export default function ScreenShareIndicator(props: IProps) {
return (
<BaseIndicator
icon = { IconScreenshare }
iconId = 'share-desktop'
iconSize = { 16 }
tooltipKey = 'videothumbnail.screenSharing'
tooltipPosition = { props.tooltipPosition } />
);
}

View File

@@ -0,0 +1,132 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { LAYOUTS, LAYOUT_CLASSNAMES } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import {
FILMSTRIP_TYPE
} from '../../constants';
import { getScreenshareFilmstripParticipantId } from '../../functions.web';
import Filmstrip from './Filmstrip';
interface IProps {
/**
* The number of columns in tile view.
*/
_columns: number;
/**
* The current layout of the filmstrip.
*/
_currentLayout?: string;
/**
* The height of the filmstrip.
*/
_filmstripHeight?: number;
/**
* The width of the filmstrip.
*/
_filmstripWidth?: number;
/**
* Whether or not the current layout is vertical filmstrip.
*/
_isVerticalFilmstrip: boolean;
/**
* The participants in the call.
*/
_remoteParticipants: Array<Object>;
/**
* The length of the remote participants array.
*/
_remoteParticipantsLength: number;
/**
* Whether or not the filmstrip should be user-resizable.
*/
_resizableFilmstrip: boolean;
/**
* The number of rows in tile view.
*/
_rows: number;
/**
* The height of the thumbnail.
*/
_thumbnailHeight?: number;
/**
* The width of the thumbnail.
*/
_thumbnailWidth?: number;
/**
* Whether or not the vertical filmstrip should have a background color.
*/
_verticalViewBackground: boolean;
/**
* Whether or not the vertical filmstrip should be displayed as grid.
*/
_verticalViewGrid: boolean;
/**
* Additional CSS class names to add to the container of all the thumbnails.
*/
_videosClassName: string;
}
// eslint-disable-next-line no-confusing-arrow
const ScreenshareFilmstrip = (props: IProps) =>
props._currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW
&& props._remoteParticipants.length === 1 ? (
<span className = { LAYOUT_CLASSNAMES[LAYOUTS.TILE_VIEW] }>
<Filmstrip
{ ...props }
filmstripType = { FILMSTRIP_TYPE.SCREENSHARE } />
</span>
) : null
;
/**
* Maps (parts of) the Redux state to the associated {@code Filmstrip}'s props.
*
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Components' own props.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const {
screenshareFilmstripDimensions: {
filmstripHeight,
filmstripWidth,
thumbnailSize
}
} = state['features/filmstrip'];
const id = getScreenshareFilmstripParticipantId(state);
return {
_columns: 1,
_currentLayout: getCurrentLayout(state),
_filmstripHeight: filmstripHeight,
_filmstripWidth: filmstripWidth,
_remoteParticipants: id ? [ id ] : [],
_resizableFilmstrip: false,
_rows: 1,
_thumbnailWidth: thumbnailSize?.width,
_thumbnailHeight: thumbnailSize?.height,
_verticalViewGrid: false,
_verticalViewBackground: false
};
}
export default connect(_mapStateToProps)(ScreenshareFilmstrip);

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { LAYOUTS, LAYOUT_CLASSNAMES } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import {
ASPECT_RATIO_BREAKPOINT,
FILMSTRIP_TYPE,
TOOLBAR_HEIGHT_MOBILE
} from '../../constants';
import { getActiveParticipantsIds, isFilmstripResizable, isStageFilmstripTopPanel } from '../../functions.web';
import Filmstrip from './Filmstrip';
interface IProps {
/**
* The number of columns in tile view.
*/
_columns: number;
/**
* The current layout of the filmstrip.
*/
_currentLayout?: string;
/**
* The height of the filmstrip.
*/
_filmstripHeight?: number;
/**
* The width of the filmstrip.
*/
_filmstripWidth?: number;
/**
* Whether or not the current layout is vertical filmstrip.
*/
_isVerticalFilmstrip: boolean;
/**
* The participants in the call.
*/
_remoteParticipants: Array<Object>;
/**
* The length of the remote participants array.
*/
_remoteParticipantsLength: number;
/**
* Whether or not the filmstrip should be user-resizable.
*/
_resizableFilmstrip: boolean;
/**
* The number of rows in tile view.
*/
_rows: number;
/**
* The height of the thumbnail.
*/
_thumbnailHeight?: number;
/**
* The width of the thumbnail.
*/
_thumbnailWidth?: number;
/**
* Whether or not the vertical filmstrip should have a background color.
*/
_verticalViewBackground: boolean;
/**
* Whether or not the vertical filmstrip should be displayed as grid.
*/
_verticalViewGrid: boolean;
/**
* Additional CSS class names to add to the container of all the thumbnails.
*/
_videosClassName: string;
}
// eslint-disable-next-line no-confusing-arrow
const StageFilmstrip = (props: IProps) =>
props._currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW ? (
<span className = { LAYOUT_CLASSNAMES[LAYOUTS.TILE_VIEW] }>
<Filmstrip
{ ...props }
filmstripType = { FILMSTRIP_TYPE.STAGE } />
</span>
) : null
;
/**
* Maps (parts of) the Redux state to the associated {@code Filmstrip}'s props.
*
* @param {Object} state - The Redux state.
* @param {any} _ownProps - Components' own props.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { toolbarButtons } = state['features/toolbox'];
const activeParticipants = getActiveParticipantsIds(state);
const reduceHeight = state['features/toolbox'].visible && toolbarButtons?.length;
const {
gridDimensions: dimensions = { columns: undefined,
rows: undefined },
filmstripHeight,
filmstripWidth,
thumbnailSize
} = state['features/filmstrip'].stageFilmstripDimensions;
const gridDimensions = dimensions;
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const availableSpace = clientHeight - Number(filmstripHeight);
let filmstripPadding = 0;
if (availableSpace > 0) {
const paddingValue = TOOLBAR_HEIGHT_MOBILE - availableSpace;
if (paddingValue > 0) {
filmstripPadding = paddingValue;
}
} else {
filmstripPadding = TOOLBAR_HEIGHT_MOBILE;
}
const collapseTileView = reduceHeight
&& isMobileBrowser()
&& videoSpaceWidth <= ASPECT_RATIO_BREAKPOINT;
const remoteFilmstripHeight = Number(filmstripHeight) - (
collapseTileView && filmstripPadding > 0 ? filmstripPadding : 0);
const _topPanelFilmstrip = isStageFilmstripTopPanel(state);
return {
_columns: gridDimensions.columns ?? 1,
_currentLayout: getCurrentLayout(state),
_filmstripHeight: remoteFilmstripHeight,
_filmstripWidth: filmstripWidth,
_remoteParticipants: activeParticipants,
_resizableFilmstrip: isFilmstripResizable(state) && _topPanelFilmstrip,
_rows: gridDimensions.rows ?? 1,
_thumbnailWidth: thumbnailSize?.width,
_thumbnailHeight: thumbnailSize?.height,
_topPanelFilmstrip,
_verticalViewGrid: false,
_verticalViewBackground: false
};
}
export default connect(_mapStateToProps)(StageFilmstrip);

View File

@@ -0,0 +1,123 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { PARTICIPANT_ROLE } from '../../../base/participants/constants';
import { getParticipantByIdOrUndefined, isScreenShareParticipantById } from '../../../base/participants/functions';
import {
getVideoTrackByParticipant,
isLocalTrackMuted,
isRemoteTrackMuted
} from '../../../base/tracks/functions.web';
import { getIndicatorsTooltipPosition } from '../../functions.web';
import AudioMutedIndicator from './AudioMutedIndicator';
import ModeratorIndicator from './ModeratorIndicator';
import ScreenShareIndicator from './ScreenShareIndicator';
/**
* The type of the React {@code Component} props of {@link StatusIndicators}.
*/
interface IProps {
/**
* Indicates if the audio muted indicator should be visible or not.
*/
_showAudioMutedIndicator: Boolean;
/**
* Indicates if the moderator indicator should be visible or not.
*/
_showModeratorIndicator: Boolean;
/**
* Indicates if the screen share indicator should be visible or not.
*/
_showScreenShareIndicator: Boolean;
/**
* The ID of the participant for which the status bar is rendered.
*/
participantID: String;
/**
* The type of thumbnail.
*/
thumbnailType: string;
}
/**
* React {@code Component} for showing the status bar in a thumbnail.
*
* @augments Component
*/
class StatusIndicators extends Component<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_showAudioMutedIndicator,
_showModeratorIndicator,
_showScreenShareIndicator,
thumbnailType
} = this.props;
const tooltipPosition = getIndicatorsTooltipPosition(thumbnailType);
return (
<>
{ _showAudioMutedIndicator && <AudioMutedIndicator tooltipPosition = { tooltipPosition } /> }
{ _showModeratorIndicator && <ModeratorIndicator tooltipPosition = { tooltipPosition } />}
{ _showScreenShareIndicator && <ScreenShareIndicator tooltipPosition = { tooltipPosition } /> }
</>
);
}
}
/**
* Maps (parts of) the Redux state to the associated {@code StatusIndicators}'s props.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {{
* _showAudioMutedIndicator: boolean,
* _showModeratorIndicator: boolean,
* _showScreenShareIndicator: boolean
* }}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantID, audio, moderator, screenshare } = ownProps;
// Only the local participant won't have id for the time when the conference is not yet joined.
const participant = getParticipantByIdOrUndefined(state, participantID);
const tracks = state['features/base/tracks'];
let isAudioMuted = true;
let isScreenSharing = false;
if (participant?.local) {
isAudioMuted = isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO);
} else if (!participant?.fakeParticipant || isScreenShareParticipantById(state, participantID)) {
// remote participants excluding shared video
const track = getVideoTrackByParticipant(state, participant);
isScreenSharing = track?.videoType === 'desktop';
isAudioMuted = isRemoteTrackMuted(tracks, MEDIA_TYPE.AUDIO, participantID);
}
const { disableModeratorIndicator } = state['features/base/config'];
return {
_showAudioMutedIndicator: isAudioMuted && audio,
_showModeratorIndicator:
!disableModeratorIndicator && participant && participant.role === PARTICIPANT_ROLE.MODERATOR && moderator,
_showScreenShareIndicator: isScreenSharing && screenshare
};
}
export default connect(_mapStateToProps)(StatusIndicators);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react';
import AudioLevelIndicator from '../../../audio-level-indicator/components/AudioLevelIndicator';
import JitsiMeetJS from '../../../base/lib-jitsi-meet/_';
import { ITrack } from '../../../base/tracks/types';
const JitsiTrackEvents = JitsiMeetJS.events.track;
interface IProps {
/**
* The audio track related to the participant.
*/
_audioTrack?: ITrack;
}
const ThumbnailAudioIndicator = ({
_audioTrack
}: IProps) => {
const [ audioLevel, setAudioLevel ] = useState(0);
useEffect(() => {
setAudioLevel(0);
if (_audioTrack) {
const { jitsiTrack } = _audioTrack;
jitsiTrack?.on(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, setAudioLevel);
}
return () => {
if (_audioTrack) {
const { jitsiTrack } = _audioTrack;
jitsiTrack?.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, setAudioLevel);
}
};
}, [ _audioTrack ]);
return (
<span className = 'audioindicator-container'>
<AudioLevelIndicator audioLevel = { audioLevel } />
</span>
);
};
export default ThumbnailAudioIndicator;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import {
isDisplayNameVisible,
isNameReadOnly
} from '../../../base/config/functions.any';
import { isScreenShareParticipantById } from '../../../base/participants/functions';
import DisplayName from '../../../display-name/components/web/DisplayName';
import StatusIndicators from './StatusIndicators';
interface IProps {
/**
* Class name for indicators container.
*/
className?: string;
/**
* Whether or not the indicators are for the local participant.
*/
local: boolean;
/**
* Id of the participant for which the component is displayed.
*/
participantId: string;
/**
* Whether or not to show the status indicators.
*/
showStatusIndicators?: boolean;
/**
* The type of thumbnail.
*/
thumbnailType?: string;
}
const useStyles = makeStyles()(() => {
return {
nameContainer: {
display: 'flex',
overflow: 'hidden',
'&>div': {
display: 'flex',
overflow: 'hidden'
}
}
};
});
const ThumbnailBottomIndicators = ({
className,
local,
participantId,
showStatusIndicators = true,
thumbnailType
}: IProps) => {
const { classes: styles, cx } = useStyles();
const _allowEditing = !useSelector(isNameReadOnly);
const _defaultLocalDisplayName = interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME;
const _showDisplayName = useSelector(isDisplayNameVisible);
const isVirtualScreenshareParticipant = useSelector(
(state: IReduxState) => isScreenShareParticipantById(state, participantId)
);
return (<div className = { cx(className, 'bottom-indicators') }>
{
showStatusIndicators && <StatusIndicators
audio = { !isVirtualScreenshareParticipant }
moderator = { true }
participantID = { participantId }
screenshare = { isVirtualScreenshareParticipant }
thumbnailType = { thumbnailType } />
}
{
_showDisplayName && (
<span className = { styles.nameContainer }>
<DisplayName
allowEditing = { local ? _allowEditing : false }
displayNameSuffix = { local ? _defaultLocalDisplayName : '' }
elementID = { local ? 'localDisplayName' : `participant_${participantId}_name` }
participantID = { participantId }
thumbnailType = { thumbnailType } />
</span>
)
}
</div>);
};
export default ThumbnailBottomIndicators;

View File

@@ -0,0 +1,158 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { isScreenShareParticipantById } from '../../../base/participants/functions';
import ConnectionIndicator from '../../../connection-indicator/components/web/ConnectionIndicator';
import { STATS_POPOVER_POSITION, THUMBNAIL_TYPE } from '../../constants';
import { getIndicatorsTooltipPosition } from '../../functions.web';
import PinnedIndicator from './PinnedIndicator';
import RaisedHandIndicator from './RaisedHandIndicator';
import StatusIndicators from './StatusIndicators';
import VideoMenuTriggerButton from './VideoMenuTriggerButton';
interface IProps {
/**
* Whether to hide the connection indicator.
*/
disableConnectionIndicator?: boolean;
/**
* Hide popover callback.
*/
hidePopover?: Function;
/**
* Class name for the status indicators container.
*/
indicatorsClassName?: string;
/**
* Whether or not the thumbnail is hovered.
*/
isHovered: boolean;
/**
* Whether or not the indicators are for the local participant.
*/
local?: boolean;
/**
* Id of the participant for which the component is displayed.
*/
participantId: string;
/**
* Whether popover is visible or not.
*/
popoverVisible?: boolean;
/**
* Show popover callback.
*/
showPopover?: Function;
/**
* The type of thumbnail.
*/
thumbnailType: string;
}
const useStyles = makeStyles()(() => {
return {
container: {
display: 'flex',
'& > *:not(:last-child)': {
marginRight: '4px'
}
}
};
});
const ThumbnailTopIndicators = ({
disableConnectionIndicator,
hidePopover,
indicatorsClassName,
isHovered,
local,
participantId,
popoverVisible,
showPopover,
thumbnailType
}: IProps) => {
const { classes: styles, cx } = useStyles();
const _isMobile = isMobileBrowser();
const { NORMAL = 16 } = interfaceConfig.INDICATOR_FONT_SIZES || {};
const _indicatorIconSize = NORMAL;
const _connectionIndicatorAutoHideEnabled = Boolean(
useSelector((state: IReduxState) => state['features/base/config'].connectionIndicators?.autoHide) ?? true);
const _connectionIndicatorDisabled = _isMobile || disableConnectionIndicator
|| Boolean(useSelector((state: IReduxState) => state['features/base/config'].connectionIndicators?.disabled));
const showConnectionIndicator = isHovered || !_connectionIndicatorAutoHideEnabled;
const isVirtualScreenshareParticipant = useSelector(
(state: IReduxState) => isScreenShareParticipantById(state, participantId)
);
if (isVirtualScreenshareParticipant) {
return (
<div className = { styles.container }>
{!_connectionIndicatorDisabled
&& <ConnectionIndicator
alwaysVisible = { showConnectionIndicator }
enableStatsDisplay = { true }
iconSize = { _indicatorIconSize }
participantId = { participantId }
statsPopoverPosition = { STATS_POPOVER_POSITION[thumbnailType] } />
}
</div>
);
}
const tooltipPosition = getIndicatorsTooltipPosition(thumbnailType);
return (<>
<div className = { styles.container }>
<PinnedIndicator
iconSize = { _indicatorIconSize }
participantId = { participantId }
tooltipPosition = { tooltipPosition } />
{!_connectionIndicatorDisabled
&& <ConnectionIndicator
alwaysVisible = { showConnectionIndicator }
enableStatsDisplay = { true }
iconSize = { _indicatorIconSize }
participantId = { participantId }
statsPopoverPosition = { STATS_POPOVER_POSITION[thumbnailType] } />
}
<RaisedHandIndicator
iconSize = { _indicatorIconSize }
participantId = { participantId }
tooltipPosition = { tooltipPosition } />
{thumbnailType !== THUMBNAIL_TYPE.TILE && (
<div className = { cx(indicatorsClassName, 'top-indicators') }>
<StatusIndicators
participantID = { participantId }
screenshare = { false } />
</div>
)}
</div>
<div className = { styles.container }>
<VideoMenuTriggerButton
hidePopover = { hidePopover }
local = { local }
participantId = { participantId }
popoverVisible = { popoverVisible }
showPopover = { showPopover }
thumbnailType = { thumbnailType }
visible = { isHovered } />
</div>
</>);
};
export default ThumbnailTopIndicators;

View File

@@ -0,0 +1,292 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getLocalParticipant } from '../../../base/participants/functions';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { LAYOUTS } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import { FILMSTRIP_TYPE, TILE_ASPECT_RATIO, TILE_HORIZONTAL_MARGIN } from '../../constants';
import { getActiveParticipantsIds, showGridInVerticalView } from '../../functions.web';
import Thumbnail from './Thumbnail';
/**
* The type of the React {@code Component} props of {@link ThumbnailWrapper}.
*/
interface IProps {
/**
* Whether or not to hide the self view.
*/
_disableSelfView?: boolean;
/**
* The type of filmstrip this thumbnail is displayed in.
*/
_filmstripType?: string;
/**
* The horizontal offset in px for the thumbnail. Used to center the thumbnails in the last row in tile view.
*/
_horizontalOffset?: number;
/**
* Whether or not the thumbnail is a local screen share.
*/
_isLocalScreenShare?: boolean;
/**
* The ID of the participant associated with the Thumbnail.
*/
_participantID?: string;
/**
* The width of the thumbnail. Used for expanding the width of the thumbnails on last row in case
* there is empty space.
*/
_thumbnailWidth?: 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 coming 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<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_disableSelfView,
_filmstripType = FILMSTRIP_TYPE.MAIN,
_isLocalScreenShare = false,
_horizontalOffset = 0,
_participantID,
_thumbnailWidth,
style
} = this.props;
if (typeof _participantID !== 'string') {
return null;
}
if (_participantID === 'local') {
return _disableSelfView ? null : (
<Thumbnail
filmstripType = { _filmstripType }
horizontalOffset = { _horizontalOffset }
key = 'local'
style = { style }
width = { _thumbnailWidth } />);
}
if (_isLocalScreenShare) {
return _disableSelfView ? null : (
<Thumbnail
filmstripType = { _filmstripType }
horizontalOffset = { _horizontalOffset }
key = 'localScreenShare'
participantID = { _participantID }
style = { style }
width = { _thumbnailWidth } />);
}
return (
<Thumbnail
filmstripType = { _filmstripType }
horizontalOffset = { _horizontalOffset }
key = { `remote_${_participantID}` }
participantID = { _participantID }
style = { style }
width = { _thumbnailWidth } />
);
}
}
/**
* 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 {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: { columnIndex: number;
data: { filmstripType: string; }; index?: number; rowIndex: number; }) {
const _currentLayout = getCurrentLayout(state);
const { remoteParticipants: remote } = state['features/filmstrip'];
const activeParticipants = getActiveParticipantsIds(state);
const disableSelfView = getHideSelfView(state);
const _verticalViewGrid = showGridInVerticalView(state);
const filmstripType = ownProps.data?.filmstripType;
const stageFilmstrip = filmstripType === FILMSTRIP_TYPE.STAGE;
const sortedActiveParticipants = activeParticipants.sort();
const remoteParticipants = stageFilmstrip ? sortedActiveParticipants : remote;
const remoteParticipantsLength = remoteParticipants.length;
const localId = getLocalParticipant(state)?.id;
if (_currentLayout === LAYOUTS.TILE_VIEW || _verticalViewGrid || stageFilmstrip) {
const { columnIndex, rowIndex } = ownProps;
const { tileViewDimensions, stageFilmstripDimensions, verticalViewDimensions } = state['features/filmstrip'];
const { gridView } = verticalViewDimensions;
let gridDimensions = tileViewDimensions?.gridDimensions,
thumbnailSize = tileViewDimensions?.thumbnailSize;
if (stageFilmstrip) {
gridDimensions = stageFilmstripDimensions.gridDimensions;
thumbnailSize = stageFilmstripDimensions.thumbnailSize;
} else if (_verticalViewGrid) {
gridDimensions = gridView?.gridDimensions;
thumbnailSize = gridView?.thumbnailSize;
}
const { columns = 1, rows = 1 } = gridDimensions ?? {};
const index = (rowIndex * columns) + columnIndex;
let horizontalOffset, thumbnailWidth;
const { iAmRecorder, disableTileEnlargement } = state['features/base/config'];
const { localScreenShare } = state['features/base/participants'];
const localParticipantsLength = localScreenShare ? 2 : 1;
let participantsLength;
if (stageFilmstrip) {
// We use the length of activeParticipants in stage filmstrip which includes local participants.
participantsLength = remoteParticipantsLength;
} else {
// We need to include the local screenshare participant in tile view.
participantsLength = remoteParticipantsLength
// Add local camera and screen share to total participant count when self view is not disabled.
+ (disableSelfView ? 0 : localParticipantsLength)
// Removes iAmRecorder from the total participants count.
- (iAmRecorder ? 1 : 0);
}
if (rowIndex === rows - 1) { // center the last row
const partialLastRowParticipantsNumber = participantsLength % columns;
if (partialLastRowParticipantsNumber > 0) {
const { width = 1, height = 1 } = thumbnailSize ?? {};
const availableWidth = columns * (width + TILE_HORIZONTAL_MARGIN);
let widthDifference = 0;
let widthToUse = width;
if (!disableTileEnlargement) {
thumbnailWidth = Math.min(
(availableWidth / partialLastRowParticipantsNumber) - TILE_HORIZONTAL_MARGIN,
height * TILE_ASPECT_RATIO);
widthDifference = thumbnailWidth - width;
widthToUse = thumbnailWidth;
}
horizontalOffset
= Math.floor((availableWidth
- (partialLastRowParticipantsNumber * (widthToUse + TILE_HORIZONTAL_MARGIN))) / 2
)
+ (columnIndex * widthDifference);
}
}
if (index > participantsLength - 1) {
return {};
}
if (stageFilmstrip) {
return {
_disableSelfView: disableSelfView,
_filmstripType: filmstripType,
_participantID: remoteParticipants[index] === localId ? 'local' : remoteParticipants[index],
_horizontalOffset: horizontalOffset,
_thumbnailWidth: thumbnailWidth
};
}
// When the thumbnails are reordered, local participant is inserted at index 0.
const localIndex = disableSelfView ? remoteParticipantsLength : 0;
// Local screen share is inserted at index 1 after the local camera.
const localScreenShareIndex = disableSelfView ? remoteParticipantsLength : 1;
const remoteIndex = !iAmRecorder && !disableSelfView
? index - localParticipantsLength
: index;
if (!iAmRecorder && index === localIndex) {
return {
_disableSelfView: disableSelfView,
_filmstripType: filmstripType,
_participantID: 'local',
_horizontalOffset: horizontalOffset,
_thumbnailWidth: thumbnailWidth
};
}
if (!iAmRecorder && localScreenShare && index === localScreenShareIndex) {
return {
_disableSelfView: disableSelfView,
_filmstripType: filmstripType,
_isLocalScreenShare: true,
_participantID: localScreenShare?.id,
_horizontalOffset: horizontalOffset,
_thumbnailWidth: thumbnailWidth
};
}
return {
_filmstripType: filmstripType,
_participantID: remoteParticipants[remoteIndex],
_horizontalOffset: horizontalOffset,
_thumbnailWidth: thumbnailWidth
};
}
if (_currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW && filmstripType === FILMSTRIP_TYPE.SCREENSHARE) {
const { screenshareFilmstripParticipantId } = state['features/filmstrip'];
const screenshares = state['features/video-layout'].remoteScreenShares;
let id = screenshares.find(sId => sId === screenshareFilmstripParticipantId);
if (!id && screenshares.length) {
id = screenshares[screenshares.length - 1];
}
return {
_filmstripType: filmstripType,
_participantID: id
};
}
const { index } = ownProps;
if (typeof index !== 'number' || remoteParticipantsLength <= index) {
return {};
}
return {
_participantID: remoteParticipants[index]
};
}
export default connect(_mapStateToProps)(ThumbnailWrapper);

View File

@@ -0,0 +1,76 @@
import React from 'react';
import LocalVideoMenuTriggerButton from '../../../video-menu/components/web/LocalVideoMenuTriggerButton';
import RemoteVideoMenuTriggerButton from '../../../video-menu/components/web/RemoteVideoMenuTriggerButton';
interface IProps {
/**
* Hide popover callback.
*/
hidePopover?: Function;
/**
* Whether or not the button is for the local participant.
*/
local?: boolean;
/**
* The id of the participant for which the button is.
*/
participantId?: string;
/**
* Whether popover is visible or not.
*/
popoverVisible?: boolean;
/**
* Show popover callback.
*/
showPopover?: Function;
/**
* The type of thumbnail.
*/
thumbnailType: string;
/**
* Whether or not the component is visible.
*/
visible: boolean;
}
// eslint-disable-next-line no-confusing-arrow
const VideoMenuTriggerButton = ({
hidePopover,
local,
participantId = '',
popoverVisible,
showPopover,
thumbnailType,
visible
}: IProps) => local
? (
<span id = 'localvideomenu'>
<LocalVideoMenuTriggerButton
buttonVisible = { visible }
hidePopover = { hidePopover }
popoverVisible = { popoverVisible }
showPopover = { showPopover }
thumbnailType = { thumbnailType } />
</span>
)
: (
<span id = 'remotevideomenu'>
<RemoteVideoMenuTriggerButton
buttonVisible = { visible }
hidePopover = { hidePopover }
participantID = { participantId }
popoverVisible = { popoverVisible }
showPopover = { showPopover }
thumbnailType = { thumbnailType } />
</span>
);
export default VideoMenuTriggerButton;

View File

@@ -0,0 +1,183 @@
import React, { TouchEventHandler } from 'react';
import { useSelector } from 'react-redux';
import { useStyles } from 'tss-react/mui';
import VideoTrack from '../../../base/media/components/web/VideoTrack';
import { ITrack } from '../../../base/tracks/types';
import { LAYOUTS } from '../../../video-layout/constants';
import { getCurrentLayout } from '../../../video-layout/functions.web';
import ThumbnailBottomIndicators from './ThumbnailBottomIndicators';
import ThumbnailTopIndicators from './ThumbnailTopIndicators';
interface IProps {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<
'containerBackground' |
'indicatorsContainer' |
'indicatorsTopContainer' |
'tintBackground' |
'indicatorsBottomContainer' |
'indicatorsBackground',
string
>>;
/**
* The class name that will be used for the container.
*/
containerClassName: string;
/**
* Indicates whether the thumbnail is hovered or not.
*/
isHovered: boolean;
/**
* Indicates whether the thumbnail is for local screenshare or not.
*/
isLocal: boolean;
/**
* Indicates whether we are currently running in a mobile browser.
*/
isMobile: boolean;
/**
* Click handler.
*/
onClick: (e?: React.MouseEvent) => void;
/**
* Mouse enter handler.
*/
onMouseEnter: (e?: React.MouseEvent) => void;
/**
* Mouse leave handler.
*/
onMouseLeave: (e?: React.MouseEvent) => void;
/**
* Mouse move handler.
*/
onMouseMove: (e?: React.MouseEvent) => void;
/**
* Touch end handler.
*/
onTouchEnd: TouchEventHandler;
/**
* Touch move handler.
*/
onTouchMove: TouchEventHandler;
/**
* Touch start handler.
*/
onTouchStart: TouchEventHandler;
/**
* The ID of the virtual screen share participant.
*/
participantId: string;
/**
* Whether or not to display a tint background over tile.
*/
shouldDisplayTintBackground: boolean;
/**
* An object with the styles for thumbnail.
*/
styles: any;
/**
* The type of thumbnail.
*/
thumbnailType: string;
/**
* JitsiTrack instance.
*/
videoTrack: ITrack;
}
const VirtualScreenshareParticipant = ({
classes,
containerClassName,
isHovered,
isLocal,
isMobile,
onClick,
onMouseEnter,
onMouseLeave,
onMouseMove,
onTouchEnd,
onTouchMove,
onTouchStart,
participantId,
shouldDisplayTintBackground,
styles,
videoTrack,
thumbnailType
}: IProps) => {
const currentLayout = useSelector(getCurrentLayout);
const videoTrackId = videoTrack?.jitsiTrack?.getId();
const video = videoTrack && <VideoTrack
id = { isLocal ? 'localScreenshare_container' : `remoteVideo_${videoTrackId || ''}` }
muted = { true }
style = { styles.video }
videoTrack = { videoTrack } />;
const { cx } = useStyles();
return (
<span
className = { containerClassName }
id = { `participant_${participantId}` }
{ ...(isMobile
? {
onTouchEnd,
onTouchMove,
onTouchStart
}
: {
onClick,
onMouseEnter,
onMouseMove,
onMouseLeave
}
) }
style = { styles.thumbnail }>
{video}
<div className = { classes?.containerBackground } />
<div
className = { cx(classes?.indicatorsContainer,
classes?.indicatorsTopContainer,
currentLayout === LAYOUTS.TILE_VIEW && 'tile-view-mode'
) }>
<ThumbnailTopIndicators
isHovered = { isHovered }
participantId = { participantId }
thumbnailType = { thumbnailType } />
</div>
{shouldDisplayTintBackground && <div className = { classes?.tintBackground } />}
<div
className = { cx(classes?.indicatorsContainer,
classes?.indicatorsBottomContainer,
currentLayout === LAYOUTS.TILE_VIEW && 'tile-view-mode'
) }>
<ThumbnailBottomIndicators
className = { classes?.indicatorsBackground }
local = { false }
participantId = { participantId }
showStatusIndicators = { true } />
</div>
</span>);
};
export default VirtualScreenshareParticipant;

View File

@@ -0,0 +1,300 @@
import { BoxModel } from '../base/styles/components/styles/BoxModel';
/**
* The size (height and width) of the small (not tile view) thumbnails.
*/
export const SMALL_THUMBNAIL_SIZE = 80;
/**
* The height of the filmstrip in narrow aspect ratio, or width in wide.
*/
export const FILMSTRIP_SIZE = SMALL_THUMBNAIL_SIZE + BoxModel.margin;
/**
* The aspect ratio of a tile in tile view.
*/
export const TILE_ASPECT_RATIO = 16 / 9;
/**
* The aspect ratio of a square tile in tile view.
*/
export const SQUARE_TILE_ASPECT_RATIO = 1;
/**
* Width below which the overflow menu(s) will be displayed as drawer(s).
*/
export const DISPLAY_DRAWER_THRESHOLD = 512;
/**
* Breakpoint past which the aspect ratio is switched in tile view.
* Also, past this breakpoint, if there are two participants in the conference, we enforce
* single column view.
* If this is to be modified, please also change the related media query from the tile_view scss file.
*/
export const ASPECT_RATIO_BREAKPOINT = 500;
/**
* Minimum height of tile for small screens.
*/
export const TILE_MIN_HEIGHT_SMALL = 150;
/**
* Minimum height of tile for large screens.
*/
export const TILE_MIN_HEIGHT_LARGE = 200;
/**
* Aspect ratio for portrait tiles.
*/
export const TILE_PORTRAIT_ASPECT_RATIO = 1 / 1.3;
/**
* The default number of visible tiles for tile view.
*/
export const TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES = 25;
/**
* The default number of columns for tile view.
*/
export const DEFAULT_MAX_COLUMNS = 5;
/**
* An extended number of columns for tile view.
*/
export const ABSOLUTE_MAX_COLUMNS = 7;
/**
* Display mode constant used when video is being displayed on the small video.
*
* @type {number}
* @constant
*/
export const DISPLAY_VIDEO = 0;
/**
* Display mode constant used when the user's avatar is being displayed on
* the small video.
*
* @type {number}
* @constant
*/
export const DISPLAY_AVATAR = 1;
/**
* Maps the display modes to class name that will be applied on the thumbnail container.
*
* @type {Array<string>}
* @constant
*/
export const DISPLAY_MODE_TO_CLASS_NAME = [
'display-video',
'display-avatar-only'
];
/**
* 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 horizontal margin of a vertical filmstrip tile container.
*
* @type {number}
*/
export const TILE_VERTICAL_CONTAINER_HORIZONTAL_MARGIN = 2;
/**
* The vertical margin of the tile grid container.
*
* @type {number}
*/
export const TILE_VIEW_GRID_VERTICAL_MARGIN = 14;
/**
* The horizontal margin of the tile grid container.
*
* @type {number}
*/
export const TILE_VIEW_GRID_HORIZONTAL_MARGIN = 14;
/**
* The height of the whole toolbar.
*/
export const TOOLBAR_HEIGHT = 72;
/**
* The height of the whole toolbar.
*/
export const TOOLBAR_HEIGHT_MOBILE = 60;
/**
* 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 = 26;
/**
* 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;
/**
* Sets after how many ms to show the thumbnail context menu on long touch on mobile.
*
* @type {number}
*/
export const SHOW_TOOLBAR_CONTEXT_MENU_AFTER = 600;
/**
* The margin for each side of the tile view. Taken away from the available
* width for the tile container to display in.
*
* NOTE: Mobile specific.
*
* @private
* @type {number}
*/
export const TILE_MARGIN = 10;
/**
* The types of thumbnails for filmstrip.
*/
export const THUMBNAIL_TYPE = {
TILE: 'TILE',
VERTICAL: 'VERTICAL',
HORIZONTAL: 'HORIZONTAL'
};
/**
* The popover position for the connection stats table.
*/
export const STATS_POPOVER_POSITION = {
[THUMBNAIL_TYPE.TILE]: 'right-start',
[THUMBNAIL_TYPE.VERTICAL]: 'left-start',
[THUMBNAIL_TYPE.HORIZONTAL]: 'top-end'
};
/**
* The tooltip position for the indicators on the thumbnail.
*/
export const INDICATORS_TOOLTIP_POSITION: {
[x: string]: 'right' | 'left' | 'top';
} = {
[THUMBNAIL_TYPE.TILE]: 'right',
[THUMBNAIL_TYPE.VERTICAL]: 'left',
[THUMBNAIL_TYPE.HORIZONTAL]: 'top'
};
/**
* The default (and minimum) width for the vertical filmstrip (user resizable).
*/
export const DEFAULT_FILMSTRIP_WIDTH = 120;
/**
* The default aspect ratio for the local tile.
*/
export const DEFAULT_LOCAL_TILE_ASPECT_RATIO = 16 / 9;
/**
* The width of the filmstrip at which it no longer goes above the stage view, but it pushes it.
*/
export const FILMSTRIP_BREAKPOINT = 180;
/**
* The width of the filmstrip at which the display mode changes from column to grid.
*/
export const FILMSTRIP_GRID_BREAKPOINT = 300;
/**
* How much before the breakpoint should we display the background.
* (We display the opaque background before we resize the stage view to make sure
* the resize is not visible behind the filmstrip).
*/
export const FILMSTRIP_BREAKPOINT_OFFSET = 5;
/**
* The minimum height for the stage view
* (used to determine the maximum height of the user-resizable top panel).
*/
export const MIN_STAGE_VIEW_HEIGHT = 700;
/**
* The minimum width for the stage view
* (used to determine the maximum width of the user-resizable vertical filmstrip).
*/
export const MIN_STAGE_VIEW_WIDTH = 800;
/**
* Horizontal margin used for the vertical filmstrip.
*/
export const VERTICAL_VIEW_HORIZONTAL_MARGIN = VERTICAL_FILMSTRIP_MIN_HORIZONTAL_MARGIN
+ SCROLL_SIZE + TILE_HORIZONTAL_MARGIN + STAGE_VIEW_THUMBNAIL_HORIZONTAL_BORDER;
/**
* The time after which a participant should be removed from active participants.
*/
export const ACTIVE_PARTICIPANT_TIMEOUT = 1000 * 60;
/**
* The types of filmstrip.
*/
export const FILMSTRIP_TYPE = {
MAIN: 'main',
STAGE: 'stage',
SCREENSHARE: 'screenshare'
};
/**
* The max number of participants to be displayed on the stage filmstrip.
*/
export const MAX_ACTIVE_PARTICIPANTS = 6;
/**
* Top filmstrip default height.
*/
export const TOP_FILMSTRIP_HEIGHT = 180;

View File

@@ -0,0 +1,110 @@
import { IReduxState, IStore } from '../app/types';
import {
getActiveSpeakersToBeDisplayed,
getVirtualScreenshareParticipantOwnerId
} from '../base/participants/functions';
import { setRemoteParticipants } from './actions';
import { isFilmstripScrollVisible } from './functions';
/**
* Computes the reorderd list of the remote participants.
*
* @param {*} store - The redux store.
* @param {boolean} force - Does not short circuit, the execution, make execute all checks.
* @param {string} participantId - The endpoint id of the participant that joined the call.
* @returns {void}
* @private
*/
export function updateRemoteParticipants(store: IStore, force?: boolean, participantId?: string) {
const state = store.getState();
let reorderedParticipants = [];
const { sortedRemoteVirtualScreenshareParticipants } = state['features/base/participants'];
if (!isFilmstripScrollVisible(state) && !sortedRemoteVirtualScreenshareParticipants.size && !force) {
if (participantId) {
const { remoteParticipants } = state['features/filmstrip'];
reorderedParticipants = [ ...remoteParticipants, participantId ];
store.dispatch(setRemoteParticipants(Array.from(new Set(reorderedParticipants))));
}
return;
}
const {
fakeParticipants,
sortedRemoteParticipants
} = state['features/base/participants'];
const remoteParticipants = new Map(sortedRemoteParticipants);
const screenShareParticipants = sortedRemoteVirtualScreenshareParticipants
? [ ...sortedRemoteVirtualScreenshareParticipants.keys() ] : [];
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
const speakers = getActiveSpeakersToBeDisplayed(state);
for (const screenshare of screenShareParticipants) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
speakers.delete(ownerId);
}
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
}
for (const speaker of speakers.keys()) {
remoteParticipants.delete(speaker);
}
// Always update the order of the thubmnails.
const participantsWithScreenShare = screenShareParticipants.reduce<string[]>((acc, screenshare) => {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
acc.push(ownerId);
acc.push(screenshare);
return acc;
}, []);
reorderedParticipants = [
...participantsWithScreenShare,
...sharedVideos,
...Array.from(speakers.keys()),
...Array.from(remoteParticipants.keys())
];
store.dispatch(setRemoteParticipants(Array.from(new Set(reorderedParticipants))));
}
/**
* Private helper to calculate the reordered list of remote participants when a participant leaves.
*
* @param {*} store - The redux store.
* @param {string} participantId - The endpoint id of the participant leaving the call.
* @returns {void}
* @private
*/
export function updateRemoteParticipantsOnLeave(store: IStore, participantId: string | null = null) {
if (!participantId) {
return;
}
const state = store.getState();
const { remoteParticipants } = state['features/filmstrip'];
const reorderedParticipants = new Set(remoteParticipants);
reorderedParticipants.delete(participantId)
&& store.dispatch(setRemoteParticipants(Array.from(reorderedParticipants)));
}
/**
* Returns whether tileview is completely disabled.
*
* @param {IReduxState} state - Redux state.
* @returns {boolean} - Whether tileview is completely disabled.
*/
export function isTileViewModeDisabled(state: IReduxState) {
const { tileView = {} } = state['features/base/config'];
return tileView.disabled;
}

View File

@@ -0,0 +1,289 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { FILMSTRIP_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
import {
getLocalParticipant,
getParticipantCountWithFake,
getPinnedParticipant
} from '../base/participants/functions';
import Platform from '../base/react/Platform.native';
import { toState } from '../base/redux/functions';
import { ASPECT_RATIO_NARROW } from '../base/responsive-ui/constants';
import { getHideSelfView } from '../base/settings/functions.any';
import conferenceStyles from '../conference/components/native/styles';
import { shouldDisplayTileView } from '../video-layout/functions.native';
import styles from './components/native/styles';
export * from './functions.any';
/**
* Returns true if the filmstrip on mobile is visible, false otherwise.
*
* NOTE: Filmstrip on mobile behaves differently to web, and is only visible
* when there are at least 2 participants.
*
* @param {Object | Function} stateful - The Object or Function that can be
* resolved to a Redux state object with the toState function.
* @returns {boolean}
*/
export function isFilmstripVisible(stateful: IStateful) {
const state = toState(stateful);
const enabled = getFeatureFlag(state, FILMSTRIP_ENABLED, true);
if (!enabled) {
return false;
}
return getParticipantCountWithFake(state) > 1;
}
/**
* Determines whether the remote video thumbnails should be displayed/visible in
* the filmstrip.
*
* @param {Object} state - The full redux state.
* @returns {boolean} - If remote video thumbnails should be displayed/visible
* in the filmstrip, then {@code true}; otherwise, {@code false}.
*/
export function shouldRemoteVideosBeVisible(state: IReduxState) {
if (state['features/invite'].calleeInfoVisible) {
return false;
}
// Include fake participants to derive how many thumbnails are displayed,
// as it is assumed all participants, including fake, will be displayed
// in the filmstrip.
const participantCount = getParticipantCountWithFake(state);
const pinnedParticipant = getPinnedParticipant(state);
const { disable1On1Mode } = state['features/base/config'];
return Boolean(
participantCount > 2
// Always show the filmstrip when there is another participant to
// show and the local video is pinned. Note we are not taking the
// toolbar visibility into account here (unlike web) because
// showing / hiding views in quick succession on mobile is taxing.
|| (participantCount > 1 && pinnedParticipant?.local)
|| disable1On1Mode);
}
/**
* Not implemented on mobile.
*
* @param {any} _state - Used on web.
* @returns {Array<string>}
*/
export function getActiveParticipantsIds(_state: any) {
return [];
}
/**
* Not implemented on mobile.
*
* @param {any} _state - Redux state.
* @returns {Array<Object>}
*/
export function getPinnedActiveParticipants(_state: any) {
return [];
}
/**
* Returns the number of participants displayed in 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 participants displayed in tile view.
*/
export function getTileViewParticipantCount(stateful: IStateful) {
const state = toState(stateful);
const disableSelfView = getHideSelfView(state);
const localParticipant = getLocalParticipant(state);
const participantCount = getParticipantCountWithFake(state) - (disableSelfView && localParticipant ? 1 : 0);
return participantCount;
}
/**
* 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: IStateful) {
const state = toState(stateful);
const participantCount = getTileViewParticipantCount(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(participantCount <= 6 ? 3 : 4, participantCount);
}
/**
* Returns true if the filmstrip has a scroll and false otherwise.
*
* @param {Object} state - The redux state.
* @returns {boolean} - True if the scroll is displayed and false otherwise.
*/
export function isFilmstripScrollVisible(state: IReduxState) {
if (shouldDisplayTileView(state)) {
return state['features/filmstrip']?.tileViewDimensions?.hasScroll;
}
const { aspectRatio, clientWidth, clientHeight, safeAreaInsets = {} } = state['features/base/responsive-ui'];
const isNarrowAspectRatio = aspectRatio === ASPECT_RATIO_NARROW;
const disableSelfView = getHideSelfView(state);
const localParticipant = Boolean(getLocalParticipant(state));
const localParticipantVisible = localParticipant && !disableSelfView;
const participantCount
= getParticipantCountWithFake(state)
- (localParticipant && (shouldDisplayLocalThumbnailSeparately() || disableSelfView) ? 1 : 0);
const { height: thumbnailHeight, width: thumbnailWidth, margin } = styles.thumbnail;
const { height, width } = getFilmstripDimensions({
aspectRatio,
clientWidth,
clientHeight,
insets: safeAreaInsets,
localParticipantVisible
});
if (isNarrowAspectRatio) {
return width < (thumbnailWidth + (2 * margin)) * participantCount;
}
return height < (thumbnailHeight + (2 * margin)) * participantCount;
}
/**
* Whether the stage filmstrip is available or not.
*
* @param {any} _state - Used on web.
* @param {any} _count - Used on web.
* @returns {boolean}
*/
export function isStageFilmstripAvailable(_state: any, _count?: any) {
return false;
}
/**
* Whether the stage filmstrip is enabled.
*
* @param {any} _state - Used on web.
* @returns {boolean}
*/
export function isStageFilmstripEnabled(_state: any) {
return false;
}
/**
* Whether or not the top panel is enabled.
*
* @param {any} _state - Used on web.
* @returns {boolean}
*/
export function isTopPanelEnabled(_state: any) {
return false;
}
/**
* Calculates the width and height of the filmstrip based on the screen size and aspect ratio.
*
* @param {Object} options - The screen aspect ratio, width, height and safe are insets.
* @returns {Object} - The width and the height.
*/
export function getFilmstripDimensions({
aspectRatio,
clientWidth,
clientHeight,
insets = {},
localParticipantVisible = true
}: {
aspectRatio: Symbol;
clientHeight: number;
clientWidth: number;
insets?: {
bottom?: number;
left?: number;
right?: number;
top?: number;
};
localParticipantVisible?: boolean;
}) {
const { height, width, margin } = styles.thumbnail; // @ts-ignore
const conferenceBorder = conferenceStyles.conference.borderWidth || 0;
const { left = 0, right = 0, top = 0, bottom = 0 } = insets;
if (aspectRatio === ASPECT_RATIO_NARROW) {
return {
height,
width:
(shouldDisplayLocalThumbnailSeparately() && localParticipantVisible
? clientWidth - width - (margin * 2) : clientWidth)
- left - right - (styles.filmstripNarrow.margin * 2) - (conferenceBorder * 2)
};
}
return {
height:
(shouldDisplayLocalThumbnailSeparately() && localParticipantVisible
? clientHeight - height - (margin * 2) : clientHeight)
- top - bottom - (conferenceBorder * 2),
width
};
}
/**
* Returns true if the local thumbnail should be displayed separately and false otherwise.
*
* @returns {boolean} - True if the local thumbnail should be displayed separately and false otherwise.
*/
export function shouldDisplayLocalThumbnailSeparately() {
// XXX Our current design is to have the local participant separate from
// the remote participants. Unfortunately, Android's Video
// implementation cannot accommodate that because remote participants'
// videos appear on top of the local participant's video at times.
// That's because Android's Video utilizes EGL and EGL gives us only two
// practical layers in which we can place our participants' videos:
// layer #0 sits behind the window, creates a hole in the window, and
// there we render the LargeVideo; layer #1 is known as media overlay in
// EGL terms, renders on top of layer #0, and, consequently, is for the
// Filmstrip. With the separate LocalThumbnail, we should have left the
// remote participants' Thumbnails in layer #1 and utilized layer #2 for
// LocalThumbnail. Unfortunately, layer #2 is not practical (that's why
// I said we had two practical layers only) because it renders on top of
// everything which in our case means on top of participant-related
// indicators such as moderator, audio and video muted, etc. For now we
// do not have much of a choice but to continue rendering LocalThumbnail
// as any other remote Thumbnail on Android.
return Platform.OS !== 'android';
}
/**
* Not implemented on mobile.
*
* @param {any} _state - Used on web.
* @returns {undefined}
*/
export function getScreenshareFilmstripParticipantId(_state: any) {
return undefined;
}

View File

@@ -0,0 +1,832 @@
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { isMobileBrowser } from '../base/environment/utils';
import { MEDIA_TYPE } from '../base/media/constants';
import {
getLocalParticipant,
getParticipantById,
getParticipantCount,
getParticipantCountWithFake,
getPinnedParticipant,
isScreenShareParticipant
} from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { getHideSelfView } from '../base/settings/functions.any';
import {
getVideoTrackByParticipant,
isLocalTrackMuted,
isRemoteTrackMuted
} from '../base/tracks/functions';
import { isTrackStreamingStatusActive } from '../connection-indicator/functions';
import { isSharingStatus } from '../shared-video/functions';
import { LAYOUTS } from '../video-layout/constants';
import { getCurrentLayout, getNotResponsiveTileViewGridDimensions } from '../video-layout/functions.web';
import {
ASPECT_RATIO_BREAKPOINT,
DEFAULT_FILMSTRIP_WIDTH,
DEFAULT_LOCAL_TILE_ASPECT_RATIO,
DISPLAY_AVATAR,
DISPLAY_VIDEO,
FILMSTRIP_GRID_BREAKPOINT,
FILMSTRIP_TYPE,
INDICATORS_TOOLTIP_POSITION,
SCROLL_SIZE,
SQUARE_TILE_ASPECT_RATIO,
THUMBNAIL_TYPE,
TILE_ASPECT_RATIO,
TILE_HORIZONTAL_MARGIN,
TILE_MIN_HEIGHT_LARGE,
TILE_MIN_HEIGHT_SMALL,
TILE_PORTRAIT_ASPECT_RATIO,
TILE_VERTICAL_MARGIN,
TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES,
TILE_VIEW_GRID_HORIZONTAL_MARGIN,
TILE_VIEW_GRID_VERTICAL_MARGIN,
VERTICAL_VIEW_HORIZONTAL_MARGIN
} from './constants';
export * from './functions.any';
/**
* Returns true if the filmstrip on mobile is visible, false otherwise.
*
* NOTE: Filmstrip on web behaves differently to mobile, much simpler, but so
* function lies here only for the sake of consistency and to avoid flow errors
* on import.
*
* @param {IStateful} stateful - The Object or Function that can be
* resolved to a Redux state object with the toState function.
* @returns {boolean}
*/
export function isFilmstripVisible(stateful: IStateful) {
return toState(stateful)['features/filmstrip'].visible;
}
/**
* Determines whether the remote video thumbnails should be displayed/visible in
* the filmstrip.
*
* @param {IReduxState} state - The full redux state.
* @returns {boolean} - If remote video thumbnails should be displayed/visible
* in the filmstrip, then {@code true}; otherwise, {@code false}.
*/
export function shouldRemoteVideosBeVisible(state: IReduxState) {
if (state['features/invite'].calleeInfoVisible) {
return false;
}
// Include fake participants to derive how many thumbnails are displayed,
// as it is assumed all participants, including fake, will be displayed
// in the filmstrip.
const participantCount = getParticipantCountWithFake(state);
let pinnedParticipant;
const { disable1On1Mode, filmstrip: { alwaysShowResizeBar } = {} } = state['features/base/config'];
const { contextMenuOpened } = state['features/base/responsive-ui'];
return Boolean(
contextMenuOpened
|| participantCount > 2
|| alwaysShowResizeBar
// Always show the filmstrip when there is another participant to
// show and the local video is pinned, or the toolbar is displayed.
|| (participantCount > 1
&& disable1On1Mode !== null
&& (state['features/toolbox'].visible
|| ((pinnedParticipant = getPinnedParticipant(state))
&& pinnedParticipant.local)))
|| disable1On1Mode);
}
/**
* Checks whether there is a playable video stream available for the user associated with the passed ID.
*
* @param {IStateful} stateful - The Object or Function that can be
* resolved to a Redux state object with the toState function.
* @param {string} id - The id of the participant.
* @returns {boolean} <tt>true</tt> if there is a playable video stream available
* or <tt>false</tt> otherwise.
*/
export function isVideoPlayable(stateful: IStateful, id: string) {
const state = toState(stateful);
const tracks = state['features/base/tracks'];
const participant = id ? getParticipantById(state, id) : getLocalParticipant(state);
const isLocal = participant?.local ?? true;
const videoTrack = getVideoTrackByParticipant(state, participant);
const isAudioOnly = Boolean(state['features/base/audio-only'].enabled);
let isPlayable = false;
if (isLocal) {
const isVideoMuted = isLocalTrackMuted(tracks, MEDIA_TYPE.VIDEO);
isPlayable = Boolean(videoTrack) && !isVideoMuted && !isAudioOnly;
} else if (!participant?.fakeParticipant || isScreenShareParticipant(participant)) {
// remote participants excluding shared video
const isVideoMuted = isRemoteTrackMuted(tracks, MEDIA_TYPE.VIDEO, id);
isPlayable = Boolean(videoTrack) && !isVideoMuted && !isAudioOnly && isTrackStreamingStatusActive(videoTrack);
}
return isPlayable;
}
/**
* Calculates the size for thumbnails when in horizontal view layout.
*
* @param {number} clientHeight - The height of the app window.
* @returns {{local: {height, width}, remote: {height, width}}}
*/
export function calculateThumbnailSizeForHorizontalView(clientHeight = 0) {
const topBottomMargin = 15;
const availableHeight = Math.min(clientHeight,
(interfaceConfig.FILM_STRIP_MAX_HEIGHT || DEFAULT_FILMSTRIP_WIDTH) + topBottomMargin);
const height = availableHeight - topBottomMargin;
return {
local: {
height,
width: Math.floor(interfaceConfig.LOCAL_THUMBNAIL_RATIO * height)
},
remote: {
height,
width: Math.floor(interfaceConfig.REMOTE_THUMBNAIL_RATIO * height)
}
};
}
/**
* Calculates the size for thumbnails when in vertical view layout.
*
* @param {number} clientWidth - The available video space width.
* @param {number} filmstripWidth - The width of the filmstrip.
* @param {boolean} isResizable - Whether the filmstrip is resizable or not.
* @returns {{local: {height, width}, remote: {height, width}}}
*/
export function calculateThumbnailSizeForVerticalView(clientWidth = 0, filmstripWidth = 0, isResizable = false) {
const availableWidth = Math.min(
Math.max(clientWidth - VERTICAL_VIEW_HORIZONTAL_MARGIN, 0),
(isResizable ? filmstripWidth : interfaceConfig.FILM_STRIP_MAX_HEIGHT) || DEFAULT_FILMSTRIP_WIDTH);
return {
local: {
height: Math.floor(availableWidth
/ (interfaceConfig.LOCAL_THUMBNAIL_RATIO || DEFAULT_LOCAL_TILE_ASPECT_RATIO)),
width: availableWidth
},
remote: {
height: isResizable
? DEFAULT_FILMSTRIP_WIDTH
: Math.floor(availableWidth / interfaceConfig.REMOTE_THUMBNAIL_RATIO),
width: availableWidth
}
};
}
/**
* Returns the minimum height of a thumbnail.
*
* @param {number} clientWidth - The available width for rendering thumbnails.
* @returns {number} The minimum height of a thumbnail.
*/
export function getThumbnailMinHeight(clientWidth: number) {
return clientWidth < ASPECT_RATIO_BREAKPOINT ? TILE_MIN_HEIGHT_SMALL : TILE_MIN_HEIGHT_LARGE;
}
/**
* Returns the default aspect ratio for a tile.
*
* @param {boolean} disableResponsiveTiles - Indicates whether the responsive tiles functionality is disabled.
* @param {boolean} disableTileEnlargement - Indicates whether the tiles enlargement functionality is disabled.
* @param {number} clientWidth - The available video space width.
* @returns {number} The default aspect ratio for a tile.
*/
export function getTileDefaultAspectRatio(disableResponsiveTiles: boolean,
disableTileEnlargement: boolean, clientWidth: number) {
if (!disableResponsiveTiles && disableTileEnlargement && clientWidth < ASPECT_RATIO_BREAKPOINT) {
return SQUARE_TILE_ASPECT_RATIO;
}
return TILE_ASPECT_RATIO;
}
/**
* Returns the number of participants that will be displayed in tile view.
*
* @param {Object} state - The redux store state.
* @returns {number} The number of participants that will be displayed in tile view.
*/
export function getNumberOfPartipantsForTileView(state: IReduxState) {
const { iAmRecorder } = state['features/base/config'];
const disableSelfView = getHideSelfView(state);
const { localScreenShare } = state['features/base/participants'];
const localParticipantsCount = localScreenShare ? 2 : 1;
const numberOfParticipants = getParticipantCountWithFake(state)
- (iAmRecorder ? 1 : 0)
- (disableSelfView ? localParticipantsCount : 0);
return numberOfParticipants;
}
/**
* Calculates the dimensions (thumbnail width/height and columns/row) for tile view when the responsive tiles are
* disabled.
*
* @param {Object} state - The redux store state.
* @returns {Object} - The dimensions.
*/
export function calculateNonResponsiveTileViewDimensions(state: IReduxState) {
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
const { disableTileEnlargement } = state['features/base/config'];
const { columns: c, minVisibleRows, rows: r } = getNotResponsiveTileViewGridDimensions(state);
const size = calculateThumbnailSizeForTileView({
columns: c,
minVisibleRows,
clientWidth: videoSpaceWidth,
clientHeight,
disableTileEnlargement,
disableResponsiveTiles: true
});
if (typeof size === 'undefined') { // The columns don't fit into the screen. We will have horizontal scroll.
const aspectRatio = disableTileEnlargement
? getTileDefaultAspectRatio(true, disableTileEnlargement, videoSpaceWidth)
: TILE_PORTRAIT_ASPECT_RATIO;
const height = getThumbnailMinHeight(videoSpaceWidth);
return {
height,
width: aspectRatio * height,
columns: c,
rows: r
};
}
return {
height: size.height,
width: size.width,
columns: c,
rows: r
};
}
/**
* Calculates the dimensions (thumbnail width/height and columns/row) for tile view when the responsive tiles are
* enabled.
*
* @param {Object} state - The redux store state.
* @returns {Object} - The dimensions.
*/
export function calculateResponsiveTileViewDimensions({
clientWidth,
clientHeight,
disableTileEnlargement = false,
noHorizontalContainerMargin = false,
maxColumns,
numberOfParticipants,
desiredNumberOfVisibleTiles = TILE_VIEW_DEFAULT_NUMBER_OF_VISIBLE_TILES,
minTileHeight
}: {
clientHeight: number;
clientWidth: number;
desiredNumberOfVisibleTiles: number;
disableTileEnlargement?: boolean;
maxColumns: number;
minTileHeight?: number | null;
noHorizontalContainerMargin?: boolean;
numberOfParticipants: number;
}) {
let height, width;
let columns, rows;
interface IDimensions {
columns?: number;
height?: number;
maxArea: number;
numberOfVisibleParticipants?: number;
rows?: number;
width?: number;
}
let dimensions: IDimensions = {
maxArea: 0
};
let minHeightEnforcedDimensions: IDimensions = {
maxArea: 0
};
let zeroVisibleRowsDimensions: IDimensions = {
maxArea: 0
};
for (let c = 1; c <= Math.min(maxColumns, numberOfParticipants, desiredNumberOfVisibleTiles); c++) {
const r = Math.ceil(numberOfParticipants / c);
// we want to display as much as possible thumbnails up to desiredNumberOfVisibleTiles
const visibleRows
= numberOfParticipants <= desiredNumberOfVisibleTiles ? r : Math.floor(desiredNumberOfVisibleTiles / c);
const size = calculateThumbnailSizeForTileView({
columns: c,
minVisibleRows: visibleRows,
clientWidth,
clientHeight,
disableTileEnlargement,
disableResponsiveTiles: false,
noHorizontalContainerMargin,
minTileHeight
});
if (size) {
const { height: currentHeight, width: currentWidth, minHeightEnforced, maxVisibleRows } = size;
const numberOfVisibleParticipants = Math.min(c * maxVisibleRows, numberOfParticipants);
let area = Math.round(
(currentHeight + TILE_VERTICAL_MARGIN)
* (currentWidth + TILE_HORIZONTAL_MARGIN)
* numberOfVisibleParticipants);
const currentDimensions = {
maxArea: area,
height: currentHeight,
width: currentWidth,
columns: c,
rows: r,
numberOfVisibleParticipants
};
const { numberOfVisibleParticipants: oldNumberOfVisibleParticipants = 0 } = dimensions;
if (!minHeightEnforced) {
if (area > dimensions.maxArea) {
dimensions = currentDimensions;
} else if ((area === dimensions.maxArea)
&& ((oldNumberOfVisibleParticipants > desiredNumberOfVisibleTiles
&& oldNumberOfVisibleParticipants >= numberOfParticipants)
|| (oldNumberOfVisibleParticipants < numberOfParticipants
&& numberOfVisibleParticipants <= desiredNumberOfVisibleTiles))
) { // If the area of the new candidates and the old ones are equal we prefer the one that will have
// closer number of visible participants to desiredNumberOfVisibleTiles config.
dimensions = currentDimensions;
}
} else if (minHeightEnforced && area >= minHeightEnforcedDimensions.maxArea) {
// If we choose configuration with minHeightEnforced there will be less than desiredNumberOfVisibleTiles
// visible tiles, that's why we prefer more columns when the area is the same.
minHeightEnforcedDimensions = currentDimensions;
} else if (minHeightEnforced && maxVisibleRows === 0) {
area = currentHeight * currentWidth * Math.min(c, numberOfParticipants);
if (area > zeroVisibleRowsDimensions.maxArea) {
zeroVisibleRowsDimensions = {
...currentDimensions,
maxArea: area
};
}
}
}
}
if (dimensions.maxArea > 0) {
({ height, width, columns, rows } = dimensions);
} else if (minHeightEnforcedDimensions.maxArea > 0) {
({ height, width, columns, rows } = minHeightEnforcedDimensions);
} else if (zeroVisibleRowsDimensions.maxArea > 0) {
({ height, width, columns, rows } = zeroVisibleRowsDimensions);
} else { // This would mean that we can't fit even one thumbnail with minimal size.
const aspectRatio = disableTileEnlargement
? getTileDefaultAspectRatio(false, disableTileEnlargement, clientWidth)
: TILE_PORTRAIT_ASPECT_RATIO;
height = getThumbnailMinHeight(clientWidth);
width = aspectRatio * height;
columns = 1;
rows = numberOfParticipants;
}
return {
height,
width,
columns,
rows
};
}
/**
* Calculates the size for thumbnails when in tile view layout.
*
* @param {Object} dimensions - The desired dimensions of the tile view grid.
* @returns {{hasScroll, height, width}}
*/
export function calculateThumbnailSizeForTileView({
columns,
minVisibleRows,
clientWidth,
clientHeight,
disableResponsiveTiles = false,
disableTileEnlargement = false,
noHorizontalContainerMargin = false,
minTileHeight
}: {
clientHeight: number;
clientWidth: number;
columns: number;
disableResponsiveTiles: boolean;
disableTileEnlargement?: boolean;
minTileHeight?: number | null;
minVisibleRows: number;
noHorizontalContainerMargin?: boolean;
}) {
const aspectRatio = getTileDefaultAspectRatio(disableResponsiveTiles, disableTileEnlargement, clientWidth);
const minHeight = minTileHeight || getThumbnailMinHeight(clientWidth);
const viewWidth = clientWidth - (columns * TILE_HORIZONTAL_MARGIN)
- (noHorizontalContainerMargin ? SCROLL_SIZE : TILE_VIEW_GRID_HORIZONTAL_MARGIN);
const availableHeight = clientHeight - TILE_VIEW_GRID_VERTICAL_MARGIN;
const viewHeight = availableHeight - (minVisibleRows * TILE_VERTICAL_MARGIN);
const initialWidth = viewWidth / columns;
let initialHeight = viewHeight / minVisibleRows;
let minHeightEnforced = false;
if (initialHeight < minHeight) {
minHeightEnforced = true;
initialHeight = minHeight;
}
if (disableTileEnlargement) {
const aspectRatioHeight = initialWidth / aspectRatio;
if (aspectRatioHeight < minHeight) { // we can't fit the required number of columns.
return;
}
const height = Math.min(aspectRatioHeight, initialHeight);
return {
height,
width: aspectRatio * height,
minHeightEnforced,
maxVisibleRows: Math.floor(availableHeight / (height + TILE_VERTICAL_MARGIN))
};
}
const initialRatio = initialWidth / initialHeight;
let height = initialHeight;
let width;
// The biggest area of the grid will be when the grid's height is equal to clientHeight or when the grid's width is
// equal to clientWidth.
if (initialRatio > aspectRatio) {
width = initialHeight * aspectRatio;
} else if (initialRatio >= TILE_PORTRAIT_ASPECT_RATIO) {
width = initialWidth;
// eslint-disable-next-line no-negated-condition
} else if (!minHeightEnforced) {
height = initialWidth / TILE_PORTRAIT_ASPECT_RATIO;
if (height >= minHeight) {
width = initialWidth;
} else { // The width is so small that we can't reach the minimum height with portrait aspect ratio.
return;
}
} else {
// We can't fit that number of columns with the desired min height and aspect ratio.
return;
}
return {
height,
width,
minHeightEnforced,
maxVisibleRows: Math.floor(availableHeight / (height + TILE_VERTICAL_MARGIN))
};
}
/**
* Returns the width of the visible area (doesn't include the left margin/padding) of the the vertical filmstrip.
*
* @returns {number} - The width of the vertical filmstrip.
*/
export function getVerticalFilmstripVisibleAreaWidth() {
// Adding 11px for the 2px right margin, 2px borders on the left and right and 5px right padding.
// Also adding 7px for the scrollbar. Note that we are not counting the left margins and paddings because this
// function is used for calculating the available space and they are invisible.
// TODO: Check if we can remove the left margins and paddings from the CSS.
// FIXME: This function is used to calculate the size of the large video, etherpad or shared video. Once everything
// is reactified this calculation will need to move to the corresponding components.
const filmstripMaxWidth = (interfaceConfig.FILM_STRIP_MAX_HEIGHT || DEFAULT_FILMSTRIP_WIDTH) + 18;
return Math.min(filmstripMaxWidth, window.innerWidth);
}
/**
* Computes information that determine the display mode.
*
* @param {Object} input - Object containing all necessary information for determining the display mode for
* the thumbnail.
* @returns {number} - One of <tt>DISPLAY_VIDEO</tt> or <tt>DISPLAY_AVATAR</tt>.
*/
export function computeDisplayModeFromInput(input: any) {
const {
filmstripType,
isActiveParticipant,
isAudioOnly,
isCurrentlyOnLargeVideo,
isVirtualScreenshareParticipant,
isScreenSharing,
canPlayEventReceived,
isRemoteParticipant,
stageParticipantsVisible,
tileViewActive
} = input;
const adjustedIsVideoPlayable = input.isVideoPlayable && (!isRemoteParticipant || canPlayEventReceived);
// Display video for virtual screen share participants in all layouts.
if (isVirtualScreenshareParticipant) {
return DISPLAY_VIDEO;
}
// Multi-stream is not supported on plan-b endpoints even if its is enabled via config.js. A virtual
// screenshare tile is still created when a remote endpoint starts screenshare to keep the behavior consistent
// and an avatar is displayed on the original participant thumbnail as long as screenshare is in progress.
if (isScreenSharing) {
return DISPLAY_AVATAR;
}
if (!tileViewActive && filmstripType === FILMSTRIP_TYPE.MAIN && ((isScreenSharing && isRemoteParticipant)
|| (stageParticipantsVisible && isActiveParticipant))) {
return DISPLAY_AVATAR;
} else if (isCurrentlyOnLargeVideo && !tileViewActive) {
// Display name is always and only displayed when user is on the stage
return adjustedIsVideoPlayable && !isAudioOnly ? DISPLAY_VIDEO : DISPLAY_AVATAR;
} else if (adjustedIsVideoPlayable && !isAudioOnly) {
// check hovering and change state to video with name
return DISPLAY_VIDEO;
}
// check hovering and change state to avatar with name
return DISPLAY_AVATAR;
}
/**
* Extracts information for props and state needed to compute the display mode.
*
* @param {Object} props - The Thumbnail component's props.
* @param {Object} state - The Thumbnail component's state.
* @returns {Object}
*/
export function getDisplayModeInput(props: any, state: { canPlayEventReceived: boolean; }) {
const {
_currentLayout,
_isActiveParticipant,
_isAudioOnly,
_isCurrentlyOnLargeVideo,
_isVirtualScreenshareParticipant,
_isScreenSharing,
_isVideoPlayable,
_participant,
_stageParticipantsVisible,
_videoTrack,
filmstripType = FILMSTRIP_TYPE.MAIN
} = props;
const tileViewActive = _currentLayout === LAYOUTS.TILE_VIEW;
const { canPlayEventReceived } = state;
return {
filmstripType,
isActiveParticipant: _isActiveParticipant,
isCurrentlyOnLargeVideo: _isCurrentlyOnLargeVideo,
isAudioOnly: _isAudioOnly,
tileViewActive,
isVideoPlayable: _isVideoPlayable,
canPlayEventReceived,
videoStream: Boolean(_videoTrack),
isRemoteParticipant: !_participant?.fakeParticipant && !_participant?.local,
isScreenSharing: _isScreenSharing,
isVirtualScreenshareParticipant: _isVirtualScreenshareParticipant,
stageParticipantsVisible: _stageParticipantsVisible,
videoStreamMuted: _videoTrack ? _videoTrack.muted : 'no stream'
};
}
/**
* Gets the tooltip position for the thumbnail indicators.
*
* @param {string} thumbnailType - The current thumbnail type.
* @returns {string}
*/
export function getIndicatorsTooltipPosition(thumbnailType?: string) {
return INDICATORS_TOOLTIP_POSITION[thumbnailType ?? ''] || 'top';
}
/**
* Returns whether or not the filmstrip is resizable.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function isFilmstripResizable(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
const _currentLayout = getCurrentLayout(state);
return !filmstrip?.disableResizable && !isMobileBrowser()
&& (_currentLayout === LAYOUTS.VERTICAL_FILMSTRIP_VIEW || _currentLayout === LAYOUTS.STAGE_FILMSTRIP_VIEW);
}
/**
* Whether or not grid should be displayed in the vertical filmstrip.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function showGridInVerticalView(state: IReduxState) {
const resizableFilmstrip = isFilmstripResizable(state);
const { width } = state['features/filmstrip'];
return resizableFilmstrip && ((width.current ?? 0) > FILMSTRIP_GRID_BREAKPOINT);
}
/**
* Gets the vertical filmstrip max width.
*
* @param {Object} state - Redux state.
* @returns {number}
*/
export function getVerticalViewMaxWidth(state: IReduxState) {
const { width } = state['features/filmstrip'];
const _resizableFilmstrip = isFilmstripResizable(state);
const _verticalViewGrid = showGridInVerticalView(state);
let maxWidth = _resizableFilmstrip
? width.current || DEFAULT_FILMSTRIP_WIDTH
: interfaceConfig.FILM_STRIP_MAX_HEIGHT || DEFAULT_FILMSTRIP_WIDTH;
// Adding 4px for the border-right and margin-right.
// On non-resizable filmstrip add 4px for the left margin and border.
// Also adding 7px for the scrollbar. Also adding 9px for the drag handle.
maxWidth += (_verticalViewGrid ? 0 : 11) + (_resizableFilmstrip ? 9 : 4);
return maxWidth;
}
/**
* Returns true if the scroll is displayed and false otherwise.
*
* @param {Object} state - The redux state.
* @returns {boolean} - True if the scroll is displayed and false otherwise.
*/
export function isFilmstripScrollVisible(state: IReduxState) {
const _currentLayout = getCurrentLayout(state);
let hasScroll = false;
switch (_currentLayout) {
case LAYOUTS.TILE_VIEW:
({ hasScroll = false } = state['features/filmstrip'].tileViewDimensions ?? {});
break;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
case LAYOUTS.STAGE_FILMSTRIP_VIEW: {
({ hasScroll = false } = state['features/filmstrip'].verticalViewDimensions);
break;
}
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: {
({ hasScroll = false } = state['features/filmstrip'].horizontalViewDimensions);
break;
}
}
return hasScroll;
}
/**
* Gets the ids of the active participants.
*
* @param {Object} state - Redux state.
* @returns {Array<string>}
*/
export function getActiveParticipantsIds(state: IReduxState) {
const { activeParticipants } = state['features/filmstrip'];
return activeParticipants.map(p => p.participantId);
}
/**
* Gets the ids of the active participants.
*
* @param {Object} state - Redux state.
* @returns {Array<Object>}
*/
export function getPinnedActiveParticipants(state: IReduxState) {
const { activeParticipants } = state['features/filmstrip'];
return activeParticipants.filter(p => p.pinned);
}
/**
* Get whether or not the stage filmstrip is available (enabled & can be used).
*
* @param {Object} state - Redux state.
* @param {number} minParticipantCount - The min number of participants for the stage filmstrip
* to be displayed.
* @returns {boolean}
*/
export function isStageFilmstripAvailable(state: IReduxState, minParticipantCount = 0) {
const { activeParticipants } = state['features/filmstrip'];
const { remoteScreenShares } = state['features/video-layout'];
const sharedVideo = isSharingStatus(state['features/shared-video']?.status ?? '');
return isStageFilmstripEnabled(state) && !sharedVideo
&& activeParticipants.length >= minParticipantCount
&& (isTopPanelEnabled(state) || remoteScreenShares.length === 0);
}
/**
* Whether the stage filmstrip should be displayed on the top.
*
* @param {Object} state - Redux state.
* @param {number} minParticipantCount - The min number of participants for the stage filmstrip
* to be displayed.
* @returns {boolean}
*/
export function isStageFilmstripTopPanel(state: IReduxState, minParticipantCount = 0) {
const { remoteScreenShares } = state['features/video-layout'];
return isTopPanelEnabled(state)
&& isStageFilmstripAvailable(state, minParticipantCount) && remoteScreenShares.length > 0;
}
/**
* Whether the stage filmstrip is disabled or not.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function isStageFilmstripEnabled(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
return Boolean(!filmstrip?.disableStageFilmstrip && interfaceConfig.VERTICAL_FILMSTRIP);
}
/**
* Whether the vertical/horizontal filmstrip is disabled.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function isFilmstripDisabled(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
return Boolean(filmstrip?.disabled);
}
/**
* Gets the thumbnail type by filmstrip type.
*
* @param {string} currentLayout - Current app layout.
* @param {string} filmstripType - The current filmstrip type.
* @returns {string}
*/
export function getThumbnailTypeFromLayout(currentLayout: string, filmstripType: string) {
switch (currentLayout) {
case LAYOUTS.TILE_VIEW:
return THUMBNAIL_TYPE.TILE;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
return THUMBNAIL_TYPE.VERTICAL;
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW:
return THUMBNAIL_TYPE.HORIZONTAL;
case LAYOUTS.STAGE_FILMSTRIP_VIEW:
if (filmstripType !== FILMSTRIP_TYPE.MAIN) {
return THUMBNAIL_TYPE.TILE;
}
return THUMBNAIL_TYPE.VERTICAL;
}
}
/**
* Returns the id of the participant displayed on the screen share filmstrip.
*
* @param {Object} state - Redux state.
* @returns {string} - The participant id.
*/
export function getScreenshareFilmstripParticipantId(state: IReduxState) {
const { screenshareFilmstripParticipantId } = state['features/filmstrip'];
const screenshares = state['features/video-layout'].remoteScreenShares;
let id = screenshares.find(sId => sId === screenshareFilmstripParticipantId);
if (!id && screenshares.length) {
id = screenshares[0];
}
return id;
}
/**
* Whether or not the top panel is enabled.
*
* @param {Object} state - Redux state.
* @returns {boolean}
*/
export function isTopPanelEnabled(state: IReduxState) {
const { filmstrip } = state['features/base/config'];
const participantsCount = getParticipantCount(state);
return !filmstrip?.disableTopPanel && participantsCount >= (filmstrip?.minParticipantCountForTopPanel ?? 50);
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/filmstrip');

View File

@@ -0,0 +1,37 @@
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { CLIENT_RESIZED, SAFE_AREA_INSETS_CHANGED, SET_ASPECT_RATIO } from '../base/responsive-ui/actionTypes';
import { setTileViewDimensions } from './actions.native';
import { updateRemoteParticipants, updateRemoteParticipantsOnLeave } from './functions.native';
import './subscriber.native';
/**
* The middleware of the feature Filmstrip.
*/
MiddlewareRegistry.register(store => next => action => {
if (action.type === PARTICIPANT_LEFT) {
// This have to be executed before we remove the participant from features/base/participants state in order to
// remove the related thumbnail component before we need to re-render it. If we do this after next()
// we will be in situation where the participant exists in the remoteParticipants array in features/filmstrip
// but doesn't exist in features/base/participants state which will lead to rendering a thumbnail for
// non-existing participant.
updateRemoteParticipantsOnLeave(store, action.participant?.id);
}
const result = next(action);
switch (action.type) {
case CLIENT_RESIZED:
case SAFE_AREA_INSETS_CHANGED:
case SET_ASPECT_RATIO:
store.dispatch(setTileViewDimensions());
break;
case PARTICIPANT_JOINED: {
updateRemoteParticipants(store, false, action.participant?.id);
break;
}
}
return result;
});

View File

@@ -0,0 +1,346 @@
import { batch } from 'react-redux';
// @ts-expect-error
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import {
DOMINANT_SPEAKER_CHANGED,
PARTICIPANT_JOINED,
PARTICIPANT_LEFT
} from '../base/participants/actionTypes';
import {
getDominantSpeakerParticipant,
getLocalParticipant,
getLocalScreenShareParticipant,
isScreenShareParticipant
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { CLIENT_RESIZED } from '../base/responsive-ui/actionTypes';
import { SETTINGS_UPDATED } from '../base/settings/actionTypes';
import { setTileView } from '../video-layout/actions.web';
import { LAYOUTS } from '../video-layout/constants';
import { getCurrentLayout } from '../video-layout/functions.web';
import { WHITEBOARD_ID } from '../whiteboard/constants';
import { isWhiteboardVisible } from '../whiteboard/functions';
import {
ADD_STAGE_PARTICIPANT,
CLEAR_STAGE_PARTICIPANTS,
REMOVE_STAGE_PARTICIPANT,
RESIZE_FILMSTRIP,
SET_USER_FILMSTRIP_WIDTH,
TOGGLE_PIN_STAGE_PARTICIPANT
} from './actionTypes';
import {
addStageParticipant,
removeStageParticipant,
setFilmstripHeight,
setFilmstripWidth,
setScreenshareFilmstripParticipant,
setStageParticipants
} from './actions.web';
import {
ACTIVE_PARTICIPANT_TIMEOUT,
DEFAULT_FILMSTRIP_WIDTH,
MAX_ACTIVE_PARTICIPANTS,
MIN_STAGE_VIEW_HEIGHT,
MIN_STAGE_VIEW_WIDTH,
TOP_FILMSTRIP_HEIGHT
} from './constants';
import {
getActiveParticipantsIds,
getPinnedActiveParticipants,
isFilmstripResizable,
isStageFilmstripAvailable,
isStageFilmstripTopPanel,
updateRemoteParticipants,
updateRemoteParticipantsOnLeave
} from './functions.web';
import './subscriber.web';
/**
* Map of timers.
*
* @type {Map}
*/
const timers = new Map();
/**
* The middleware of the feature Filmstrip.
*/
MiddlewareRegistry.register(store => next => action => {
if (action.type === PARTICIPANT_LEFT) {
// This has to be executed before we remove the participant from features/base/participants state in order to
// remove the related thumbnail component before we need to re-render it. If we do this after next()
// we will be in situation where the participant exists in the remoteParticipants array in features/filmstrip
// but doesn't exist in features/base/participants state which will lead to rendering a thumbnail for
// non-existing participant.
updateRemoteParticipantsOnLeave(store, action.participant?.id);
}
let result;
switch (action.type) {
case CLIENT_RESIZED: {
const state = store.getState();
if (isFilmstripResizable(state)) {
const { width: filmstripWidth, topPanelHeight } = state['features/filmstrip'];
const { clientHeight, videoSpaceWidth } = action;
let height, width;
if ((filmstripWidth.current ?? 0) > videoSpaceWidth - MIN_STAGE_VIEW_WIDTH) {
width = Math.max(videoSpaceWidth - MIN_STAGE_VIEW_WIDTH, DEFAULT_FILMSTRIP_WIDTH);
} else {
width = Math.min(videoSpaceWidth - MIN_STAGE_VIEW_WIDTH, filmstripWidth.userSet ?? 0);
}
if (width !== filmstripWidth.current) {
store.dispatch(setFilmstripWidth(width));
}
if ((topPanelHeight.current ?? 0) > clientHeight - MIN_STAGE_VIEW_HEIGHT) {
height = Math.max(clientHeight - MIN_STAGE_VIEW_HEIGHT, TOP_FILMSTRIP_HEIGHT);
} else {
height = Math.min(clientHeight - MIN_STAGE_VIEW_HEIGHT, topPanelHeight.userSet ?? 0);
}
if (height !== topPanelHeight.current) {
store.dispatch(setFilmstripHeight(height));
}
}
break;
}
case PARTICIPANT_JOINED: {
result = next(action);
if (isScreenShareParticipant(action.participant)) {
break;
}
updateRemoteParticipants(store, false, action.participant?.id);
break;
}
case SETTINGS_UPDATED: {
if (typeof action.settings?.localFlipX === 'boolean') {
// TODO: This needs to be removed once the large video is Reactified.
VideoLayout.onLocalFlipXChanged(action.settings.localFlipX);
}
if (action.settings?.disableSelfView) {
const state = store.getState();
const local = getLocalParticipant(state);
const localScreenShare = getLocalScreenShareParticipant(state);
const activeParticipantsIds = getActiveParticipantsIds(state);
if (activeParticipantsIds.find(id => id === local?.id)) {
store.dispatch(removeStageParticipant(local?.id ?? ''));
}
if (localScreenShare) {
if (activeParticipantsIds.find(id => id === localScreenShare.id)) {
store.dispatch(removeStageParticipant(localScreenShare.id));
}
}
}
if (action.settings?.maxStageParticipants !== undefined) {
const maxParticipants = action.settings.maxStageParticipants;
const { activeParticipants } = store.getState()['features/filmstrip'];
const newMax = Math.min(MAX_ACTIVE_PARTICIPANTS, maxParticipants);
if (newMax < activeParticipants.length) {
const toRemove = activeParticipants.slice(0, activeParticipants.length - newMax);
batch(() => {
toRemove.forEach(p => store.dispatch(removeStageParticipant(p.participantId)));
});
}
}
break;
}
case SET_USER_FILMSTRIP_WIDTH: {
VideoLayout.refreshLayout();
break;
}
case RESIZE_FILMSTRIP: {
const { width = 0 } = action;
store.dispatch(setFilmstripWidth(width));
break;
}
case ADD_STAGE_PARTICIPANT: {
const { dispatch, getState } = store;
const { participantId, pinned } = action;
const state = getState();
const { activeParticipants } = state['features/filmstrip'];
const { maxStageParticipants } = state['features/base/settings'];
const isWhiteboardActive = isWhiteboardVisible(state);
let queue;
if (activeParticipants.find(p => p.participantId === participantId)) {
queue = activeParticipants.filter(p => p.participantId !== participantId);
queue.push({
participantId,
pinned
});
const tid = timers.get(participantId);
clearTimeout(tid);
timers.delete(participantId);
} else if (activeParticipants.length < (maxStageParticipants ?? 0)) {
queue = [ ...activeParticipants, {
participantId,
pinned
} ];
} else {
const notPinnedIndex = activeParticipants.findIndex(p => !p.pinned);
if (notPinnedIndex === -1) {
if (pinned) {
queue = [ ...activeParticipants, {
participantId,
pinned
} ];
queue.shift();
}
} else {
queue = [ ...activeParticipants, {
participantId,
pinned
} ];
queue.splice(notPinnedIndex, 1);
}
}
if (participantId === WHITEBOARD_ID) {
// If the whiteboard is pinned, this action should clear the other pins.
queue = [ {
participantId,
pinned: true
} ];
} else if (isWhiteboardActive && Array.isArray(queue)) {
// When another participant is pinned, remove the whiteboard from the stage area.
queue = queue.filter(p => p?.participantId !== WHITEBOARD_ID);
}
// If queue is undefined we haven't made any changes to the active participants. This will mostly happen
// if the participant that we are trying to add is not pinned and all slots are currently taken by pinned
// participants.
// IMPORTANT: setting active participants to undefined will crash jitsi-meet.
if (typeof queue !== 'undefined') {
dispatch(setStageParticipants(queue));
if (!pinned) {
const timeoutId = setTimeout(() => dispatch(removeStageParticipant(participantId)),
ACTIVE_PARTICIPANT_TIMEOUT);
timers.set(participantId, timeoutId);
}
}
if (getCurrentLayout(state) === LAYOUTS.TILE_VIEW) {
dispatch(setTileView(false));
}
break;
}
case REMOVE_STAGE_PARTICIPANT: {
const state = store.getState();
const { participantId } = action;
const tid = timers.get(participantId);
clearTimeout(tid);
timers.delete(participantId);
const dominant = getDominantSpeakerParticipant(state);
if (participantId === dominant?.id) {
const timeoutId = setTimeout(() => store.dispatch(removeStageParticipant(participantId)),
ACTIVE_PARTICIPANT_TIMEOUT);
timers.set(participantId, timeoutId);
return;
}
break;
}
case DOMINANT_SPEAKER_CHANGED: {
const { id } = action.participant;
const state = store.getState();
const stageFilmstrip = isStageFilmstripAvailable(state);
const local = getLocalParticipant(state);
const currentLayout = getCurrentLayout(state);
const dominantSpeaker = getDominantSpeakerParticipant(state);
if (dominantSpeaker?.id === id || id === local?.id || currentLayout === LAYOUTS.TILE_VIEW) {
break;
}
if (stageFilmstrip) {
const isPinned = getPinnedActiveParticipants(state).some(p => p.participantId === id);
store.dispatch(addStageParticipant(id, Boolean(isPinned)));
}
break;
}
case PARTICIPANT_LEFT: {
const state = store.getState();
const { id } = action.participant;
const activeParticipantsIds = getActiveParticipantsIds(state);
if (activeParticipantsIds.find(pId => pId === id)) {
const tid = timers.get(id);
const { activeParticipants } = state['features/filmstrip'];
clearTimeout(tid);
timers.delete(id);
store.dispatch(setStageParticipants(activeParticipants.filter(p => p.participantId !== id)));
}
break;
}
case TOGGLE_PIN_STAGE_PARTICIPANT: {
const { dispatch, getState } = store;
const state = getState();
const { participantId } = action;
const pinnedParticipants = getPinnedActiveParticipants(state);
const dominant = getDominantSpeakerParticipant(state);
if (isStageFilmstripTopPanel(state, 2)) {
const screenshares = state['features/video-layout'].remoteScreenShares;
if (screenshares.find(sId => sId === participantId)) {
dispatch(setScreenshareFilmstripParticipant(participantId));
break;
}
}
if (pinnedParticipants.find(p => p.participantId === participantId)) {
if (dominant?.id === participantId) {
const { activeParticipants } = state['features/filmstrip'];
const queue = activeParticipants.map(p => {
if (p.participantId === participantId) {
return {
participantId,
pinned: false
};
}
return p;
});
dispatch(setStageParticipants(queue));
} else {
dispatch(removeStageParticipant(participantId));
}
} else {
dispatch(addStageParticipant(participantId, true));
}
break;
}
case CLEAR_STAGE_PARTICIPANTS: {
const activeParticipants = getActiveParticipantsIds(store.getState());
activeParticipants.forEach(pId => {
const tid = timers.get(pId);
clearTimeout(tid);
timers.delete(pId);
});
}
}
return result ?? next(action);
});

View File

@@ -0,0 +1,418 @@
import { PARTICIPANT_LEFT } from '../base/participants/actionTypes';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CLEAR_STAGE_PARTICIPANTS,
REMOVE_STAGE_PARTICIPANT,
SET_FILMSTRIP_ENABLED,
SET_FILMSTRIP_HEIGHT,
SET_FILMSTRIP_VISIBLE,
SET_FILMSTRIP_WIDTH,
SET_HORIZONTAL_VIEW_DIMENSIONS,
SET_REMOTE_PARTICIPANTS,
SET_SCREENSHARE_FILMSTRIP_PARTICIPANT,
SET_SCREENSHARING_TILE_DIMENSIONS,
SET_STAGE_FILMSTRIP_DIMENSIONS,
SET_STAGE_PARTICIPANTS,
SET_TILE_VIEW_DIMENSIONS,
SET_TOP_PANEL_VISIBILITY,
SET_USER_FILMSTRIP_HEIGHT,
SET_USER_FILMSTRIP_WIDTH,
SET_USER_IS_RESIZING,
SET_VERTICAL_VIEW_DIMENSIONS,
SET_VISIBLE_REMOTE_PARTICIPANTS,
SET_VOLUME
} from './actionTypes';
const DEFAULT_STATE = {
/**
* The list of participants to be displayed on the stage filmstrip.
*/
activeParticipants: [],
/**
* The indicator which determines whether the {@link Filmstrip} is enabled.
*
* @public
* @type {boolean}
*/
enabled: true,
/**
* The horizontal view dimensions.
*
* @public
* @type {Object}
*/
horizontalViewDimensions: {},
/**
* Whether or not the user is actively resizing the filmstrip.
*
* @public
* @type {boolean}
*/
isResizing: false,
/**
* The custom audio volume levels per participant.
*
* @type {Object}
*/
participantsVolume: {},
/**
* The ordered IDs of the remote participants displayed in the filmstrip.
*
* @public
* @type {Array<string>}
*/
remoteParticipants: [],
/**
* The dimensions of the screenshare filmstrip.
*/
screenshareFilmstripDimensions: {},
/**
* The id of the participant whose screenshare to
* display on the screenshare filmstrip.
*/
screenshareFilmstripParticipantId: null,
/**
* The stage filmstrip view dimensions.
*
* @public
* @type {Object}
*/
stageFilmstripDimensions: {},
/**
* The tile view dimensions.
*
* @public
* @type {Object}
*/
tileViewDimensions: {},
/**
* The height of the resizable top panel.
*/
topPanelHeight: {
/**
* Current height. Affected by: user top panel resize,
* window resize.
*/
current: null,
/**
* Height set by user resize. Used as the preferred height.
*/
userSet: null
},
/**
* The indicator determines if the top panel is visible.
*/
topPanelVisible: true,
/**
* The vertical view dimensions.
*
* @public
* @type {Object}
*/
verticalViewDimensions: {},
/**
* The indicator which determines whether the {@link Filmstrip} is visible.
*
* @public
* @type {boolean}
*/
visible: true,
/**
* The end index in the remote participants array that is visible in the filmstrip.
*
* @public
* @type {number}
*/
visibleParticipantsEndIndex: 0,
/**
* The start index in the remote participants array that is visible in the filmstrip.
*
* @public
* @type {number}
*/
visibleParticipantsStartIndex: 0,
/**
* The visible remote participants in the filmstrip.
*
* @public
* @type {Set<string>}
*/
visibleRemoteParticipants: new Set<string>(),
/**
* The width of the resizable filmstrip.
*
* @public
* @type {Object}
*/
width: {
/**
* Current width. Affected by: user filmstrip resize,
* window resize, panels open/ close.
*/
current: null,
/**
* Width set by user resize. Used as the preferred width.
*/
userSet: null
}
};
interface IDimensions {
height: number;
width: number;
}
interface IFilmstripDimensions {
columns?: number;
filmstripHeight?: number;
filmstripWidth?: number;
gridDimensions?: {
columns: number;
rows: number;
};
hasScroll?: boolean;
thumbnailSize?: IDimensions;
}
export interface IFilmstripState {
activeParticipants: Array<{
participantId: string;
pinned?: boolean;
}>;
enabled: boolean;
horizontalViewDimensions: {
hasScroll?: boolean;
local?: IDimensions;
remote?: IDimensions;
remoteVideosContainer?: IDimensions;
};
isResizing: boolean;
participantsVolume: {
[participantId: string]: number;
};
remoteParticipants: string[];
screenshareFilmstripDimensions: {
filmstripHeight?: number;
filmstripWidth?: number;
thumbnailSize?: IDimensions;
};
screenshareFilmstripParticipantId?: string | null;
stageFilmstripDimensions: IFilmstripDimensions;
tileViewDimensions?: IFilmstripDimensions;
topPanelHeight: {
current: number | null;
userSet: number | null;
};
topPanelVisible: boolean;
verticalViewDimensions: {
gridView?: {
gridDimensions: {
columns: number;
rows: number;
};
hasScroll: boolean;
thumbnailSize: IDimensions;
};
hasScroll?: boolean;
local?: IDimensions;
remote?: IDimensions;
remoteVideosContainer?: IDimensions;
};
visible: boolean;
visibleParticipantsEndIndex: number;
visibleParticipantsStartIndex: number;
visibleRemoteParticipants: Set<string>;
width: {
current: number | null;
userSet: number | null;
};
}
ReducerRegistry.register<IFilmstripState>(
'features/filmstrip',
(state = DEFAULT_STATE, action): IFilmstripState => {
switch (action.type) {
case SET_FILMSTRIP_ENABLED:
return {
...state,
enabled: action.enabled
};
case SET_FILMSTRIP_VISIBLE:
return {
...state,
visible: action.visible
};
case SET_HORIZONTAL_VIEW_DIMENSIONS:
return {
...state,
horizontalViewDimensions: action.dimensions
};
case SET_REMOTE_PARTICIPANTS: {
state.remoteParticipants = action.participants;
const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state;
state.visibleRemoteParticipants = new Set(state.remoteParticipants.slice(startIndex, endIndex + 1));
return { ...state };
}
case SET_TILE_VIEW_DIMENSIONS:
return {
...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 SET_VISIBLE_REMOTE_PARTICIPANTS: {
const { endIndex, startIndex } = action;
const { remoteParticipants } = state;
const visibleRemoteParticipants = new Set(remoteParticipants.slice(startIndex, endIndex + 1));
return {
...state,
visibleParticipantsStartIndex: startIndex,
visibleParticipantsEndIndex: endIndex,
visibleRemoteParticipants
};
}
case PARTICIPANT_LEFT: {
const { id, local } = action.participant;
if (local) {
return state;
}
delete state.participantsVolume[id];
return {
...state
};
}
case SET_FILMSTRIP_HEIGHT:{
return {
...state,
topPanelHeight: {
...state.topPanelHeight,
current: action.height
}
};
}
case SET_FILMSTRIP_WIDTH: {
return {
...state,
width: {
...state.width,
current: action.width
}
};
}
case SET_USER_FILMSTRIP_HEIGHT: {
const { height } = action;
return {
...state,
topPanelHeight: {
current: height,
userSet: height
}
};
}
case SET_USER_FILMSTRIP_WIDTH: {
const { width } = action;
return {
...state,
width: {
current: width,
userSet: width
}
};
}
case SET_USER_IS_RESIZING: {
return {
...state,
isResizing: action.resizing
};
}
case SET_STAGE_FILMSTRIP_DIMENSIONS: {
return {
...state,
stageFilmstripDimensions: action.dimensions
};
}
case SET_STAGE_PARTICIPANTS: {
return {
...state,
activeParticipants: action.queue
};
}
case REMOVE_STAGE_PARTICIPANT: {
return {
...state,
activeParticipants: state.activeParticipants.filter(p => p.participantId !== action.participantId)
};
}
case CLEAR_STAGE_PARTICIPANTS: {
return {
...state,
activeParticipants: []
};
}
case SET_SCREENSHARING_TILE_DIMENSIONS: {
return {
...state,
screenshareFilmstripDimensions: action.dimensions
};
}
case SET_TOP_PANEL_VISIBILITY: {
return {
...state,
topPanelVisible: action.visible
};
}
case SET_SCREENSHARE_FILMSTRIP_PARTICIPANT: {
return {
...state,
screenshareFilmstripParticipantId: action.participantId
};
}
}
return state;
});

View File

@@ -0,0 +1,35 @@
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { isFilmstripScrollVisible, updateRemoteParticipants } from './functions';
/**
* Listens for changes to the screensharing status of the remote participants to recompute the reordered list of the
* remote endpoints.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/video-layout'].remoteScreenShares,
/* listener */ (remoteScreenShares, store) => updateRemoteParticipants(store));
/**
* Listens for changes to the remote screenshare participants to recompute the reordered list of the remote endpoints.
* We force updateRemoteParticipants to make sure it executes and for the case where
* sortedRemoteVirtualScreenshareParticipants becomes 0. We do not want to short circuit it in case of no screen-sharers
* and no scroll and triggered for dominant speaker changed.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].sortedRemoteVirtualScreenshareParticipants,
/* listener */ (sortedRemoteVirtualScreenshareParticipants, store) => updateRemoteParticipants(store, true));
/**
* Listens for changes to the dominant speaker to recompute the reordered list of the remote endpoints.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].dominantSpeaker,
/* listener */ (dominantSpeaker, store) => updateRemoteParticipants(store));
/**
* Listens for changes in the filmstrip scroll visibility.
*/
StateListenerRegistry.register(
/* selector */ state => isFilmstripScrollVisible(state),
/* listener */ (_, store) => updateRemoteParticipants(store));

View File

@@ -0,0 +1,42 @@
import { getCurrentConference } from '../base/conference/functions';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { shouldDisplayTileView } from '../video-layout/functions.native';
import { setRemoteParticipants, setTileViewDimensions } from './actions.native';
import { getTileViewParticipantCount } from './functions.native';
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 => getTileViewParticipantCount(state),
/* 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) => {
if (isTileView) {
store.dispatch(setTileViewDimensions());
}
});
/**
* Listens for changes in the current conference and clears remote participants from this feature.
*/
StateListenerRegistry.register(
state => getCurrentConference(state),
(conference, { dispatch }, previousConference) => {
if (conference !== previousConference) {
dispatch(setRemoteParticipants([]));
}
});

View File

@@ -0,0 +1,237 @@
import { pinParticipant } from '../base/participants/actions';
import { getParticipantCountWithFake } from '../base/participants/functions';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { clientResized, setNarrowLayout } from '../base/responsive-ui/actions';
import { getHideSelfView } from '../base/settings/functions.any';
import { selectParticipantInLargeVideo } from '../large-video/actions.any';
import { getParticipantsPaneOpen } from '../participants-pane/functions';
import { setOverflowDrawer } from '../toolbox/actions.web';
import { LAYOUTS } from '../video-layout/constants';
import { getCurrentLayout, shouldDisplayTileView } from '../video-layout/functions.web';
import { clearStageParticipants,
setFilmstripVisible,
setHorizontalViewDimensions,
setScreenshareFilmstripParticipant,
setScreensharingTileDimensions,
setStageFilmstripViewDimensions,
setTileViewDimensions,
setVerticalViewDimensions
} from './actions.web';
import {
ASPECT_RATIO_BREAKPOINT,
DISPLAY_DRAWER_THRESHOLD
} from './constants';
import {
isFilmstripResizable,
isTopPanelEnabled
} from './functions.web';
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 => {
return {
numberOfParticipants: getParticipantCountWithFake(state),
disableSelfView: getHideSelfView(state),
localScreenShare: state['features/base/participants'].localScreenShare
};
},
/* listener */ (currentState, store) => {
const state = store.getState();
const resizableFilmstrip = isFilmstripResizable(state);
if (shouldDisplayTileView(state)) {
store.dispatch(setTileViewDimensions());
}
if (resizableFilmstrip) {
store.dispatch(setVerticalViewDimensions());
}
}, {
deepEquals: true
});
/**
* Listens for changes in the selected layout to calculate the dimensions of the tile view grid and horizontal view.
*/
StateListenerRegistry.register(
/* selector */ state => {
const { clientHeight, videoSpaceWidth } = state['features/base/responsive-ui'];
return {
layout: getCurrentLayout(state),
height: clientHeight,
width: videoSpaceWidth
};
},
/* listener */ ({ layout }, store) => {
switch (layout) {
case LAYOUTS.TILE_VIEW: {
const { pinnedParticipant } = store.getState()['features/base/participants'];
if (pinnedParticipant) {
store.dispatch(pinParticipant(null));
}
store.dispatch(clearStageParticipants());
store.dispatch(setTileViewDimensions());
break;
}
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW:
store.dispatch(setHorizontalViewDimensions());
break;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
store.dispatch(setVerticalViewDimensions());
if (store.getState()['features/filmstrip'].activeParticipants.length > 1) {
store.dispatch(clearStageParticipants());
}
break;
case LAYOUTS.STAGE_FILMSTRIP_VIEW:
store.dispatch(pinParticipant(null));
break;
}
}, {
deepEquals: true
});
/**
* Listens for changes in the chat state to recompute available width.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/chat'].isOpen,
/* listener */ (isChatOpen, store) => {
const { innerWidth, innerHeight } = window;
store.dispatch(clientResized(innerWidth, innerHeight));
});
/**
* Listens for changes in the participant pane state to calculate the
* dimensions of the tile view grid and the tiles.
*/
StateListenerRegistry.register(
/* selector */ getParticipantsPaneOpen,
/* listener */ (isOpen, store) => {
const { innerWidth, innerHeight } = window;
store.dispatch(clientResized(innerWidth, innerHeight));
});
/**
* Listens for changes in the client width to determine whether the overflow menu(s) should be displayed as drawers.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/responsive-ui'].videoSpaceWidth < DISPLAY_DRAWER_THRESHOLD,
/* listener */ (widthBelowThreshold, store) => {
store.dispatch(setOverflowDrawer(widthBelowThreshold));
store.dispatch(setNarrowLayout(widthBelowThreshold));
});
/**
* Gracefully hide/show the filmstrip when going past threshold.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/responsive-ui'].videoSpaceWidth < ASPECT_RATIO_BREAKPOINT,
/* listener */ (widthBelowThreshold, store) => {
const state = store.getState();
const { disableFilmstripAutohiding } = state['features/base/config'];
if (!disableFilmstripAutohiding) {
store.dispatch(setFilmstripVisible(!widthBelowThreshold));
}
});
/**
* Listens for changes in the filmstrip width to determine the size of the tiles.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/filmstrip'].width?.current,
/* listener */(_, store) => {
store.dispatch(setVerticalViewDimensions());
});
/**
* Listens for changes in the filmstrip config to determine the size of the tiles.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/config'].filmstrip?.disableResizable,
/* listener */(_, store) => {
store.dispatch(setVerticalViewDimensions());
});
/**
* Listens for changes to determine the size of the stage filmstrip tiles.
*/
StateListenerRegistry.register(
/* selector */ state => {
return {
remoteScreenShares: state['features/video-layout'].remoteScreenShares.length,
length: state['features/filmstrip'].activeParticipants.length,
width: state['features/filmstrip'].width?.current,
visible: state['features/filmstrip'].visible,
clientWidth: state['features/base/responsive-ui'].videoSpaceWidth,
clientHeight: state['features/base/responsive-ui'].clientHeight,
tileView: state['features/video-layout'].tileViewEnabled,
height: state['features/filmstrip'].topPanelHeight?.current
};
},
/* listener */(_, store) => {
if (getCurrentLayout(store.getState()) === LAYOUTS.STAGE_FILMSTRIP_VIEW) {
store.dispatch(setStageFilmstripViewDimensions());
}
}, {
deepEquals: true
});
/**
* Listens for changes in the active participants count determine the stage participant (when
* there's just one).
*/
StateListenerRegistry.register(
/* selector */ state => state['features/filmstrip'].activeParticipants,
/* listener */(activeParticipants, store) => {
if (activeParticipants.length <= 1) {
store.dispatch(selectParticipantInLargeVideo());
}
});
/**
* Listens for changes to determine the size of the screenshare filmstrip.
*/
StateListenerRegistry.register(
/* selector */ state => {
return {
length: state['features/video-layout'].remoteScreenShares.length,
clientWidth: state['features/base/responsive-ui'].videoSpaceWidth,
clientHeight: state['features/base/responsive-ui'].clientHeight,
height: state['features/filmstrip'].topPanelHeight?.current,
width: state['features/filmstrip'].width?.current,
visible: state['features/filmstrip'].visible,
topPanelVisible: state['features/filmstrip'].topPanelVisible
};
},
/* listener */({ length }, store) => {
if (length >= 1 && isTopPanelEnabled(store.getState())) {
store.dispatch(setScreensharingTileDimensions());
}
}, {
deepEquals: true
});
/**
* Listens for changes to clear invalid data.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/video-layout'].remoteScreenShares.length,
/* listener */(length, store) => {
if (length === 0) {
store.dispatch(setScreenshareFilmstripParticipant());
}
}, {
deepEquals: true
});