fix(large-video)pin prev speaker on stage when local user is dominant speaker. (#16511)

* fix(large-video)pin prev speaker on stage when local user is dominant speaker.
Also fix the case where the dominant speaker sometimes doesn't show up in the filmstrip when SS is pinned to stage.

* fix(test) Add more tests for activeSpeaker

* fix(test) Do not check for large-video if there is no remote video track.

* fix(filmstrip) Get updated display name for calc speaker order
This commit is contained in:
Jaya Allamsetty
2025-10-16 15:35:15 -04:00
committed by GitHub
parent 8ab02d598c
commit 82d4628976
7 changed files with 235 additions and 109 deletions

View File

@@ -62,65 +62,6 @@ const AVATAR_CHECKER_FUNCTIONS = [
];
/* 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.
*

View File

@@ -76,12 +76,12 @@ const DEFAULT_STATE = {
numberOfParticipantsNotSupportingE2EE: 0,
overwrittenNameList: {},
pinnedParticipant: undefined,
previousSpeakers: new Set<string>(),
raisedHandsQueue: [],
remote: new Map(),
remoteVideoSources: new Set<string>(),
sortedRemoteVirtualScreenshareParticipants: new Map(),
sortedRemoteParticipants: new Map(),
speakersList: new Map()
sortedRemoteParticipants: new Map()
};
export interface IParticipantsState {
@@ -94,12 +94,12 @@ export interface IParticipantsState {
numberOfParticipantsNotSupportingE2EE: number;
overwrittenNameList: { [id: string]: string; };
pinnedParticipant?: string;
previousSpeakers: Set<string>;
raisedHandsQueue: Array<{ hasBeenNotified?: boolean; id: string; raisedHandTimestamp: number; }>;
remote: Map<string, IParticipant>;
remoteVideoSources: Set<string>;
sortedRemoteParticipants: Map<string, string>;
sortedRemoteVirtualScreenshareParticipants: Map<string, string>;
speakersList: Map<string, string>;
}
/**
@@ -156,22 +156,10 @@ 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]));
// Build chronologically ordered Set of remote speakers (excluding local)
const previousSpeakersSet: Set<string>
= new Set(previousSpeakers.filter((speaker: string) => speaker !== local?.id));
// Only one dominant speaker is allowed.
if (dominantSpeaker) {
@@ -181,8 +169,8 @@ ReducerRegistry.register<IParticipantsState>('features/base/participants',
if (_updateParticipantProperty(state, id, 'dominantSpeaker', true)) {
return {
...state,
dominantSpeaker: id, // @ts-ignore
speakersList: new Map(sortedSpeakersList)
dominantSpeaker: id,
previousSpeakers: previousSpeakersSet
};
}
@@ -435,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.previousSpeakers.delete(id);
if (pinnedParticipant === id) {
state.pinnedParticipant = undefined;

View File

@@ -1,12 +1,28 @@
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';
/**
* Returns an array containing the first `count` items from a set.
*
* @param {Set<T>} set - The set from which to take items.
* @param {number} count - The number of items to take.
* @returns {T[]} An array containing the taken items.
* @private
*/
function _takeFirstN<T>(set: Set<T>, count: number): T[] {
const result: T[] = [];
for (const item of set) {
if (result.length >= count) break;
result.push(item);
}
return result;
}
/**
* Computes the reorderd list of the remote participants.
*
@@ -16,7 +32,7 @@ import { isFilmstripScrollVisible } from './functions';
* @returns {void}
* @private
*/
export function updateRemoteParticipants(store: IStore, force?: boolean, participantId?: string) {
export function updateRemoteParticipants(store: IStore, force?: boolean, participantId?: string): void {
const state = store.getState();
let reorderedParticipants = [];
const { sortedRemoteVirtualScreenshareParticipants } = state['features/base/participants'];
@@ -33,14 +49,26 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
}
const {
dominantSpeaker,
fakeParticipants,
previousSpeakers,
sortedRemoteParticipants
} = state['features/base/participants'];
const { visibleRemoteParticipants } = state['features/filmstrip'];
const remoteParticipants = new Map(sortedRemoteParticipants);
const screenShareParticipants = sortedRemoteVirtualScreenshareParticipants
? [ ...sortedRemoteVirtualScreenshareParticipants.keys() ] : [];
const sharedVideos = fakeParticipants ? Array.from(fakeParticipants.keys()) : [];
const speakers = getActiveSpeakersToBeDisplayed(state);
const speakers: Set<string> = new Set();
const dominant = dominantSpeaker ? getParticipantById(state, dominantSpeaker) : undefined;
const config = state['features/base/config'];
const defaultRemoteDisplayName = config?.defaultRemoteDisplayName ?? 'Fellow Jitster';
// Generate the remote active speakers list.
if (dominant && !dominant.local) {
speakers.add(dominant.id);
}
previousSpeakers.forEach(id => speakers.add(id));
for (const screenshare of screenShareParticipants) {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
@@ -57,7 +85,16 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
remoteParticipants.delete(speaker);
}
// Always update the order of the thubmnails.
// Calculate the number of slots available for active speakers and then sort them alphabetically to ensure
// consistent order.
const numberOfActiveSpeakerSlots
= visibleRemoteParticipants.size - (screenShareParticipants.length * 2) - sharedVideos.length;
const activeSpeakersDisplayed = _takeFirstN(speakers, numberOfActiveSpeakerSlots)
.sort((a: string, b: string) => {
return (getParticipantById(state, a)?.name ?? defaultRemoteDisplayName)
.localeCompare(getParticipantById(state, b)?.name ?? defaultRemoteDisplayName);
});
const participantsWithScreenShare = screenShareParticipants.reduce<string[]>((acc, screenshare) => {
const ownerId = getVirtualScreenshareParticipantOwnerId(screenshare);
@@ -67,10 +104,11 @@ export function updateRemoteParticipants(store: IStore, force?: boolean, partici
return acc;
}, []);
// Always update the order of the thumbnails.
reorderedParticipants = [
...participantsWithScreenShare,
...sharedVideos,
...Array.from(speakers.keys()),
...activeSpeakersDisplayed,
...Array.from(remoteParticipants.keys())
];

View File

@@ -161,18 +161,26 @@ function _electParticipantInLargeVideo(state: IReduxState) {
}
}
// Next, pick the dominant speaker (other than self).
// Next, pick the dominant speaker or the last active speaker if the dominant speaker is local.
participant = getDominantSpeakerParticipant(state);
if (participant && !participant.local) {
// Return the screensharing participant id associated with this endpoint if multi-stream is enabled and
// auto_pin_latest_screen_share setting is disabled.
const screenshareParticipant = getVirtualScreenshareParticipantByOwnerId(state, participant.id);
let speakerId: string | undefined;
return screenshareParticipant?.id ?? participant.id;
if (participant?.local) {
const { previousSpeakers } = state['features/base/participants'];
if (previousSpeakers?.size) {
speakerId = previousSpeakers.keys().next().value;
}
} else if (participant) {
speakerId = participant.id;
}
// In case this is the local participant.
participant = undefined;
// Return the screensharing participant id associated with this endpoint.
if (speakerId) {
const screenshareParticipant = getVirtualScreenshareParticipantByOwnerId(state, speakerId);
return screenshareParticipant?.id ?? speakerId;
}
// Next, pick the most recent participant with video.
const lastVisibleRemoteParticipant = _electLastVisibleRemoteParticipant(state);

View File

@@ -579,6 +579,50 @@ export class Participant {
return new LargeVideo(this);
}
/**
* Waits for a specific participant to be displayed on large video.
*
* @param {string} expectedEndpointId - The endpoint ID of the participant expected on large video.
* @param {string} timeoutMsg - Optional custom timeout message.
* @param {number} timeout - Optional timeout in milliseconds (default: 30000).
* @returns {Promise<void>}
*/
async waitForParticipantOnLargeVideo(
expectedEndpointId: string,
timeoutMsg?: string,
timeout: number = 30_000): Promise<void> {
await this.driver.waitUntil(
async () => await this.getLargeVideo().getResource() === expectedEndpointId,
{
timeout,
timeoutMsg: timeoutMsg || `Expected ${expectedEndpointId} on large video for ${this.name}`
});
}
/**
* Waits for any one of the specified participants to be displayed on large video.
*
* @param {string[]} expectedEndpointIds - Array of endpoint IDs, any one of which is expected on large video.
* @param {string} timeoutMsg - Optional custom timeout message.
* @param {number} timeout - Optional timeout in milliseconds (default: 30000).
* @returns {Promise<void>}
*/
async waitForAnyParticipantOnLargeVideo(
expectedEndpointIds: string[],
timeoutMsg?: string,
timeout: number = 30_000): Promise<void> {
await this.driver.waitUntil(
async () => {
const largeVideoResource = await this.getLargeVideo().getResource();
return expectedEndpointIds.includes(largeVideoResource);
},
{
timeout,
timeoutMsg: timeoutMsg || `Expected one of [${expectedEndpointIds.join(', ')}] on large video for ${this.name}`
});
}
/**
* Returns the videoQuality Dialog.
*

View File

@@ -13,6 +13,16 @@ describe('Active speaker', () => {
const { p1, p2, p3 } = ctx;
// Exit tile view to enter stage view (default for 3 participants is tile view)
await p1.getToolbar().clickExitTileViewButton();
await p1.waitForTileViewDisplayed(true);
await p2.getToolbar().clickExitTileViewButton();
await p2.waitForTileViewDisplayed(true);
await p3.getToolbar().clickExitTileViewButton();
await p3.waitForTileViewDisplayed(true);
await muteAudioAndCheck(p1, p2);
await muteAudioAndCheck(p2, p1);
await muteAudioAndCheck(p3, p1);
@@ -31,6 +41,110 @@ describe('Active speaker', () => {
await assertOneDominantSpeaker(p2);
await assertOneDominantSpeaker(p3);
});
it('testLocalDominantSpeakerShowsLastRemote', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
const p2EndpointId = await p2.getEndpointId();
const p3EndpointId = await p3.getEndpointId();
// p2 speaks first
await p2.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p2EndpointId, 'P2 not displayed as dominant speaker on P1');
await p2.getToolbar().clickAudioMuteButton();
// p3 speaks second
await p3.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p3EndpointId, 'P3 not displayed as dominant speaker on P1');
await p3.getToolbar().clickAudioMuteButton();
// Now p1 starts screenshare and becomes dominant speaker
await p1.getToolbar().clickDesktopSharingButton();
// p1 should see p3 (last remote dominant speaker) on stage
await p1.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p3EndpointId, 'P1 should see P3 (last remote speaker) on stage when local is dominant');
await p1.getToolbar().clickAudioMuteButton();
await p1.getToolbar().clickStopDesktopSharingButton();
});
it('testAlternatingLocalAndRemoteDominantSpeaker', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
const p2EndpointId = await p2.getEndpointId();
const p3EndpointId = await p3.getEndpointId();
// Test alternating pattern: p2 -> p1 (local) -> p3 -> p1 (local) -> p2
// This tests that local dominant speaker consistently shows last remote speaker
// p2 speaks
await p2.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p2EndpointId, 'P2 not shown on P1');
await p2.getToolbar().clickAudioMuteButton();
// p1 (local) speaks - should still show p2
await p1.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p2EndpointId, 'P1 local dominant should show P2 (last remote)');
await p1.getToolbar().clickAudioMuteButton();
// p3 speaks
await p3.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p3EndpointId, 'P3 not shown on P1');
await p3.getToolbar().clickAudioMuteButton();
// p1 (local) speaks again - should now show p3
await p1.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p3EndpointId, 'P1 local dominant should show P3 (last remote)');
await p1.getToolbar().clickAudioMuteButton();
// p2 speaks again
await p2.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p2EndpointId, 'P2 not shown on P1 after second speak');
await p2.getToolbar().clickAudioMuteButton();
// p1 (local) speaks - should show p2 again
await p1.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(p2EndpointId, 'P1 local dominant should show P2 (last remote) again');
await p1.getToolbar().clickAudioMuteButton();
});
it('testDominantSpeakerWithSimultaneousSpeakers', async () => {
await ensureThreeParticipants();
const { p1, p2, p3 } = ctx;
const p2EndpointId = await p2.getEndpointId();
const p3EndpointId = await p3.getEndpointId();
// Multiple participants unmute simultaneously (this is the scenario we're testing)
await p2.getToolbar().clickAudioUnmuteButton();
await p3.getToolbar().clickAudioUnmuteButton();
// Wait for one to become dominant (bridge will pick one)
await p1.waitForAnyParticipantOnLargeVideo(
[ p2EndpointId, p3EndpointId ],
'No dominant speaker detected when multiple speak simultaneously');
// Get which one was selected as dominant
const firstDominant = await p1.getLargeVideo().getResource();
// Mute both
await Promise.all([
p2.getToolbar().clickAudioMuteButton(),
p3.getToolbar().clickAudioMuteButton()
]);
// Now p1 becomes local dominant speaker
// Should show the last remote dominant speaker (firstDominant)
await p1.getToolbar().clickAudioUnmuteButton();
await p1.waitForParticipantOnLargeVideo(
firstDominant,
'Local dominant should show last remote dominant after simultaneous speakers');
await p1.getToolbar().clickAudioMuteButton();
});
});
/**
@@ -63,14 +177,12 @@ async function testActiveSpeaker(
await activeSpeaker.getFilmstrip().assertAudioMuteIconIsDisplayed(activeSpeaker, true);
// Verify that the user is now an active speaker from otherParticipant1's perspective
const otherParticipant1Driver = otherParticipant1.driver;
await otherParticipant1Driver.waitUntil(
async () => await otherParticipant1.getFilmstrip().isDominantSpeaker(speakerEndpoint),
{
timeout: 30_000, // 30 seconds
timeoutMsg: `${activeSpeaker.name} is not selected as active speaker.`
});
await otherParticipant1.waitForParticipantOnLargeVideo(speakerEndpoint,
`Active speaker ${activeSpeaker.name} not displayed on large video.`,
30_000); // 30 seconds
await otherParticipant2.waitForParticipantOnLargeVideo(speakerEndpoint,
`Active speaker ${activeSpeaker.name} not displayed on large video.`,
30_000); // 30 seconds
// just a debug print to go in logs
activeSpeaker.log('Muting in testActiveSpeaker');

View File

@@ -175,11 +175,6 @@ describe('Start muted', () => {
await p1.getParticipantsPane().assertVideoMuteIconIsDisplayed(p2);
await p2.getParticipantsPane().assertVideoMuteIconIsDisplayed(p1);
await Promise.all([
p1.getLargeVideo().waitForSwitchTo(await p2.getEndpointId()),
p2.getLargeVideo().waitForSwitchTo(await p1.getEndpointId())
]);
await unmuteVideoAndCheck(p2, p1);
await p1.getLargeVideo().assertPlaying();
});