diff --git a/conference.js b/conference.js index c7a6bb6a2..886525789 100644 --- a/conference.js +++ b/conference.js @@ -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, diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 5d7712517..b4226f1af 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -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, diff --git a/react/features/base/participants/actionTypes.js b/react/features/base/participants/actionTypes.js index 3e70d6855..056bddb16 100644 --- a/react/features/base/participants/actionTypes.js +++ b/react/features/base/participants/actionTypes.js @@ -6,7 +6,9 @@ * { * type: DOMINANT_SPEAKER_CHANGED, * participant: { - * id: string + * conference: JitsiConference, + * id: string, + * previousSpeakers: Array * } * } */ diff --git a/react/features/base/participants/actions.js b/react/features/base/participants/actions.js index 5a08e3053..08c74ddbd 100644 --- a/react/features/base/participants/actions.js +++ b/react/features/base/participants/actions.js @@ -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} 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 * } * }} */ -export function dominantSpeakerChanged(id, conference) { +export function dominantSpeakerChanged(dominantSpeaker, previousSpeakers, conference) { return { type: DOMINANT_SPEAKER_CHANGED, participant: { conference, - id + id: dominantSpeaker, + previousSpeakers } }; } diff --git a/react/features/base/participants/reducer.js b/react/features/base/participants/reducer.js index 41c8bf388..eb664f994 100644 --- a/react/features/base/participants/reducer.js +++ b/react/features/base/participants/reducer.js @@ -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; } diff --git a/react/features/filmstrip/actionTypes.js b/react/features/filmstrip/actionTypes.js index c74d00151..3d0906f1b 100644 --- a/react/features/filmstrip/actionTypes.js +++ b/react/features/filmstrip/actionTypes.js @@ -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 + * } + */ +export const SET_REMOTE_PARTICIPANTS = 'SET_REMOTE_PARTICIPANTS'; + /** * The type of (redux) action which sets the dimensions of the thumbnails in vertical view. * diff --git a/react/features/filmstrip/actions.web.js b/react/features/filmstrip/actions.web.js index 3a7218896..7911eea54 100644 --- a/react/features/filmstrip/actions.web.js +++ b/react/features/filmstrip/actions.web.js @@ -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} participants - The list of the remote participant endpoint IDs. + * @returns {{ + type: SET_REMOTE_PARTICIPANTS, + participants: Array + }} + */ +export function setRemoteParticipants(participants: Array) { + return { + type: SET_REMOTE_PARTICIPANTS, + participants + }; +} + /** * Sets the dimensions of the tile view grid. * diff --git a/react/features/filmstrip/components/web/Filmstrip.js b/react/features/filmstrip/components/web/Filmstrip.js index e092e2fb1..5384d5b67 100644 --- a/react/features/filmstrip/components/web/Filmstrip.js +++ b/react/features/filmstrip/components/web/Filmstrip.js @@ -269,11 +269,11 @@ class Filmstrip extends PureComponent { 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 { _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 { 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)); } diff --git a/react/features/filmstrip/components/web/ThumbnailWrapper.js b/react/features/filmstrip/components/web/ThumbnailWrapper.js index 7e019ff4f..7d78f0501 100644 --- a/react/features/filmstrip/components/web/ThumbnailWrapper.js +++ b/react/features/filmstrip/components/web/ThumbnailWrapper.js @@ -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 }; } diff --git a/react/features/filmstrip/functions.web.js b/react/features/filmstrip/functions.web.js index 0fe651d0d..a687b3192 100644 --- a/react/features/filmstrip/functions.web.js +++ b/react/features/filmstrip/functions.web.js @@ -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)); +} diff --git a/react/features/filmstrip/middleware.web.js b/react/features/filmstrip/middleware.web.js index e90713d7a..69d521d52 100644 --- a/react/features/filmstrip/middleware.web.js +++ b/react/features/filmstrip/middleware.web.js @@ -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))); +} diff --git a/react/features/filmstrip/reducer.js b/react/features/filmstrip/reducer.js index b201ee683..06a76d9cb 100644 --- a/react/features/filmstrip/reducer.js +++ b/react/features/filmstrip/reducer.js @@ -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} */ remoteParticipants: [], @@ -77,22 +78,21 @@ const DEFAULT_STATE = { */ visibleParticipantsEndIndex: 0, - /** - * The visible participants in the filmstrip. - * - * @public - * @type {Array} - */ - 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} + */ + 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; diff --git a/react/features/filmstrip/subscriber.web.js b/react/features/filmstrip/subscriber.web.js index de6ee26ac..c30ac8d1f 100644 --- a/react/features/filmstrip/subscriber.web.js +++ b/react/features/filmstrip/subscriber.web.js @@ -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); + } +} diff --git a/react/features/video-quality/subscriber.js b/react/features/video-quality/subscriber.js index 5fd66e4bf..c8c1071d3 100644 --- a/react/features/video-quality/subscriber.js +++ b/react/features/video-quality/subscriber.js @@ -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 }; }); }