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:
parent
5638025211
commit
68c5ce7cd2
@ -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>
|
||||
|
||||
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!-- Grid Layout Settings Button -->
|
||||
@if (showLayoutSelector()) {
|
||||
@if (isLayoutSwitchingAllowed()) {
|
||||
<button
|
||||
mat-menu-item
|
||||
id="grid-layout-settings-btn"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user