Skip to content
Merged
60 changes: 0 additions & 60 deletions react/features/base/participants/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,66 +60,6 @@ const AVATAR_CHECKER_FUNCTIONS = [
return null;
}
];
/* eslint-enable arrow-body-style */

/**
* Returns the list of active speakers that should be moved to the top of the sorted list of participants so that the
* dominant speaker is visible always on the vertical filmstrip in stage layout.
*
* @param {Function | Object} stateful - The (whole) redux state, or redux's {@code getState} function to be used to
* retrieve the state.
* @returns {Array<string>}
*/
export function getActiveSpeakersToBeDisplayed(stateful: IStateful) {
const state = toState(stateful);
const {
dominantSpeaker,
fakeParticipants,
sortedRemoteVirtualScreenshareParticipants,
speakersList
} = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
let activeSpeakers = new Map(speakersList);

// Do not re-sort the active speakers if dominant speaker is currently visible.
if (dominantSpeaker && visibleRemoteParticipants.has(dominantSpeaker)) {
return activeSpeakers;
}
let availableSlotsForActiveSpeakers = visibleRemoteParticipants.size;

if (activeSpeakers.has(dominantSpeaker ?? '')) {
activeSpeakers.delete(dominantSpeaker ?? '');
}

// Add dominant speaker to the beginning of the list (not including self) since the active speaker list is always
// alphabetically sorted.
if (dominantSpeaker && dominantSpeaker !== getLocalParticipant(state)?.id) {
const updatedSpeakers = Array.from(activeSpeakers);

updatedSpeakers.splice(0, 0, [ dominantSpeaker, getParticipantById(state, dominantSpeaker)?.name ?? '' ]);
activeSpeakers = new Map(updatedSpeakers);
}

// Remove screenshares from the count.
if (sortedRemoteVirtualScreenshareParticipants) {
availableSlotsForActiveSpeakers -= sortedRemoteVirtualScreenshareParticipants.size * 2;
for (const screenshare of Array.from(sortedRemoteVirtualScreenshareParticipants.keys())) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare as string);

activeSpeakers.delete(ownerId);
}
}

// Remove fake participants from the count.
if (fakeParticipants) {
availableSlotsForActiveSpeakers -= fakeParticipants.size;
}
const truncatedSpeakersList = Array.from(activeSpeakers).slice(0, availableSlotsForActiveSpeakers);

truncatedSpeakersList.sort((a: any, b: any) => a[1].localeCompare(b[1]));

return new Map(truncatedSpeakersList);
}

