diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-layout/meeting-layout.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-layout/meeting-layout.component.ts index 362ebbc7..2a45258f 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-layout/meeting-layout.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-layout/meeting-layout.component.ts @@ -1,5 +1,4 @@ import { Component, signal, computed, effect, inject, DestroyRef, input, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { Participant } from 'livekit-client'; import { LoggerService, OpenViduService, ILogger, OpenViduComponentsUiModule } from 'openvidu-components-angular'; import { MeetLayoutMode } from '../../../models/layout.model'; @@ -23,66 +22,45 @@ import { MeetingService } from '../../../services/meeting/meeting.service'; }) export class MeetingLayoutComponent { private readonly loggerSrv = inject(LoggerService); - private readonly layoutService = inject(MeetLayoutService); + protected readonly layoutService = inject(MeetLayoutService); protected readonly openviduService = inject(OpenViduService); protected meetingContextService = inject(MeetingContextService); protected meetingService = inject(MeetingService); - private readonly destroyRef = inject(DestroyRef); + protected readonly destroyRef = inject(DestroyRef); private readonly log: ILogger = this.loggerSrv.get('MeetingLayoutComponent'); 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'; - /** - * Maximum number of active remote speakers to show in the layout when the last speakers layout is enabled. - */ - readonly maxRemoteSpeakers = input(4); + protected readonly meetingUrl = computed(() => this.meetingContextService.meetingUrl()); - protected meetingUrl = computed(() => { - return this.meetingContextService.meetingUrl(); - }); - - protected showMeetingLinkOverlay = computed(() => { + protected readonly showMeetingLinkOverlay = computed(() => { const remoteParticipants = this.meetingContextService.remoteParticipants(); return this.meetingContextService.canModerateRoom() && remoteParticipants.length === 0; }); - // Reactive state with Signals - now using MeetingContextService - private readonly remoteParticipants = computed(() => this.meetingContextService.remoteParticipants()); - - private readonly layoutMode = toSignal(this.layoutService.layoutMode$, { - initialValue: MeetLayoutMode.SMART_MOSAIC - }); - /** * Tracks the order of active speakers (most recent last) - * Using array instead of Map for better ordered iteration performance */ private readonly activeSpeakersOrder = signal([]); - /** - * Computed signal that determines if last speakers layout is enabled - */ - private readonly isLastSpeakersLayoutEnabled = computed(() => this.layoutMode() === MeetLayoutMode.SMART_MOSAIC); - /** * Computed signal that provides the filtered list of participants to display. - * This is the main output used by the template. - * Optimized with memoization via computed() + * Automatically reacts to changes in layout service configuration. */ readonly filteredRemoteParticipants = computed(() => { - const remoteParticipants = this.remoteParticipants(); - const isLastSpeakersMode = this.isLastSpeakersLayoutEnabled(); + const remoteParticipants = this.meetingContextService.remoteParticipants(); + const isLastSpeakersMode = this.layoutService.isSmartMosaicEnabled(); if (!isLastSpeakersMode) { - // DEFAULT layout mode: show all participants + // MOSAIC layout mode: show all participants return remoteParticipants; } - // LAST_SPEAKERS layout mode: show only active speakers + // SMART_MOSAIC layout mode: show only active speakers const activeSpeakersOrder = this.activeSpeakersOrder(); - const maxSpeakers = this.maxRemoteSpeakers(); + const maxSpeakers = this.layoutService.maxRemoteSpeakers(); // If no active speakers yet, initialize with first N remote participants if (activeSpeakersOrder.length === 0) { @@ -113,42 +91,22 @@ export class MeetingLayoutComponent { constructor() { effect(() => { - const lkRoom = this.meetingContextService.lkRoom(); - if (lkRoom) { + if (this.meetingContextService.lkRoom()) { this.setupActiveSpeakersListener(); } }); - // Effect to log layout mode changes (development only) - effect(() => { - const mode = this.layoutMode(); - this.log.d(`Layout mode changed to: ${mode}`); - }); - // Effect to handle active speakers cleanup when participants leave effect(() => { - const remoteParticipants = this.remoteParticipants(); + if (!this.layoutService.isSmartMosaicEnabled()) return; + + const remoteParticipants = this.meetingContextService.remoteParticipants(); const activeSpeakersOrder = this.activeSpeakersOrder(); - - // Only cleanup in last speakers mode - if (!this.isLastSpeakersLayoutEnabled()) { - return; - } - - // Create set of current participant identities for O(1) lookup const currentIdentities = new Set(remoteParticipants.map((p) => p.identity)); - - // Filter out speakers who are no longer in the room const cleanedOrder = activeSpeakersOrder.filter((identity) => currentIdentities.has(identity)); - // Only update if something changed if (cleanedOrder.length !== activeSpeakersOrder.length) { - untracked(() => { - this.activeSpeakersOrder.set(cleanedOrder); - this.log.d( - `Cleaned active speakers order. Removed ${activeSpeakersOrder.length - cleanedOrder.length} participants` - ); - }); + untracked(() => this.activeSpeakersOrder.set(cleanedOrder)); } }); } @@ -184,76 +142,46 @@ export class MeetingLayoutComponent { /** * Handles active speakers changed events from LiveKit - * Optimized with early returns and Set operations */ private readonly handleActiveSpeakersChanged = (speakers: Participant[]): void => { - // Early return if not in last speakers mode - if (!this.isLastSpeakersLayoutEnabled()) { - return; - } + if (!this.layoutService.isSmartMosaicEnabled()) return; - // Filter out local participant const remoteSpeakers = speakers.filter((p) => !p.isLocal); + if (remoteSpeakers.length === 0) return; - if (remoteSpeakers.length === 0) { - return; - } - - // Get new speaker identities (trimmed to max) - const maxSpeakers = this.maxRemoteSpeakers(); + const maxSpeakers = this.layoutService.maxRemoteSpeakers(); const newSpeakerIdentities = remoteSpeakers.map((p) => p.identity).slice(0, maxSpeakers); - // Early return if speakers haven't changed (optimization) - if (this.isSameSpeakersList(newSpeakerIdentities)) { - return; - } + if (this.isSameSpeakersList(newSpeakerIdentities)) return; - // Update active speakers order this.updateActiveSpeakersOrder(newSpeakerIdentities); }; /** * Checks if the new speakers list is identical to the current one - * Optimized comparison with early returns */ private isSameSpeakersList(newIdentities: string[]): boolean { const currentOrder = this.activeSpeakersOrder(); - const maxSpeakers = this.maxRemoteSpeakers(); - - // Get the current active speakers (last N) + const maxSpeakers = this.layoutService.maxRemoteSpeakers(); const currentActiveIdentities = currentOrder.slice(-maxSpeakers); - // Quick length check - if (currentActiveIdentities.length !== newIdentities.length) { - return false; - } - - // Compare elements in order - return currentActiveIdentities.every((identity, index) => identity === newIdentities[index]); + return ( + currentActiveIdentities.length === newIdentities.length && + currentActiveIdentities.every((identity, index) => identity === newIdentities[index]) + ); } /** * Updates the active speakers order with new speakers - * Maintains order with most recent speakers at the end - * Uses efficient Set operations for O(1) lookups */ private updateActiveSpeakersOrder(newSpeakerIdentities: string[]): void { const currentOrder = this.activeSpeakersOrder(); const newIdentitiesSet = new Set(newSpeakerIdentities); - - // Remove new speakers from current position (if they exist) const filteredOrder = currentOrder.filter((identity) => !newIdentitiesSet.has(identity)); - - // Add new speakers to the end (most recent) const updatedOrder = [...filteredOrder, ...newSpeakerIdentities]; - - // Trim to reasonable max size to prevent memory leaks - // Keep 2x maxRemoteSpeakers for smooth transitions - const maxSpeakers = this.maxRemoteSpeakers(); + const maxSpeakers = this.layoutService.maxRemoteSpeakers(); const trimmedOrder = updatedOrder.slice(-(maxSpeakers * 2)); this.activeSpeakersOrder.set(trimmedOrder); - - this.log.d(`Active speakers updated: ${trimmedOrder.length} in order`); } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.html index b5cbbecb..8b7ff54c 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.html @@ -9,7 +9,7 @@
@@ -41,11 +41,12 @@ [max]="6" [step]="1" [displayWith]="formatLabel" + [showTickMarks]="true" discrete > diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.ts index c24a40a3..1b6f299e 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-settings-panel/meeting-settings-panel.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, signal } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; @@ -8,12 +8,10 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { FormsModule } from '@angular/forms'; import { MeetLayoutMode } from '../../../models/layout.model'; +import { MeetLayoutService } from '../../../services/layout.service'; /** * Component for additional settings in the Settings Panel. - * This component allows users to configure grid layout preferences including: - * - Layout mode (Mosaic or Mosaic Smart) - * - Number of participants to display in Smart mode */ @Component({ selector: 'ov-meeting-settings-panel', @@ -31,43 +29,40 @@ import { MeetLayoutMode } from '../../../models/layout.model'; styleUrl: './meeting-settings-panel.component.scss' }) export class MeetingSettingsPanelComponent { + private readonly layoutService = inject(MeetLayoutService); + /** * Expose LayoutMode enum to template */ readonly LayoutMode = MeetLayoutMode; /** - * Current selected layout mode + * Current layout mode */ - layoutMode = signal(MeetLayoutMode.MOSAIC); + protected readonly layoutMode = computed(() => this.layoutService.layoutMode()); /** - * Number of participants to display in Smart mode - * Range: 1-20 + * Current participant count */ - participantCount = signal(6); + protected readonly participantCount = computed(() => this.layoutService.maxRemoteSpeakers()); /** - * Computed property to check if Smart mode is active + * Computed property to check if Smart Mosaic mode is active */ - isSmartMode = computed(() => this.layoutMode() === MeetLayoutMode.SMART_MOSAIC); + readonly isSmartMode = this.layoutService.isSmartMosaicEnabled; /** * Handler for layout mode change */ onLayoutModeChange(mode: MeetLayoutMode): void { - this.layoutMode.set(mode); - console.log('Layout mode changed to:', mode); - // TODO: Integrate with layout service when available + this.layoutService.setLayoutMode(mode); } /** * Handler for participant count change */ onParticipantCountChange(count: number): void { - this.participantCount.set(count); - console.log('Participant count changed to:', count); - // TODO: Integrate with layout service when available + this.layoutService.setMaxRemoteSpeakers(count); } /** diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/storage.model.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/storage.model.ts index e373f3da..1986af70 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/storage.model.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/storage.model.ts @@ -1,5 +1,6 @@ export enum MeetStorageKeys { - LAYOUT_MODE = 'layoutMode' + LAYOUT_MODE = 'layoutMode', + MAX_REMOTE_SPEAKERS = 'maxRemoteSpeakers' } export const STORAGE_PREFIX = 'OpenViduMeet-'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/layout.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/layout.service.ts index 273948b5..2f337266 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/layout.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/layout.service.ts @@ -1,6 +1,5 @@ -import { Injectable } from '@angular/core'; +import { Injectable, signal, computed, effect } from '@angular/core'; import { LayoutService, LoggerService, ViewportService } from 'openvidu-components-angular'; -import { Observable, Subject } from 'rxjs'; import { MeetLayoutMode } from '../models/layout.model'; import { MeetStorageService } from './storage.service'; @@ -8,9 +7,21 @@ import { MeetStorageService } from './storage.service'; providedIn: 'root' }) export class MeetLayoutService extends LayoutService { - private layoutMode: MeetLayoutMode = MeetLayoutMode.MOSAIC; - layoutModeSubject: Subject = new Subject(); - layoutMode$: Observable = this.layoutModeSubject.asObservable(); + + private DEFAULT_MIN_REMOTE_SPEAKERS = 1; + private DEFAULT_SMART_MOSAIC_SPEAKERS = 4; + private DEFAULT_LAYOUT_MODE = MeetLayoutMode.MOSAIC; + + private readonly _layoutMode = signal(MeetLayoutMode.MOSAIC); + readonly layoutMode = this._layoutMode.asReadonly(); + private readonly _maxRemoteSpeakers = signal(this.DEFAULT_SMART_MOSAIC_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); constructor( protected loggerService: LoggerService, @@ -21,48 +32,125 @@ export class MeetLayoutService extends LayoutService { 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}`); + }); } /** * Initializes the layout mode for the application. - * - * This method retrieves the layout mode from the storage service. If the retrieved - * layout mode is valid and exists in the `LayoutMode` enum, it sets the layout mode - * to the retrieved value. Otherwise, it defaults to `LayoutMode.DEFAULT`. + * Retrieves the layout mode from storage or defaults to MOSAIC. */ - private initializeLayoutMode() { + private initializeLayoutMode(): void { const layoutMode = this.storageService.getLayoutMode(); if (layoutMode && Object.values(MeetLayoutMode).includes(layoutMode)) { - this.layoutMode = layoutMode; + this._layoutMode.set(layoutMode); } else { - this.layoutMode = MeetLayoutMode.MOSAIC; + this._layoutMode.set(this.DEFAULT_LAYOUT_MODE); } + this.log.d(`Layout mode initialized: ${this._layoutMode()}`); + } + + /** + * Initializes the max remote speakers count from storage. + */ + private initializeMaxRemoteSpeakers(): void { + const count = this.storageService.getMaxRemoteSpeakers(); + if (count && count >= this.DEFAULT_MIN_REMOTE_SPEAKERS && count <= this.DEFAULT_SMART_MOSAIC_SPEAKERS) { + this._maxRemoteSpeakers.set(count); + } else { + this._maxRemoteSpeakers.set(this.DEFAULT_SMART_MOSAIC_SPEAKERS); + } + this.log.d(`Max remote speakers initialized: ${this._maxRemoteSpeakers()}`); } /** * Checks if the current layout mode is set to display the last speakers. - * - * @returns {boolean} `true` if the layout mode is set to `LAST_SPEAKERS`, otherwise `false`. + * @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; + return this._layoutMode() === MeetLayoutMode.SMART_MOSAIC; } - setLayoutMode(layoutMode: MeetLayoutMode) { - const layoutNeedsUpdate = this.layoutMode !== layoutMode && Object.values(MeetLayoutMode).includes(layoutMode); + /** + * 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 (!layoutNeedsUpdate) { + if (!isValidMode) { + this.log.w(`Invalid layout mode: ${layoutMode}`); return; } - this.log.d(`Layout mode updated from ${this.layoutMode} to ${layoutMode}`); - this.layoutMode = layoutMode; - this.layoutModeSubject.next(this.layoutMode); - this.storageService.setLayoutMode(layoutMode); + 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.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.DEFAULT_MIN_REMOTE_SPEAKERS || count > this.DEFAULT_SMART_MOSAIC_SPEAKERS) { + this.log.w(`Invalid max remote speakers count: ${count}. Must be between ${this.DEFAULT_MIN_REMOTE_SPEAKERS} and ${this.DEFAULT_SMART_MOSAIC_SPEAKERS}`); + 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); + + // Trigger layout update if in Smart Mosaic mode + if (this.isSmartMosaicEnabled()) { + this.update(); + } + } + + /** + * Gets the current layout mode. + * @deprecated Use layoutMode signal directly instead + * @returns {MeetLayoutMode} The current layout mode + */ getLayoutMode(): MeetLayoutMode { - return this.layoutMode; + return this._layoutMode(); + } + + /** + * Gets the current max remote speakers count. + * @returns {number} The current max remote speakers count + */ + getMaxRemoteSpeakers(): number { + return this._maxRemoteSpeakers(); } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/storage.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/storage.service.ts index 848ee4d0..dd4bf22d 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/storage.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/storage.service.ts @@ -17,16 +17,35 @@ export class MeetStorageService extends StorageService { * * @param layoutMode - The layout mode to be set. */ - setLayoutMode(layoutMode: MeetLayoutMode) { + setLayoutMode(layoutMode: MeetLayoutMode): void { this.set(MeetStorageKeys.LAYOUT_MODE, layoutMode); } /** * Retrieves the current layout mode from storage. * - * @returns {string} The layout mode stored in the storage, or an empty string if not found. + * @returns {MeetLayoutMode | null} The layout mode stored in the storage, or null if not found. */ getLayoutMode(): MeetLayoutMode | null { return this.get(MeetStorageKeys.LAYOUT_MODE) || null; } + + /** + * Sets the maximum number of remote speakers to display in Smart Mosaic mode. + * + * @param count - The maximum number of remote speakers (1-20). + */ + setMaxRemoteSpeakers(count: number): void { + this.set(MeetStorageKeys.MAX_REMOTE_SPEAKERS, count.toString()); + } + + /** + * Retrieves the maximum number of remote speakers from storage. + * + * @returns {number | null} The max remote speakers count, or null if not found. + */ + getMaxRemoteSpeakers(): number | null { + const value = this.get(MeetStorageKeys.MAX_REMOTE_SPEAKERS); + return value ? parseInt(value, 10) : null; + } }