feat(Filmstrip): Reorder the visible participants in the filmstrip. (#9707)

* feat(Filmstrip): Reorder the visible participants in the filmstrip.
The participants are ordered alphabetically and the endpoints with screenshares, shared-videos and dominant speakers (in that order) are bumped to the top of the list. The local participant is also moved to the top left corner as opposed to the bottom right corner.

* squash: Implement review comments.

* squash: store alphabetically sorted list in redux and move shared videos to top.

* squash: Use the DEFAULT_REMOTE_DISPLAY_NAME from interfaceConfig for users without a display name.
This commit is contained in:
Jaya Allamsetty 2021-08-18 18:34:01 -04:00 committed by GitHub
parent a7a44902ec
commit 40099e97ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 228 additions and 85 deletions

View File

@ -2106,7 +2106,7 @@ export default {
room.on(
JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED,
id => APP.store.dispatch(dominantSpeakerChanged(id, room)));
(dominant, previous) => APP.store.dispatch(dominantSpeakerChanged(dominant, previous, room)));
room.on(
JitsiConferenceEvents.CONFERENCE_CREATED_TIMESTAMP,

View File

@ -170,7 +170,7 @@ function _addConferenceListeners(conference, dispatch, state) {
conference.on(
JitsiConferenceEvents.DOMINANT_SPEAKER_CHANGED,
id => dispatch(dominantSpeakerChanged(id, conference)));
(dominant, previous) => dispatch(dominantSpeakerChanged(dominant, previous, conference)));
conference.on(
JitsiConferenceEvents.ENDPOINT_MESSAGE_RECEIVED,

View File

@ -6,7 +6,9 @@
* {
* type: DOMINANT_SPEAKER_CHANGED,
* participant: {
* id: string
* conference: JitsiConference,
* id: string,
* previousSpeakers: Array<string>
* }
* }
*/

View File

@ -31,7 +31,8 @@ import logger from './logger';
/**
* Create an action for when dominant speaker changes.
*
* @param {string} id - Participant's ID.
* @param {string} dominantSpeaker - Participant ID of the dominant speaker.
* @param {Array<string>} previousSpeakers - Participant IDs of the previous speakers.
* @param {JitsiConference} conference - The {@code JitsiConference} associated
* with the participant identified by the specified {@code id}. Only the local
* participant is allowed to not specify an associated {@code JitsiConference}
@ -40,16 +41,18 @@ import logger from './logger';
* type: DOMINANT_SPEAKER_CHANGED,
* participant: {
* conference: JitsiConference,
* id: string
* id: string,
* previousSpeakers: Array<string>
* }
* }}
*/
export function dominantSpeakerChanged(id, conference) {
export function dominantSpeakerChanged(dominantSpeaker, previousSpeakers, conference) {
return {
type: DOMINANT_SPEAKER_CHANGED,
participant: {
conference,
id
id: dominantSpeaker,
previousSpeakers
}
};
}

View File

@ -14,6 +14,8 @@ import {
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
import { isParticipantModerator } from './functions';
declare var interfaceConfig: Object;
/**
* Participant object.
* @typedef {Object} Participant
@ -51,13 +53,15 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
];
const DEFAULT_STATE = {
haveParticipantWithScreenSharingFeature: false,
dominantSpeaker: undefined,
everyoneIsModerator: false,
pinnedParticipant: undefined,
fakeParticipants: new Map(),
haveParticipantWithScreenSharingFeature: false,
local: undefined,
pinnedParticipant: undefined,
remote: new Map(),
fakeParticipants: new Map()
sortedRemoteParticipants: new Map(),
speakersList: []
};
/**
@ -91,8 +95,13 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
}
case DOMINANT_SPEAKER_CHANGED: {
const { participant } = action;
const { id } = participant;
const { dominantSpeaker } = state;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const speakersList = [];
// Update the speakers list.
id !== local?.id && speakersList.push(id);
speakersList.push(...previousSpeakers.filter(p => p !== local?.id));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
@ -102,7 +111,8 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) {
return {
...state,
dominantSpeaker: id
dominantSpeaker: id,
speakersList
};
}
@ -180,21 +190,22 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
}
case PARTICIPANT_JOINED: {
const participant = _participantJoined(action);
const { id, isFakeParticipant, name, pinned } = participant;
const { pinnedParticipant, dominantSpeaker } = state;
if (participant.pinned) {
if (pinned) {
if (pinnedParticipant) {
_updateParticipantProperty(state, pinnedParticipant, 'pinned', false);
}
state.pinnedParticipant = participant.id;
state.pinnedParticipant = id;
}
if (participant.dominantSpeaker) {
if (dominantSpeaker) {
_updateParticipantProperty(state, dominantSpeaker, 'dominantSpeaker', false);
}
state.dominantSpeaker = participant.id;
state.dominantSpeaker = id;
}
const isModerator = isParticipantModerator(participant);
@ -213,10 +224,21 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
};
}
state.remote.set(participant.id, participant);
state.remote.set(id, participant);
if (participant.isFakeParticipant) {
state.fakeParticipants.set(participant.id, participant);
// Insert the new participant.
const displayName = name
?? (typeof interfaceConfig === 'object' ? interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME : 'Fellow Jitser');
const sortedRemoteParticipants = Array.from(state.sortedRemoteParticipants);
sortedRemoteParticipants.push([ id, displayName ]);
sortedRemoteParticipants.sort((a, b) => a[1].localeCompare(b[1]));
// The sort order of participants is preserved since Map remembers the original insertion order of the keys.
state.sortedRemoteParticipants = new Map(sortedRemoteParticipants);
if (isFakeParticipant) {
state.fakeParticipants.set(id, participant);
}
return { ...state };
@ -242,6 +264,8 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
return state;
}
state.sortedRemoteParticipants.delete(id);
if (!state.everyoneIsModerator && !isParticipantModerator(oldParticipant)) {
state.everyoneIsModerator = _isEveryoneModerator(state);
}
@ -272,6 +296,9 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
state.dominantSpeaker = undefined;
}
// Remove the participant from the list of speakers.
state.speakersList = state.speakersList.filter(speaker => speaker !== id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;
}

View File

@ -50,6 +50,15 @@ export const SET_TILE_VIEW_DIMENSIONS = 'SET_TILE_VIEW_DIMENSIONS';
*/
export const SET_HORIZONTAL_VIEW_DIMENSIONS = 'SET_HORIZONTAL_VIEW_DIMENSIONS';
/**
* The type of (redux) action which sets the 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.
*

View File

@ -5,6 +5,7 @@ import { getLocalParticipant, getRemoteParticipants, pinParticipant } from '../b
import {
SET_HORIZONTAL_VIEW_DIMENSIONS,
SET_REMOTE_PARTICIPANTS,
SET_TILE_VIEW_DIMENSIONS,
SET_VERTICAL_VIEW_DIMENSIONS,
SET_VISIBLE_REMOTE_PARTICIPANTS,
@ -25,6 +26,23 @@ import {
calculateThumbnailSizeForVerticalView
} from './functions';
/**
* 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 dimensions of the tile view grid.
*

View File

@ -269,11 +269,11 @@ class Filmstrip extends PureComponent <Props> {
return `empty-${index}`;
}
if (index === _remoteParticipantsLength) {
if (index === 0) {
return 'local';
}
return _remoteParticipants[index];
return _remoteParticipants[index - 1];
}
_onListItemsRendered: Object => void;
@ -287,7 +287,7 @@ class Filmstrip extends PureComponent <Props> {
_onListItemsRendered({ visibleStartIndex, visibleStopIndex }) {
const { dispatch } = this.props;
dispatch(setVisibleRemoteParticipants(visibleStartIndex, visibleStopIndex));
dispatch(setVisibleRemoteParticipants(visibleStartIndex, visibleStopIndex + 1));
}
_onGridItemsRendered: Object => void;
@ -305,9 +305,12 @@ class Filmstrip extends PureComponent <Props> {
visibleRowStopIndex
}) {
const { _columns, dispatch } = this.props;
const startIndex = (visibleRowStartIndex * _columns) + visibleColumnStartIndex;
let startIndex = (visibleRowStartIndex * _columns) + visibleColumnStartIndex;
const endIndex = (visibleRowStopIndex * _columns) + visibleColumnStopIndex;
// In tile view, the start index needs to be offset by 1 because the first participant is the local
// participant.
startIndex = startIndex > 0 ? startIndex - 1 : 0;
dispatch(setVisibleRemoteParticipants(startIndex, endIndex));
}

View File

@ -126,7 +126,8 @@ function _mapStateToProps(state, ownProps) {
return {};
}
if (index === remoteParticipantsLength) {
// Make the local participant as the first thumbnail (top left corner) in tile view.
if (index === 0) {
return {
_participantID: 'local',
_horizontalOffset: horizontalOffset
@ -134,7 +135,7 @@ function _mapStateToProps(state, ownProps) {
}
return {
_participantID: remoteParticipants[index],
_participantID: remoteParticipants[index - 1],
_horizontalOffset: horizontalOffset
};
}

View File

@ -16,6 +16,7 @@ import {
isRemoteTrackMuted
} from '../base/tracks/functions';
import { setRemoteParticipants } from './actions.web';
import {
ASPECT_RATIO_BREAKPOINT,
DISPLAY_AVATAR,
@ -265,3 +266,36 @@ export function computeDisplayMode(input: Object) {
// check hovering and change state to avatar with name
return isHovered ? DISPLAY_AVATAR_WITH_NAME : DISPLAY_AVATAR;
}
/**
* Computes the reorderd list of the remote participants.
*
* @param {*} store - The redux store.
* @returns {void}
* @private
*/
export function updateRemoteParticipants(store: Object) {
const state = store.getState();
const { fakeParticipants, sortedRemoteParticipants, speakersList } = state['features/base/participants'];
const { remoteScreenShares } = state['features/video-layout'];
const screenShares = (remoteScreenShares || []).slice();
let speakers = (speakersList || []).slice();
const remoteParticipants = new Map(sortedRemoteParticipants);
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
for (const screenshare of screenShares) {
remoteParticipants.delete(screenshare);
speakers = speakers.filter(speaker => speaker !== screenshare);
}
for (const sharedVideo of sharedVideos) {
remoteParticipants.delete(sharedVideo);
speakers = speakers.filter(speaker => speaker !== sharedVideo);
}
for (const speaker of speakers) {
remoteParticipants.delete(speaker);
}
const reorderedParticipants
= [ ...screenShares.reverse(), ...sharedVideos, ...speakers, ...Array.from(remoteParticipants.keys()) ];
store.dispatch(setRemoteParticipants(reorderedParticipants));
}

View File

@ -1,6 +1,7 @@
// @flow
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../base/participants';
import { MiddlewareRegistry } from '../base/redux';
import { CLIENT_RESIZED } from '../base/responsive-ui';
import { SETTINGS_UPDATED } from '../base/settings';
@ -9,8 +10,13 @@ import {
LAYOUTS
} from '../video-layout';
import { setHorizontalViewDimensions, setTileViewDimensions, setVerticalViewDimensions } from './actions.web';
import {
setHorizontalViewDimensions,
setRemoteParticipants,
setTileViewDimensions,
setVerticalViewDimensions
} from './actions.web';
import { updateRemoteParticipants } from './functions.web';
import './subscriber.web';
/**
@ -41,6 +47,14 @@ MiddlewareRegistry.register(store => next => action => {
}
break;
}
case PARTICIPANT_JOINED: {
updateRemoteParticipants(store);
break;
}
case PARTICIPANT_LEFT: {
_updateRemoteParticipantsOnLeave(store, 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.
@ -53,3 +67,22 @@ MiddlewareRegistry.register(store => next => action => {
return result;
});
/**
* 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
*/
function _updateRemoteParticipantsOnLeave(store, participantId = 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)));
}

View File

@ -1,12 +1,13 @@
// @flow
import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../base/participants';
import { PARTICIPANT_LEFT } from '../base/participants';
import { ReducerRegistry } from '../base/redux';
import {
SET_FILMSTRIP_ENABLED,
SET_FILMSTRIP_VISIBLE,
SET_HORIZONTAL_VIEW_DIMENSIONS,
SET_REMOTE_PARTICIPANTS,
SET_TILE_VIEW_DIMENSIONS,
SET_VERTICAL_VIEW_DIMENSIONS,
SET_VISIBLE_REMOTE_PARTICIPANTS,
@ -40,8 +41,8 @@ const DEFAULT_STATE = {
/**
* The ordered IDs of the remote participants displayed in the filmstrip.
*
* NOTE: Currently the order will match the one from the base/participants array. But this is good initial step for
* reordering the remote participants.
* @public
* @type {Array<string>}
*/
remoteParticipants: [],
@ -77,22 +78,21 @@ const DEFAULT_STATE = {
*/
visibleParticipantsEndIndex: 0,
/**
* The visible participants in the filmstrip.
*
* @public
* @type {Array<string>}
*/
visibleParticipants: [],
/**
* The start index in the remote participants array that is visible in the filmstrip.
*
* @public
* @type {number}
*/
visibleParticipantsStartIndex: 0
visibleParticipantsStartIndex: 0,
/**
* The visible remote participants in the filmstrip.
*
* @public
* @type {Set<string>}
*/
visibleRemoteParticipants: new Set()
};
ReducerRegistry.register(
@ -116,6 +116,14 @@ ReducerRegistry.register(
...state,
horizontalViewDimensions: action.dimensions
};
case SET_REMOTE_PARTICIPANTS: {
const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state;
state.remoteParticipants = action.participants;
state.visibleRemoteParticipants = new Set(state.remoteParticipants.slice(startIndex, endIndex));
return { ...state };
}
case SET_TILE_VIEW_DIMENSIONS:
return {
...state,
@ -138,27 +146,13 @@ ReducerRegistry.register(
[action.participantId]: action.volume
}
};
case SET_VISIBLE_REMOTE_PARTICIPANTS:
case SET_VISIBLE_REMOTE_PARTICIPANTS: {
return {
...state,
visibleParticipantsStartIndex: action.startIndex,
visibleParticipantsEndIndex: action.endIndex,
visibleParticipants: state.remoteParticipants.slice(action.startIndex, action.endIndex + 1)
visibleRemoteParticipants: new Set(state.remoteParticipants.slice(action.startIndex, action.endIndex))
};
case PARTICIPANT_JOINED: {
const { id, local } = action.participant;
if (!local) {
state.remoteParticipants = [ ...state.remoteParticipants, id ];
const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state;
if (state.remoteParticipants.length - 1 <= endIndex) {
state.visibleParticipants = state.remoteParticipants.slice(startIndex, endIndex + 1);
}
}
return state;
}
case PARTICIPANT_LEFT: {
const { id, local } = action.participant;
@ -166,25 +160,6 @@ ReducerRegistry.register(
if (local) {
return state;
}
let removedParticipantIndex = 0;
state.remoteParticipants = state.remoteParticipants.filter((participantId, index) => {
if (participantId === id) {
removedParticipantIndex = index;
return false;
}
return true;
});
const { visibleParticipantsStartIndex: startIndex, visibleParticipantsEndIndex: endIndex } = state;
if (removedParticipantIndex >= startIndex && removedParticipantIndex <= endIndex) {
state.visibleParticipants = state.remoteParticipants.slice(startIndex, endIndex + 1);
}
delete state.participantsVolume[id];
return state;

View File

@ -8,13 +8,18 @@ import { getParticipantsPaneOpen } from '../participants-pane/functions';
import { setOverflowDrawer } from '../toolbox/actions.web';
import { getCurrentLayout, getTileViewGridDimensions, shouldDisplayTileView, LAYOUTS } from '../video-layout';
import { setHorizontalViewDimensions, setTileViewDimensions, setVerticalViewDimensions } from './actions.web';
import {
setHorizontalViewDimensions,
setTileViewDimensions,
setVerticalViewDimensions
} from './actions.web';
import {
ASPECT_RATIO_BREAKPOINT,
DISPLAY_DRAWER_THRESHOLD,
SINGLE_COLUMN_BREAKPOINT,
TWO_COLUMN_BREAKPOINT
} from './constants';
import { updateRemoteParticipants } from './functions.web';
/**
* Listens for changes in the number of participants to calculate the dimensions of the tile view grid and the tiles.
@ -153,3 +158,36 @@ StateListenerRegistry.register(
store.dispatch(setTileViewDimensions(gridDimensions));
}
});
/**
* 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 dominant speaker to recompute the reordered list of the remote endpoints.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/base/participants'].dominantSpeaker,
/* listener */ (dominantSpeaker, store) => _reorderDominantSpeakers(store));
/**
* Private helper function that reorders the remote participants based on dominant speaker changes.
*
* @param {*} store - The redux store.
* @returns {void}
* @private
*/
function _reorderDominantSpeakers(store) {
const state = store.getState();
const { dominantSpeaker, local } = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
// Reorder the participants if the new dominant speaker is currently not visible.
if (dominantSpeaker !== local?.id && !visibleRemoteParticipants.has(dominantSpeaker)) {
updateRemoteParticipants(store);
}
}

View File

@ -22,8 +22,8 @@ declare var APP: Object;
* scrolling through the thumbnails prompting updates to the selected endpoints.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/filmstrip'].visibleParticipants,
/* listener */ debounce((visibleParticipants, store) => {
/* selector */ state => state['features/filmstrip'].visibleRemoteParticipants,
/* listener */ debounce((visibleRemoteParticipants, store) => {
_updateReceiverVideoConstraints(store);
}, 100));
@ -191,11 +191,11 @@ function _updateReceiverVideoConstraints({ getState }) {
const { maxReceiverVideoQuality, preferredVideoQuality } = state['features/video-quality'];
const { participantId: largeVideoParticipantId } = state['features/large-video'];
const maxFrameHeight = Math.min(maxReceiverVideoQuality, preferredVideoQuality);
let { visibleParticipants } = state['features/filmstrip'];
let { visibleRemoteParticipants } = state['features/filmstrip'];
// TODO: implement this on mobile.
if (navigator.product === 'ReactNative') {
visibleParticipants = Array.from(state['features/base/participants'].remote.keys());
visibleRemoteParticipants = new Set(Array.from(state['features/base/participants'].remote.keys()));
}
const receiverConstraints = {
@ -208,22 +208,22 @@ function _updateReceiverVideoConstraints({ getState }) {
// Tile view.
if (shouldDisplayTileView(state)) {
if (!visibleParticipants?.length) {
if (!visibleRemoteParticipants?.size) {
return;
}
visibleParticipants.forEach(participantId => {
visibleRemoteParticipants.forEach(participantId => {
receiverConstraints.constraints[participantId] = { 'maxHeight': maxFrameHeight };
});
// Stage view.
} else {
if (!visibleParticipants?.length && !largeVideoParticipantId) {
if (!visibleRemoteParticipants?.size && !largeVideoParticipantId) {
return;
}
if (visibleParticipants?.length > 0) {
visibleParticipants.forEach(participantId => {
if (visibleRemoteParticipants?.size > 0) {
visibleRemoteParticipants.forEach(participantId => {
receiverConstraints.constraints[participantId] = { 'maxHeight': VIDEO_QUALITY_LEVELS.LOW };
});
}