/**
* Resolves the first loadable avatar URL for a participant.
Expand Down
25 changes: 5 additions & 20 deletions react/features/base/participants/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const PARTICIPANT_PROPS_TO_OMIT_WHEN_UPDATE = [
];

const DEFAULT_STATE = {
activeSpeakers: new Set<string>(),
dominantSpeaker: undefined,
fakeParticipants: new Map(),
local: undefined,
Expand All @@ -82,10 +83,10 @@ const DEFAULT_STATE = {
remoteVideoSources: new Set<string>(),
sortedRemoteVirtualScreenshareParticipants: new Map(),
sortedRemoteParticipants: new Map(),
speakersList: new Map()
};

export interface IParticipantsState {
activeSpeakers: Set<string>;
dominantSpeaker?: string;
fakeParticipants: Map<string, IParticipant>;
local?: ILocalParticipant;
Expand All @@ -100,7 +101,6 @@ export interface IParticipantsState {
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}

/**
Expand Down Expand Up @@ -157,22 +157,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
const { participant } = action;
const { id, previousSpeakers = [] } = participant;
const { dominantSpeaker, local } = state;
const newSpeakers = [ id, ...previousSpeakers ];
const sortedSpeakersList: Array<Array<string>> = [];

for (const speaker of newSpeakers) {
if (speaker !== local?.id) {
const remoteParticipant = state.remote.get(speaker);

remoteParticipant
&& sortedSpeakersList.push(
[ speaker, _getDisplayName(state, remoteParticipant?.name) ]
);
}
}

// Keep the remote speaker list sorted alphabetically.
sortedSpeakersList.sort((a, b) => a[1].localeCompare(b[1]));
const activeSpeakers = new Set(previousSpeakers.filter((speakerId: string) => speakerId !== local?.id));

// Only one dominant speaker is allowed.
if (dominantSpeaker) {
Expand All @@ -183,7 +168,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
return {
...state,
dominantSpeaker: id, // @ts-ignore
speakersList: new Map(sortedSpeakersList)
activeSpeakers
};
}

Expand Down Expand Up @@ -438,7 +423,7 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
}

// Remove the participant from the list of speakers.
state.speakersList.has(id) && state.speakersList.delete(id);
state.activeSpeakers.delete(id);

if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;
Expand Down
58 changes: 38 additions & 20 deletions react/features/filmstrip/functions.any.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { IReduxState, IStore } from '../app/types';
import {
getActiveSpeakersToBeDisplayed,
getVirtualScreenshareParticipantOwnerId
} from '../base/participants/functions';
import { getParticipantById, getVirtualScreenshareParticipantOwnerId } from '../base/participants/functions';

import { setRemoteParticipants } from './actions';
import { isFilmstripScrollVisible } from './functions';
Expand Down Expand Up @@ -33,44 +30,65 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
}

const {
activeSpeakers,
dominantSpeaker,
fakeParticipants,
sortedRemoteParticipants
} = state['features/base/participants'];
const config = state['features/base/config'];
const defaultRemoteDisplayName = config?.defaultRemoteDisplayName ?? 'Fellow Jitster';
const dominant = dominantSpeaker ? getParticipantById(state, dominantSpeaker) : undefined;
let dominantSpeakerSlot = 0;
const remoteParticipants = new Map(sortedRemoteParticipants);
const screenShareParticipants = sortedRemoteVirtualScreenshareParticipants
? [ ...sortedRemoteVirtualScreenshareParticipants.keys() ] : [];
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
const speakers = getActiveSpeakersToBeDisplayed(state);
const speakers = new Array<string>();
const { visibleRemoteParticipants } = state['features/filmstrip'];

for (const screenshare of screenShareParticipants) {
const participantsWithScreenShare = screenShareParticipants.reduce<string[]>((acc, screenshare) => {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);

acc.push(ownerId);
acc.push(screenshare);
remoteParticipants.delete(ownerId);
remoteParticipants.delete(screenshare);
speakers.delete(ownerId);
}
activeSpeakers.delete(ownerId);

return acc;
}, []);

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);
if (dominant && !dominant.local && participantsWithScreenShare.indexOf(dominant.id) === -1) {
dominantSpeakerSlot = 1;
remoteParticipants.delete(dominant.id);
speakers.push(dominant.id);
}

return acc;
}, []);
// Find the number of slots available for speakers.
const slotsForSpeakers
= visibleRemoteParticipants.size - (screenShareParticipants.length * 2) - sharedVideos.length - dominantSpeakerSlot;

// Construct the list of speakers to be shown.
if (slotsForSpeakers > 0) {
Array.from(activeSpeakers).slice(0, slotsForSpeakers).forEach((speakerId: string) => {
speakers.push(speakerId);
remoteParticipants.delete(speakerId);
});
Comment on lines 66 to 81
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dominant speaker is added to the speakers array (line 68) and removed from remoteParticipants (line 67), but is NOT removed from activeSpeakers. Then on lines 77-80, speakers from activeSpeakers are added to the speakers array. Since activeSpeakers (from the reducer, line 160) includes the dominant speaker in the previousSpeakers array, the dominant speaker could be added to the speakers array twice - once at line 68 and again at line 78.

This would cause the dominant speaker to appear twice in the final reorderedParticipants array at line 88-93. To fix this, after line 68, add: activeSpeakers.delete(dominant.id); (though note this requires fixing the Redux state mutation issue first by making activeSpeakers a mutable copy).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

@jallamsetty1 jallamsetty1 Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

active speakers contains the list of the most recent speakers and excludes the dominant speaker.

speakers.sort((a: string, b: string) => {
return (getParticipantById(state, a)?.name ?? defaultRemoteDisplayName)
.localeCompare(getParticipantById(state, b)?.name ?? defaultRemoteDisplayName);
});
}

// Always update the order of the thubmnails.
reorderedParticipants = [
...participantsWithScreenShare,
...sharedVideos,
...Array.from(speakers.keys()),
...speakers,
...Array.from(remoteParticipants.keys())
];

Expand Down
3 changes: 3 additions & 0 deletions tests/helpers/Participant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const P1 = 'p1';
export const P2 = 'p2';
export const P3 = 'p3';
export const P4 = 'p4';
export const P5 = 'p5';
export const P6 = 'p6';
export const P7 = 'p7';

/**
* Participant.
Expand Down
63 changes: 59 additions & 4 deletions tests/helpers/participants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { P1, P2, P3, P4, Participant } from './Participant';
import { P1, P2, P3, P4, P5, P6, P7, Participant } from './Participant';
import { config } from './TestsConfig';
import { IJoinOptions, IParticipantOptions } from './types';

Expand Down Expand Up @@ -122,6 +122,54 @@ export async function ensureFourParticipants(options?: IJoinOptions): Promise<vo
]);
}

/**
* Ensure that there are seven participants.
*
* @param {IJoinOptions} options - The options to use when joining the participant.
* @returns {Promise<void>}
*/
export async function ensureSevenParticipants(options?: IJoinOptions): Promise<void> {
await ensureOneParticipant(options);

// Join participants in batches to avoid overwhelming the system
// First batch: p2, p3, p4
await Promise.all([
joinParticipant({ name: P2 }, options),
joinParticipant({ name: P3 }, options),
joinParticipant({ name: P4 }, options)
]);

// Second batch: p5, p6, p7
await Promise.all([
joinParticipant({ name: P5 }, options),
joinParticipant({ name: P6 }, options),
joinParticipant({ name: P7 }, options)
]);

if (options?.skipInMeetingChecks) {
return Promise.resolve();
}

await Promise.all([
ctx.p1.waitForIceConnected(),
ctx.p2.waitForIceConnected(),
ctx.p3.waitForIceConnected(),
ctx.p4.waitForIceConnected(),
ctx.p5.waitForIceConnected(),
ctx.p6.waitForIceConnected(),
ctx.p7.waitForIceConnected()
]);
await Promise.all([
ctx.p1.waitForSendReceiveData().then(() => ctx.p1.waitForRemoteStreams(1)),
ctx.p2.waitForSendReceiveData().then(() => ctx.p2.waitForRemoteStreams(1)),
ctx.p3.waitForSendReceiveData().then(() => ctx.p3.waitForRemoteStreams(1)),
ctx.p4.waitForSendReceiveData().then(() => ctx.p4.waitForRemoteStreams(1)),
ctx.p5.waitForSendReceiveData().then(() => ctx.p5.waitForRemoteStreams(1)),
ctx.p6.waitForSendReceiveData().then(() => ctx.p6.waitForRemoteStreams(1)),
ctx.p7.waitForSendReceiveData().then(() => ctx.p7.waitForRemoteStreams(1))
]);
}

