frontend: Refactors layout and feature configurations

Consolidates layout management into a dedicated component and service.

- Replaces layout selection logic with feature-based approach.
- Improves code readability and maintainability.
This commit is contained in:
Carlos Santos 2025-12-01 12:25:51 +01:00
parent 5638025211
commit 68c5ce7cd2
11 changed files with 232 additions and 308 deletions

View File

@ -1,14 +1,14 @@
@if (meetingContextService.lkRoom()) {
<ov-layout [ovRemoteParticipants]="filteredRemoteParticipants()">
@if (showMeetingLinkOverlay()) {
<ov-layout [ovRemoteParticipants]="visibleRemoteParticipants()">
@if (shouldShowLinkOverlay()) {
<ng-container *ovLayoutAdditionalElements>
<div id="share-link-overlay" class="main-share-meeting-link-container fade-in-delayed OV_big">
<ov-share-meeting-link
class="main-share-meeting-link"
[title]="linkOverlayTitle"
[subtitle]="linkOverlaySubtitle"
[titleSize]="linkOverlayTitleSize"
[titleWeight]="linkOverlayTitleWeight"
[title]="linkOverlayConfig.title"
[subtitle]="linkOverlayConfig.subtitle"
[titleSize]="linkOverlayConfig.titleSize"
[titleWeight]="linkOverlayConfig.titleWeight"
[meetingUrl]="meetingUrl()"
(copyClicked)="onCopyMeetingLinkClicked()"
></ov-share-meeting-link>

View File

@ -1,19 +1,11 @@
import { Component, signal, computed, effect, inject, DestroyRef, input, untracked } from '@angular/core';
import { Participant } from 'livekit-client';
import { LoggerService, OpenViduService, ILogger, OpenViduComponentsUiModule } from 'openvidu-components-angular';
import { MeetLayoutMode } from '../../../models/layout.model';
import { Component, computed, effect, inject, untracked } from '@angular/core';
import { LoggerService, ILogger, OpenViduComponentsUiModule } from 'openvidu-components-angular';
import { CustomParticipantModel } from '../../../models';
import { MeetLayoutService } from '../../../services/layout.service';
import { MeetingContextService } from '../../../services/meeting/meeting-context.service';
import { ShareMeetingLinkComponent } from '../../../components/share-meeting-link/share-meeting-link.component';
import { MeetingService } from '../../../services/meeting/meeting.service';
/**
* MeetingLayoutComponent - Intelligent layout component for scalable video conferencing
*
* This component implements an optimized layout system that displays only the N most recent
* active speakers to maximize client-side performance and scalability.
*/
@Component({
selector: 'ov-meeting-custom-layout',
imports: [OpenViduComponentsUiModule, ShareMeetingLinkComponent],
@ -21,182 +13,114 @@ import { MeetingService } from '../../../services/meeting/meeting.service';
styleUrl: './meeting-custom-layout.component.scss'
})
export class MeetingCustomLayoutComponent {
private readonly loggerSrv = inject(LoggerService);
private readonly logger: ILogger = inject(LoggerService).get('MeetingCustomLayoutComponent');
protected readonly layoutService = inject(MeetLayoutService);
protected readonly openviduService = inject(OpenViduService);
protected meetingContextService = inject(MeetingContextService);
protected meetingService = inject(MeetingService);
protected readonly destroyRef = inject(DestroyRef);
private readonly log: ILogger = this.loggerSrv.get('MeetingCustomLayoutComponent');
protected readonly linkOverlayTitle = 'Start collaborating';
protected readonly linkOverlaySubtitle = 'Share this link to bring others into the meeting';
protected readonly linkOverlayTitleSize: 'sm' | 'md' | 'lg' | 'xl' = 'xl';
protected readonly linkOverlayTitleWeight: 'normal' | 'bold' = 'bold';
protected readonly meetingContextService = inject(MeetingContextService);
protected readonly meetingService = inject(MeetingService);
protected readonly linkOverlayConfig = {
title: 'Start collaborating',
subtitle: 'Share this link to bring others into the meeting',
titleSize: 'xl' as const,
titleWeight: 'bold' as const
};
protected readonly meetingUrl = computed(() => this.meetingContextService.meetingUrl());
protected readonly showMeetingLinkOverlay = computed(() => {
const remoteParticipants = this.meetingContextService.remoteParticipants();
return this.meetingContextService.canModerateRoom() && remoteParticipants.length === 0;
protected readonly shouldShowLinkOverlay = computed(() => {
const hasNoRemotes = this.meetingContextService.remoteParticipants().length === 0;
return this.meetingContextService.canModerateRoom() && hasNoRemotes;
});
/**
* Whether the layout selector feature is enabled
*/
protected readonly showLayoutSelector = this.meetingContextService.showLayoutSelector;
protected readonly isLayoutSwitchingAllowed = this.meetingContextService.allowLayoutSwitching;
/**
* Tracks the order of active speakers (most recent last)
*/
private readonly activeSpeakersOrder = signal<string[]>([]);
private displayedParticipantIds: string[] = [];
/**
* Computed signal that provides the filtered list of participants to display.
* Automatically reacts to changes in layout service configuration.
* When showLayoutSelector is false, returns all remote participants (default behavior).
*/
readonly filteredRemoteParticipants = computed(() => {
const remoteParticipants = this.meetingContextService.remoteParticipants();
readonly visibleRemoteParticipants = computed(() => {
const allRemotes = this.meetingContextService.remoteParticipants();
// If layout selector is disabled, use default behavior (show all participants)
if (!this.showLayoutSelector()) {
return remoteParticipants;
if (!this.isSmartMosaicActive()) {
return allRemotes;
}
const isLastSpeakersMode = this.layoutService.isSmartMosaicEnabled();
const participantMap = new Map(allRemotes.map((p) => [p.identity, p]));
const availableIds = new Set(participantMap.keys());
const targetIds = this.layoutService.computeParticipantsToDisplay(availableIds);
if (!isLastSpeakersMode) {
// MOSAIC layout mode: show all participants
return remoteParticipants;
}
this.syncDisplayedParticipantsWithTarget(targetIds, availableIds);
// SMART_MOSAIC layout mode: show only active speakers
const activeSpeakersOrder = this.activeSpeakersOrder();
const maxSpeakers = this.layoutService.maxRemoteSpeakers();
// If no active speakers yet, initialize with first N remote participants
if (activeSpeakersOrder.length === 0) {
return remoteParticipants.slice(0, maxSpeakers);
}
// Build participants map for O(1) lookup
const participantsMap = new Map(remoteParticipants.map((p) => [p.identity, p]));
// Filter active speakers that still exist in remote participants
const validActiveSpeakers = activeSpeakersOrder
.map((identity) => participantsMap.get(identity))
.filter((p): p is CustomParticipantModel => p !== undefined)
.slice(-maxSpeakers); // Take last N speakers (most recent)
// If we have fewer active speakers than max, fill with additional participants
if (validActiveSpeakers.length < maxSpeakers) {
const activeSpeakerIdentities = new Set(validActiveSpeakers.map((p) => p.identity));
const additionalParticipants = remoteParticipants
.filter((p) => !activeSpeakerIdentities.has(p.identity))
.slice(0, maxSpeakers - validActiveSpeakers.length);
return [...validActiveSpeakers, ...additionalParticipants];
}
return validActiveSpeakers;
return this.displayedParticipantIds
.map((id) => participantMap.get(id))
.filter((p): p is CustomParticipantModel => p !== undefined);
});
constructor() {
effect(() => {
// Only setup active speakers if layout selector is enabled
if (this.showLayoutSelector() && this.meetingContextService.lkRoom()) {
this.setupActiveSpeakersListener();
}
});
// Effect to handle active speakers cleanup when participants leave
effect(() => {
// Skip if layout selector is disabled
if (!this.showLayoutSelector()) return;
if (!this.layoutService.isSmartMosaicEnabled()) return;
const remoteParticipants = this.meetingContextService.remoteParticipants();
const activeSpeakersOrder = this.activeSpeakersOrder();
const currentIdentities = new Set(remoteParticipants.map((p) => p.identity));
const cleanedOrder = activeSpeakersOrder.filter((identity) => currentIdentities.has(identity));
if (cleanedOrder.length !== activeSpeakersOrder.length) {
untracked(() => this.activeSpeakersOrder.set(cleanedOrder));
}
});
this.setupSpeakerTrackingEffect();
this.setupParticipantCleanupEffect();
}
protected onCopyMeetingLinkClicked(): void {
const room = this.meetingContextService.meetRoom();
if (!room) {
this.log.e('Cannot copy link: meeting room is undefined');
this.logger.e('Cannot copy link: meeting room is undefined');
return;
}
this.meetingService.copyMeetingSpeakerLink(room);
}
private isSmartMosaicActive(): boolean {
return this.isLayoutSwitchingAllowed() && this.layoutService.isSmartMosaicEnabled();
}
/**
* Sets up the listener for active speakers changes from LiveKit
* Uses efficient Set operations and early returns for performance
* Synchronizes the list of displayed participants with the target set of participant IDs.
* Ensures that only available participants are shown, replaces removed IDs with new ones
* when possible, and appends any remaining participants needed to match the target state.
* @param targetIds Set of participant IDs that should be displayed.
* @param availableIds Set of participant IDs that are currently available for display.
*/
private setupActiveSpeakersListener(): void {
const room = this.openviduService.getRoom();
if (!room) {
this.log.e('Cannot setup active speakers listener: room is undefined');
return;
private syncDisplayedParticipantsWithTarget(targetIds: Set<string>, availableIds: Set<string>): void {
this.displayedParticipantIds = this.displayedParticipantIds.filter((id) => availableIds.has(id));
const currentDisplaySet = new Set(this.displayedParticipantIds);
const idsToRemove = this.displayedParticipantIds.filter((id) => !targetIds.has(id));
const idsToAdd = [...targetIds].filter((id) => !currentDisplaySet.has(id));
// Substitute removed with added at same positions
for (const removeId of idsToRemove) {
const addId = idsToAdd.shift();
if (addId) {
const index = this.displayedParticipantIds.indexOf(removeId);
if (index !== -1) this.displayedParticipantIds[index] = addId;
} else {
this.displayedParticipantIds = this.displayedParticipantIds.filter((id) => id !== removeId);
}
}
// Register cleanup on component destroy
this.destroyRef.onDestroy(() => {
room.off('activeSpeakersChanged', this.handleActiveSpeakersChanged);
// Append remaining
for (const addId of idsToAdd) {
if (this.displayedParticipantIds.length < targetIds.size) {
this.displayedParticipantIds.push(addId);
}
}
}
private setupSpeakerTrackingEffect(): void {
effect(() => {
const room = this.meetingContextService.lkRoom();
if (this.isLayoutSwitchingAllowed() && room) {
this.layoutService.initializeSpeakerTracking(room);
}
});
room.on('activeSpeakersChanged', this.handleActiveSpeakersChanged);
}
/**
* Handles active speakers changed events from LiveKit
*/
private readonly handleActiveSpeakersChanged = (speakers: Participant[]): void => {
if (!this.layoutService.isSmartMosaicEnabled()) return;
private setupParticipantCleanupEffect(): void {
effect(() => {
if (!this.isSmartMosaicActive()) return;
const remoteSpeakers = speakers.filter((p) => !p.isLocal);
if (remoteSpeakers.length === 0) return;
const maxSpeakers = this.layoutService.maxRemoteSpeakers();
const newSpeakerIdentities = remoteSpeakers.map((p) => p.identity).slice(0, maxSpeakers);
if (this.isSameSpeakersList(newSpeakerIdentities)) return;
this.updateActiveSpeakersOrder(newSpeakerIdentities);
};
/**
* Checks if the new speakers list is identical to the current one
*/
private isSameSpeakersList(newIdentities: string[]): boolean {
const currentOrder = this.activeSpeakersOrder();
const maxSpeakers = this.layoutService.maxRemoteSpeakers();
const currentActiveIdentities = currentOrder.slice(-maxSpeakers);
return (
currentActiveIdentities.length === newIdentities.length &&
currentActiveIdentities.every((identity, index) => identity === newIdentities[index])
);
}
/**
* Updates the active speakers order with new speakers
*/
private updateActiveSpeakersOrder(newSpeakerIdentities: string[]): void {
const currentOrder = this.activeSpeakersOrder();
const newIdentitiesSet = new Set(newSpeakerIdentities);
const filteredOrder = currentOrder.filter((identity) => !newIdentitiesSet.has(identity));
const updatedOrder = [...filteredOrder, ...newSpeakerIdentities];
const maxSpeakers = this.layoutService.maxRemoteSpeakers();
const trimmedOrder = updatedOrder.slice(-(maxSpeakers * 2));
this.activeSpeakersOrder.set(trimmedOrder);
const currentIds = new Set(this.meetingContextService.remoteParticipants().map((p) => p.identity));
untracked(() => this.layoutService.removeDisconnectedSpeakers(currentIds));
});
}
}

View File

@ -1,5 +1,5 @@
<!-- Grid Layout Configuration Section -->
@if (showLayoutSelector()) {
@if (isLayoutSwitchingAllowed()) {
<div class="layout-section">
<div class="section-header">
<mat-icon class="section-icon material-symbols-outlined">browse</mat-icon>
@ -14,14 +14,14 @@
(ngModelChange)="onLayoutModeChange($event)"
aria-label="Select layout mode"
>
<mat-radio-button [value]="LayoutMode.MOSAIC" class="layout-radio-option">
<mat-radio-button [value]="LayoutMode.MOSAIC" id="layout-mosaic" class="layout-radio-option">
<div class="radio-content">
<span class="radio-label">Mosaic</span>
<span class="radio-description">Shows all participants in a unified grid</span>
</div>
</mat-radio-button>
<mat-radio-button [value]="LayoutMode.SMART_MOSAIC" class="layout-radio-option">
<mat-radio-button [value]="LayoutMode.SMART_MOSAIC" id="layout-smart-mosaic" class="layout-radio-option">
<div class="radio-content">
<span class="radio-label">Smart Mosaic</span>
<span class="radio-description">Shows a limited number of active participants</span>

View File

@ -39,9 +39,9 @@ export class MeetingSettingsExtensionsComponent {
readonly LayoutMode = MeetLayoutMode;
/**
* Whether the layout selector feature is enabled
* Whether the layout switching feature is allowed
*/
protected readonly showLayoutSelector = this.meetingContextService.showLayoutSelector;
protected readonly isLayoutSwitchingAllowed = this.meetingContextService.allowLayoutSwitching;
/**
* Current layout mode

View File

@ -1,5 +1,5 @@
<!-- Grid Layout Settings Button -->
@if (showLayoutSelector()) {
@if (isLayoutSwitchingAllowed()) {
<button
mat-menu-item
id="grid-layout-settings-btn"

View File

@ -50,9 +50,9 @@ export class MeetingToolbarMoreOptionsMenuComponent {
readonly isDesktopView = computed(() => this.viewportService.isDesktop());
/**
* Whether the layout selector feature is enabled
* Whether the layout switching feature is allowed
*/
readonly showLayoutSelector = computed(() => this.meetingContextService.showLayoutSelector());
readonly isLayoutSwitchingAllowed = computed(() => this.meetingContextService.allowLayoutSwitching());
/**
* Opens the settings panel to allow users to change grid layout

View File

@ -1,3 +1,5 @@
import { MeetRoomTheme } from '@openvidu-meet/typings';
export interface AppData {
mode: ApplicationMode;
edition: Edition;
@ -13,3 +15,34 @@ export enum Edition {
CE = 'CE',
PRO = 'PRO'
}
/**
* Interface that defines all available features in the application
*/
export interface ApplicationFeatures {
// Media Features
videoEnabled: boolean;
audioEnabled: boolean;
showCamera: boolean;
showMicrophone: boolean;
showScreenShare: boolean;
// UI Features
showRecordingPanel: boolean;
showChat: boolean;
showBackgrounds: boolean;
showParticipantList: boolean;
showSettings: boolean;
showFullscreen: boolean;
showThemeSelector: boolean;
allowLayoutSwitching: boolean; // flag for allowing smart layout. It's changed manually.
// Permissions
canModerateRoom: boolean;
canRecordRoom: boolean;
canRetrieveRecordings: boolean;
// Appearance
hasCustomTheme: boolean;
themeConfig?: MeetRoomTheme;
}

View File

@ -15,7 +15,6 @@ import {
import { Subject } from 'rxjs';
import { MeetingParticipantItemComponent } from '../../customization';
import {
ApplicationFeatures,
FeatureConfigurationService,
GlobalConfigService,
MeetingContextService,
@ -27,6 +26,7 @@ import {
WebComponentManagerService
} from '../../services';
import { MeetingLobbyComponent } from '../../components/meeting-lobby/meeting-lobby.component';
import { ApplicationFeatures } from '../../models/app.model';
@Component({
selector: 'ov-meeting',

View File

@ -4,41 +4,10 @@ import {
MeetRoomConfig,
MeetRoomMemberPermissions,
MeetRoomMemberRole,
MeetRoomTheme,
TrackSource
} from '@openvidu-meet/typings';
import { LoggerService } from 'openvidu-components-angular';
/**
* Interface that defines all available features in the application
*/
export interface ApplicationFeatures {
// Media Features
videoEnabled: boolean;
audioEnabled: boolean;
showCamera: boolean;
showMicrophone: boolean;
showScreenShare: boolean;
// UI Features
showRecordingPanel: boolean;
showChat: boolean;
showBackgrounds: boolean;
showParticipantList: boolean;
showSettings: boolean;
showFullscreen: boolean;
showThemeSelector: boolean;
showLayoutSelector: boolean;
// Permissions
canModerateRoom: boolean;
canRecordRoom: boolean;
canRetrieveRecordings: boolean;
// Appearance
hasCustomTheme: boolean;
themeConfig?: MeetRoomTheme;
}
import { ApplicationFeatures } from '../models/app.model';
/**
* Base configuration for default features
@ -57,7 +26,7 @@ const DEFAULT_FEATURES: ApplicationFeatures = {
showSettings: true,
showFullscreen: true,
showThemeSelector: true,
showLayoutSelector: true, // flag for allowing smart layout. It's changed manually.
allowLayoutSwitching: true,
canModerateRoom: false,
canRecordRoom: false,

View File

@ -1,31 +1,33 @@
import { Injectable, signal, computed, effect } from '@angular/core';
import { Injectable, signal, computed, effect, DestroyRef, inject } from '@angular/core';
import { Room, Participant } from 'livekit-client';
import { LayoutService, LoggerService, ViewportService } from 'openvidu-components-angular';
import { MeetLayoutMode } from '../models/layout.model';
import { MeetStorageService } from './storage.service';
@Injectable({
providedIn: 'root'
})
@Injectable({ providedIn: 'root' })
export class MeetLayoutService extends LayoutService {
private readonly DEFAULT_SMART_MOSAIC_SPEAKERS = 4;
private readonly destroyRef = inject(DestroyRef);
private readonly DEFAULT_MAX_SPEAKERS = 4;
private readonly DEFAULT_LAYOUT_MODE = MeetLayoutMode.MOSAIC;
/** Minimum number of remote speakers that can be displayed when Smart Mosaic layout is enabled */
readonly MIN_REMOTE_SPEAKERS = 1;
/** Maximum number of remote speakers that can be displayed when Smart Mosaic layout is enabled */
readonly MAX_REMOTE_SPEAKERS_LIMIT = 6;
private readonly _layoutMode = signal<MeetLayoutMode>(MeetLayoutMode.MOSAIC);
readonly layoutMode = this._layoutMode.asReadonly();
private readonly _maxRemoteSpeakers = signal<number>(this.DEFAULT_SMART_MOSAIC_SPEAKERS);
private readonly _maxRemoteSpeakers = signal<number>(this.DEFAULT_MAX_SPEAKERS);
readonly maxRemoteSpeakers = this._maxRemoteSpeakers.asReadonly();
/**
* Computed signal that checks if Smart Mosaic layout is enabled
* This is automatically recomputed when layoutMode changes
*/
readonly isSmartMosaicEnabled = computed(() => this._layoutMode() === MeetLayoutMode.SMART_MOSAIC);
private readonly _speakerRecencyOrder = signal<string[]>([]);
readonly speakerRecencyOrder = this._speakerRecencyOrder.asReadonly();
private currentRoom: Room | null = null;
private isSpeakerTrackingActive = false;
constructor(
protected loggerService: LoggerService,
protected viewPortService: ViewportService,
@ -34,128 +36,124 @@ export class MeetLayoutService extends LayoutService {
super(loggerService, viewPortService);
this.log = this.loggerService.get('MeetLayoutService');
this.initializeLayoutMode();
this.initializeMaxRemoteSpeakers();
// Effect to persist layout mode changes to storage
effect(() => {
const mode = this._layoutMode();
this.storageService.setLayoutMode(mode);
this.log.d(`Layout mode persisted to storage: ${mode}`);
});
// Effect to persist max remote speakers changes to storage
effect(() => {
const count = this._maxRemoteSpeakers();
this.storageService.setMaxRemoteSpeakers(count);
this.log.d(`Max remote speakers persisted to storage: ${count}`);
});
this.loadLayoutModeFromStorage();
this.loadMaxSpeakersFromStorage();
this.setupStoragePersistence();
}
/**
* Initializes the layout mode for the application.
* Retrieves the layout mode from storage or defaults to MOSAIC.
*/
private initializeLayoutMode(): void {
const layoutMode = this.storageService.getLayoutMode();
if (layoutMode && Object.values(MeetLayoutMode).includes(layoutMode)) {
this._layoutMode.set(layoutMode);
} else {
this._layoutMode.set(this.DEFAULT_LAYOUT_MODE);
}
this.log.d(`Layout mode initialized: ${this._layoutMode()}`);
private loadLayoutModeFromStorage(): void {
const storedMode = this.storageService.getLayoutMode();
const isValidMode = storedMode && Object.values(MeetLayoutMode).includes(storedMode);
this._layoutMode.set(isValidMode ? storedMode : this.DEFAULT_LAYOUT_MODE);
}
/**
* Initializes the max remote speakers count from storage.
*/
private initializeMaxRemoteSpeakers(): void {
const count = this.storageService.getMaxRemoteSpeakers();
if (count && count >= this.MIN_REMOTE_SPEAKERS && count <= this.MAX_REMOTE_SPEAKERS_LIMIT) {
this._maxRemoteSpeakers.set(count);
} else {
this._maxRemoteSpeakers.set(this.DEFAULT_SMART_MOSAIC_SPEAKERS);
}
this.log.d(`Max remote speakers initialized: ${this._maxRemoteSpeakers()}`);
private loadMaxSpeakersFromStorage(): void {
const storedCount = this.storageService.getMaxRemoteSpeakers();
const isValidCount =
storedCount && storedCount >= this.MIN_REMOTE_SPEAKERS && storedCount <= this.MAX_REMOTE_SPEAKERS_LIMIT;
this._maxRemoteSpeakers.set(isValidCount ? storedCount : this.DEFAULT_MAX_SPEAKERS);
}
/**
* Checks if the current layout mode is set to display the last speakers.
* @deprecated Use isSmartMosaicEnabled computed signal instead
* @returns {boolean} `true` if the layout mode is set to `SMART_MOSAIC`, otherwise `false`.
*/
isLastSpeakersLayoutEnabled(): boolean {
return this._layoutMode() === MeetLayoutMode.SMART_MOSAIC;
private setupStoragePersistence(): void {
effect(() => this.storageService.setLayoutMode(this._layoutMode()));
effect(() => this.storageService.setMaxRemoteSpeakers(this._maxRemoteSpeakers()));
}
/**
* Sets the layout mode and triggers layout update.
* This method validates the mode and only updates if it's different.
*
* @param layoutMode - The new layout mode to set
*/
setLayoutMode(layoutMode: MeetLayoutMode): void {
const currentMode = this._layoutMode();
const isValidMode = Object.values(MeetLayoutMode).includes(layoutMode);
if (!isValidMode) {
this.log.w(`Invalid layout mode: ${layoutMode}`);
setLayoutMode(mode: MeetLayoutMode): void {
if (!Object.values(MeetLayoutMode).includes(mode)) {
this.log.w(`Invalid layout mode: ${mode}`);
return;
}
if (this._layoutMode() === mode) return;
if (currentMode === layoutMode) {
this.log.d(`Layout mode already set to: ${layoutMode}`);
return;
}
this.log.d(`Layout mode updated from ${currentMode} to ${layoutMode}`);
this._layoutMode.set(layoutMode);
this._layoutMode.set(mode);
this.update();
}
/**
* Sets the maximum number of remote speakers to display in Smart Mosaic mode.
* Validates the count is between the default minimum and the default maximum.
*
* @param count - Number of remote participants to display (default minimum to default maximum)
*/
setMaxRemoteSpeakers(count: number): void {
if (count < this.MIN_REMOTE_SPEAKERS || count > this.MAX_REMOTE_SPEAKERS_LIMIT) {
this.log.w(
`Invalid max remote speakers count: ${count}. Must be between ${this.MIN_REMOTE_SPEAKERS} and ${this.MAX_REMOTE_SPEAKERS_LIMIT}`
`Invalid speaker count: ${count}. Range: ${this.MIN_REMOTE_SPEAKERS}-${this.MAX_REMOTE_SPEAKERS_LIMIT}`
);
return;
}
if (this._maxRemoteSpeakers() === count) return;
const currentCount = this._maxRemoteSpeakers();
if (currentCount === count) {
this.log.d(`Max remote speakers already set to: ${count}`);
return;
}
this.log.d(`Max remote speakers updated from ${currentCount} to ${count}`);
this._maxRemoteSpeakers.set(count);
if (this.isSmartMosaicEnabled()) this.update();
}
// Trigger layout update if in Smart Mosaic mode
if (this.isSmartMosaicEnabled()) {
this.update();
initializeSpeakerTracking(room: Room): void {
if (this.isSpeakerTrackingActive) return;
this.currentRoom = room;
this.isSpeakerTrackingActive = true;
room.on('activeSpeakersChanged', this.handleActiveSpeakersChanged);
this.destroyRef.onDestroy(() => this.cleanupSpeakerTracking());
}
cleanupSpeakerTracking(): void {
this.currentRoom?.off('activeSpeakersChanged', this.handleActiveSpeakersChanged);
this.currentRoom = null;
this._speakerRecencyOrder.set([]);
this.isSpeakerTrackingActive = false;
}
/**
* Removes participant IDs from the speaker recency order that are not in the set of connected participants.
* @param connectedParticipantIds Set of participant IDs that are currently connected.
*/
removeDisconnectedSpeakers(connectedParticipantIds: Set<string>): void {
const currentOrder = this._speakerRecencyOrder();
const filteredOrder = currentOrder.filter((id) => connectedParticipantIds.has(id));
if (filteredOrder.length !== currentOrder.length) {
this._speakerRecencyOrder.set(filteredOrder);
}
}
/**
* Gets the current layout mode.
* @deprecated Use layoutMode signal directly instead
* @returns {MeetLayoutMode} The current layout mode
* Determines the set of participant IDs to display by prioritizing the most
* recent speakers and filling remaining slots with other available participants.
* Ensures the result does not exceed the maximum number of remote speakers.
* @param availableIds Set of participant IDs currently available for selection.
* @returns Set of participant IDs selected for display.
*/
getLayoutMode(): MeetLayoutMode {
return this._layoutMode();
computeParticipantsToDisplay(availableIds: Set<string>): Set<string> {
const maxCount = this._maxRemoteSpeakers();
const speakerOrder = this._speakerRecencyOrder();
const recentSpeakers = speakerOrder.filter((id) => availableIds.has(id)).slice(-maxCount);
if (recentSpeakers.length >= maxCount) {
return new Set(recentSpeakers);
}
const recentSpeakerSet = new Set(recentSpeakers);
const fillersNeeded = maxCount - recentSpeakers.length;
const fillers = [...availableIds].filter((id) => !recentSpeakerSet.has(id)).slice(0, fillersNeeded);
return new Set([...recentSpeakers, ...fillers]);
}
/**
* Gets the current max remote speakers count.
* @returns {number} The current max remote speakers count
*/
getMaxRemoteSpeakers(): number {
return this._maxRemoteSpeakers();
private readonly handleActiveSpeakersChanged = (speakers: Participant[]): void => {
if (!this.isSmartMosaicEnabled()) return;
const remoteSpeakerIds = speakers.filter((p) => !p.isLocal).map((p) => p.identity);
if (remoteSpeakerIds.length > 0) {
this.updateSpeakerRecency(remoteSpeakerIds);
}
};
private updateSpeakerRecency(newSpeakerIds: string[]): void {
const newSpeakerSet = new Set(newSpeakerIds);
const currentOrder = this._speakerRecencyOrder();
const withoutNewSpeakers = currentOrder.filter((id) => !newSpeakerSet.has(id));
const updatedOrder = [...withoutNewSpeakers, ...newSpeakerIds];
const maxHistorySize = this._maxRemoteSpeakers() * 2;
this._speakerRecencyOrder.set(updatedOrder.slice(-maxHistorySize));
}
}

View File

@ -102,9 +102,9 @@ export class MeetingContextService {
readonly canModerateRoom = computed(() => this.featureConfigService.features().canModerateRoom);
/**
* Computed signal for whether the layout selector feature is enabled
* Computed signal for whether layout switching is allowed
*/
readonly showLayoutSelector = computed(() => this.featureConfigService.features().showLayoutSelector);
readonly allowLayoutSwitching = computed(() => this.featureConfigService.features().allowLayoutSwitching);
/**
* Computed signal for whether the device is mobile