frontend: enhance layout management with reactive settings and storage integration

This commit is contained in:
Carlos Santos 2025-11-24 18:58:11 +01:00
parent c8cfb6598e
commit 153af9c673
6 changed files with 174 additions and 142 deletions

View File

@ -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<number>(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<string[]>([]);
/**
* 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`);
}
}

View File

@ -9,7 +9,7 @@
<div class="layout-mode-container">
<mat-radio-group
class="layout-radio-group"
[(ngModel)]="layoutMode"
[ngModel]="layoutMode()"
(ngModelChange)="onLayoutModeChange($event)"
aria-label="Select layout mode"
>
@ -41,11 +41,12 @@
[max]="6"
[step]="1"
[displayWith]="formatLabel"
[showTickMarks]="true"
discrete
>
<input
matSliderThumb
[(ngModel)]="participantCount"
[ngModel]="participantCount()"
(ngModelChange)="onParticipantCountChange($event)"
aria-label="Number of participants to display"
/>

View File

@ -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>(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<number>(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);
}
/**

View File

@ -1,5 +1,6 @@
export enum MeetStorageKeys {
LAYOUT_MODE = 'layoutMode'
LAYOUT_MODE = 'layoutMode',
MAX_REMOTE_SPEAKERS = 'maxRemoteSpeakers'
}
export const STORAGE_PREFIX = 'OpenViduMeet-';

View File

@ -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<MeetLayoutMode> = new Subject<MeetLayoutMode>();
layoutMode$: Observable<MeetLayoutMode> = 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>(MeetLayoutMode.MOSAIC);
readonly layoutMode = this._layoutMode.asReadonly();
private readonly _maxRemoteSpeakers = signal<number>(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();
}
}

View File

@ -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;
}
}