/**
* Ensure that there are two participants.
*
Expand Down Expand Up @@ -244,10 +292,17 @@ export async function checkForScreensharingTile(sharer: Participant, observer: P
}

/**
* Hangs up all participants (p1, p2, p3 and p4)
* Hangs up all participants (p1, p2, p3, p4, p5, p6, and p7)
* @returns {Promise<void>}
*/
export function hangupAllParticipants() {
return Promise.all([ ctx.p1?.hangup(), ctx.p2?.hangup(), ctx.p3?.hangup(), ctx.p4?.hangup() ]
.map(p => p ?? Promise.resolve()));
return Promise.all([
ctx.p1?.hangup(),
ctx.p2?.hangup(),
ctx.p3?.hangup(),
ctx.p4?.hangup(),
ctx.p5?.hangup(),
ctx.p6?.hangup(),
ctx.p7?.hangup()
].map(p => p ?? Promise.resolve()));
}
6 changes: 5 additions & 1 deletion tests/helpers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export type IContext = {
p2: Participant;
p3: Participant;
p4: Participant;
p5: Participant;
p6: Participant;
p7: Participant;

/** A room name automatically generated by the framework for convenience. */
roomName: string;
/**
Expand All @@ -39,7 +43,7 @@ export type IParticipantOptions = {
/** Whether it should use the iFrame API. */
iFrameApi?: boolean;
/** Determines the browser instance to use. */
name: 'p1' | 'p2' | 'p3' | 'p4';
name: 'p1' | 'p2' | 'p3' | 'p4' | 'p5' | 'p6' | 'p7';
/** An optional token to use. */
token?: IToken;
};
Expand Down
Loading
Loading