ov-components: Refactor videoconference component to use centralized state management

master
Carlos Santos 2025-07-22 18:17:57 +02:00
parent 8af9f75a10
commit 04b8b741e2
3 changed files with 233 additions and 61 deletions

View File

@ -1,16 +1,16 @@
<div id="call-container"> <div id="call-container">
<div id="spinner" *ngIf="loading"> <div id="spinner" *ngIf="componentState.isLoading">
<mat-spinner [diameter]="50"></mat-spinner> <mat-spinner [diameter]="spinnerDiameter"></mat-spinner>
<span>{{ 'PREJOIN.PREPARING' | translate }}</span> <span>{{ 'PREJOIN.PREPARING' | translate }}</span>
</div> </div>
<div [@inOutAnimation] id="pre-join-container" *ngIf="showPrejoin && !loading"> <div [@inOutAnimation] id="pre-join-container" *ngIf="componentState.showPrejoin && !componentState.isLoading">
<ng-container *ngIf="openviduAngularPreJoinTemplate; else defaultPreJoin"> <ng-container *ngIf="openviduAngularPreJoinTemplate; else defaultPreJoin">
<ng-container *ngTemplateOutlet="openviduAngularPreJoinTemplate"></ng-container> <ng-container *ngTemplateOutlet="openviduAngularPreJoinTemplate"></ng-container>
</ng-container> </ng-container>
<ng-template #defaultPreJoin> <ng-template #defaultPreJoin>
<ov-pre-join <ov-pre-join
[error]="_tokenError" [error]="componentState.error?.tokenError"
(onReadyToJoin)="_onReadyToJoin()" (onReadyToJoin)="_onReadyToJoin()"
(onVideoDeviceChanged)="onVideoDeviceChanged.emit($event)" (onVideoDeviceChanged)="onVideoDeviceChanged.emit($event)"
(onVideoEnabledChanged)="onVideoEnabledChanged.emit($event)" (onVideoEnabledChanged)="onVideoEnabledChanged.emit($event)"
@ -21,12 +21,12 @@
</ng-template> </ng-template>
</div> </div>
<div id="spinner" *ngIf="!loading && error"> <div id="spinner" *ngIf="!componentState.isLoading && componentState.error?.hasError">
<mat-icon class="error-icon">error</mat-icon> <mat-icon class="error-icon">error</mat-icon>
<span>{{ errorMessage }}</span> <span>{{ componentState.error?.message }}</span>
</div> </div>
<div [@inOutAnimation] id="vc-container" *ngIf="isRoomReady && !showPrejoin && !loading && !error"> <div [@inOutAnimation] id="vc-container" *ngIf="componentState.isRoomReady && !componentState.showPrejoin && !componentState.isLoading && !componentState.error?.hasError">
<ov-session <ov-session
(onRoomCreated)="onRoomCreated.emit($event)" (onRoomCreated)="onRoomCreated.emit($event)"
(onRoomReconnecting)="onRoomDisconnected.emit()" (onRoomReconnecting)="onRoomDisconnected.emit()"

View File

@ -17,6 +17,7 @@ import {
ToolbarDirective ToolbarDirective
} from '../../directives/template/openvidu-components-angular.directive'; } from '../../directives/template/openvidu-components-angular.directive';
import { ILogger } from '../../models/logger.model'; import { ILogger } from '../../models/logger.model';
import { VideoconferenceState, VideoconferenceStateInfo } from '../../models/videoconference-state.model';
import { ActionService } from '../../services/action/action.service'; import { ActionService } from '../../services/action/action.service';
import { OpenViduComponentsConfigService } from '../../services/config/directive-config.service'; import { OpenViduComponentsConfigService } from '../../services/config/directive-config.service';
import { DeviceService } from '../../services/device/device.service'; import { DeviceService } from '../../services/device/device.service';
@ -52,13 +53,20 @@ import { LangOption } from '../../models/lang.model';
styleUrls: ['./videoconference.component.scss'], styleUrls: ['./videoconference.component.scss'],
animations: [ animations: [
trigger('inOutAnimation', [ trigger('inOutAnimation', [
transition(':enter', [style({ opacity: 0 }), animate('300ms ease-out', style({ opacity: 1 }))]) transition(':enter', [style({ opacity: 0 }), animate(`${VideoconferenceComponent.ANIMATION_DURATION_MS}ms ease-out`, style({ opacity: 1 }))])
// transition(':leave', [style({ opacity: 1 }), animate('50ms ease-in', style({ opacity: 0.9 }))]) // transition(':leave', [style({ opacity: 1 }), animate('50ms ease-in', style({ opacity: 0.9 }))])
]) ])
], ],
standalone: false standalone: false
}) })
export class VideoconferenceComponent implements OnDestroy, AfterViewInit { export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
// Constants
private static readonly PARTICIPANT_NAME_TIMEOUT_MS = 1000;
private static readonly ANIMATION_DURATION_MS = 300;
private static readonly MATERIAL_ICONS_URL = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&icon_names=background_replace,keep_off';
private static readonly MATERIAL_ICONS_SELECTOR = 'link[href*="Material+Symbols+Outlined"]';
private static readonly SPINNER_DIAMETER = 50;
// *** Toolbar *** // *** Toolbar ***
/** /**
* @internal * @internal
@ -379,41 +387,55 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
/** /**
* @internal * @internal
* Centralized state management for the videoconference component
*/ */
error: boolean = false; componentState: VideoconferenceStateInfo = {
/** state: VideoconferenceState.INITIALIZING,
* @internal showPrejoin: true,
*/ isRoomReady: false,
errorMessage: string = ''; isConnected: false,
/** hasAudioDevices: false,
* @internal hasVideoDevices: false,
*/ hasUserInitiatedJoin: false,
showPrejoin: boolean = true; wasPrejoinShown: false,
isLoading: true,
/** error: {
* @internal hasError: false,
* Tracks if user has initiated the join process to prevent prejoin from showing again message: '',
*/ tokenError: null
private hasUserInitiatedJoin: boolean = false; }
};
/**
* @internal
*/
isRoomReady: boolean = false;
/**
* @internal
*/
loading = true;
/**
* @internal
*/
_tokenError: any;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
private log: ILogger; private log: ILogger;
private latestParticipantName: string | undefined; private latestParticipantName: string | undefined;
// Expose constants to template
get spinnerDiameter(): number {
return VideoconferenceComponent.SPINNER_DIAMETER;
}
/**
* @internal
* Updates the component state
*/
private updateComponentState(newState: Partial<VideoconferenceStateInfo>): void {
this.componentState = { ...this.componentState, ...newState };
this.log.d(`State updated to: ${this.componentState.state}`, this.componentState);
}
/**
* @internal
* Checks if user has initiated the join process
*/
private hasUserInitiatedJoin(): boolean {
return (
this.componentState.state === VideoconferenceState.JOINING ||
this.componentState.state === VideoconferenceState.READY_TO_CONNECT ||
this.componentState.state === VideoconferenceState.CONNECTED
);
}
/** /**
* @internal * @internal
*/ */
@ -426,6 +448,17 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
private libService: OpenViduComponentsConfigService private libService: OpenViduComponentsConfigService
) { ) {
this.log = this.loggerSrv.get('VideoconferenceComponent'); this.log = this.loggerSrv.get('VideoconferenceComponent');
// Initialize state
this.updateComponentState({
state: VideoconferenceState.INITIALIZING,
showPrejoin: true,
isRoomReady: false,
wasPrejoinShown: false,
isLoading: true,
error: { hasError: false }
});
this.subscribeToVideconferenceDirectives(); this.subscribeToVideconferenceDirectives();
} }
@ -441,7 +474,11 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
ngAfterViewInit() { ngAfterViewInit() {
this.addMaterialIconsIfNeeded(); this.addMaterialIconsIfNeeded();
this.setupTemplates(); this.setupTemplates();
this.deviceSrv.initializeDevices().then(() => (this.loading = false)); this.deviceSrv.initializeDevices().then(() => {
this.updateComponentState({
isLoading: false
});
});
} }
/** /**
@ -449,10 +486,10 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
*/ */
private addMaterialIconsIfNeeded(): void { private addMaterialIconsIfNeeded(): void {
//Add material icons to the page if not already present //Add material icons to the page if not already present
const existingLink = document.querySelector('link[href*="Material+Symbols+Outlined"]'); const existingLink = document.querySelector(VideoconferenceComponent.MATERIAL_ICONS_SELECTOR);
if (!existingLink) { if (!existingLink) {
const link = document.createElement('link'); const link = document.createElement('link');
link.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&icon_names=background_replace,keep_off'; link.href = VideoconferenceComponent.MATERIAL_ICONS_URL;
link.rel = 'stylesheet'; link.rel = 'stylesheet';
document.head.appendChild(link); document.head.appendChild(link);
} }
@ -630,17 +667,23 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
try { try {
// Mark that user has initiated the join process // Mark that user has initiated the join process
this.hasUserInitiatedJoin = true; this.updateComponentState({
state: VideoconferenceState.JOINING,
wasPrejoinShown: this.componentState.showPrejoin
});
// Always initialize the room when ready to join // Always initialize the room when ready to join
this.openviduService.initRoom(); this.openviduService.initRoom();
const participantName = this.latestParticipantName; const participantName = this.latestParticipantName;
if (this.isRoomReady) { if (this.componentState.isRoomReady) {
// Room is ready, hide prejoin and proceed // Room is ready, hide prejoin and proceed
this.log.d('Room is ready, proceeding to join'); this.log.d('Room is ready, proceeding to join');
this.showPrejoin = false; this.updateComponentState({
state: VideoconferenceState.READY_TO_CONNECT,
showPrejoin: false
});
} else { } else {
// Room not ready, request token if we have a participant name // Room not ready, request token if we have a participant name
if (participantName) { if (participantName) {
@ -651,26 +694,34 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
} }
} }
const wasPrejoinShown = this.showPrejoin;
// Emit onReadyToJoin event only if prejoin page was actually shown // Emit onReadyToJoin event only if prejoin page was actually shown
// This ensures the event semantics are correct // This ensures the event semantics are correct
if (wasPrejoinShown) { if (this.componentState.wasPrejoinShown) {
this.log.d('Emitting onReadyToJoin event (prejoin was shown)'); this.log.d('Emitting onReadyToJoin event (prejoin was shown)');
this.onReadyToJoin.emit(); this.onReadyToJoin.emit();
} }
} catch (error) { } catch (error) {
this.log.e('Error during ready to join process', error); this.log.e('Error during ready to join process', error);
// Could emit an error event or handle gracefully based on requirements this.updateComponentState({
state: VideoconferenceState.ERROR,
error: {
hasError: true,
message: 'Error during ready to join process'
}
});
} }
} }
/** /**
* @internal * @internal
*/ */
_onParticipantLeft(event: ParticipantLeftEvent) { _onParticipantLeft(event: ParticipantLeftEvent) {
this.isRoomReady = false; // Reset to disconnected state to allow prejoin to show again if needed
// Reset join initiation flag to allow prejoin to show again if needed this.updateComponentState({
this.hasUserInitiatedJoin = false; state: VideoconferenceState.DISCONNECTED,
isRoomReady: false,
showPrejoin: this.libService.showPrejoin()
});
this.onParticipantLeft.emit(event); this.onParticipantLeft.emit(event);
} }
@ -685,20 +736,34 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
const livekitUrl = this.libService.getLivekitUrl(); const livekitUrl = this.libService.getLivekitUrl();
this.openviduService.initializeAndSetToken(token, livekitUrl); this.openviduService.initializeAndSetToken(token, livekitUrl);
this.log.d('Token has been successfully set. Room is ready to join'); this.log.d('Token has been successfully set. Room is ready to join');
this.isRoomReady = true;
// Only update showPrejoin if user hasn't initiated join process yet // Only update showPrejoin if user hasn't initiated join process yet
// This prevents prejoin from showing again after user clicked join // This prevents prejoin from showing again after user clicked join
if (!this.hasUserInitiatedJoin) { if (!this.hasUserInitiatedJoin()) {
this.showPrejoin = this.libService.showPrejoin(); this.updateComponentState({
state: VideoconferenceState.PREJOIN_SHOWN,
isRoomReady: true,
showPrejoin: this.libService.showPrejoin()
});
} else { } else {
// User has initiated join, proceed to hide prejoin and continue // User has initiated join, proceed to hide prejoin and continue
this.log.d('User has initiated join, hiding prejoin and proceeding'); this.log.d('User has initiated join, hiding prejoin and proceeding');
this.showPrejoin = false; this.updateComponentState({
state: VideoconferenceState.READY_TO_CONNECT,
isRoomReady: true,
showPrejoin: false
});
} }
} catch (error) { } catch (error) {
this.log.e('Error trying to set token', error); this.log.e('Error trying to set token', error);
this._tokenError = error; this.updateComponentState({
state: VideoconferenceState.ERROR,
error: {
hasError: true,
message: 'Error setting token',
tokenError: error
}
});
} }
}); });
@ -706,16 +771,26 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
if (!error) return; if (!error) return;
this.log.e('Token error received', error); this.log.e('Token error received', error);
this._tokenError = error; this.updateComponentState({
state: VideoconferenceState.ERROR,
error: {
hasError: true,
message: 'Token error',
tokenError: error
}
});
if (!this.showPrejoin) { if (!this.componentState.showPrejoin) {
this.actionService.openDialog(error.name, error.message, false); this.actionService.openDialog(error.name, error.message, false);
} }
}); });
this.libService.prejoin$.pipe(takeUntil(this.destroy$)).subscribe((value: boolean) => { this.libService.prejoin$.pipe(takeUntil(this.destroy$)).subscribe((value: boolean) => {
this.showPrejoin = value; this.updateComponentState({
if (!this.showPrejoin) { showPrejoin: value
});
if (!value) {
// Emit token ready if the prejoin page won't be shown // Emit token ready if the prejoin page won't be shown
// Ensure we have a participant name before proceeding with the join // Ensure we have a participant name before proceeding with the join
@ -747,10 +822,9 @@ export class VideoconferenceComponent implements OnDestroy, AfterViewInit {
} }
this._onReadyToJoin(); this._onReadyToJoin();
} }
}, 1000); }, VideoconferenceComponent.PARTICIPANT_NAME_TIMEOUT_MS);
} }
} }
// this.cd.markForCheck();
}); });
this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe((name: string) => { this.libService.participantName$.pipe(takeUntil(this.destroy$)).subscribe((name: string) => {

View File

@ -0,0 +1,98 @@
/**
* Enum representing the possible states of the videoconference component
*/
export enum VideoconferenceState {
/**
* Initial state when the component is loading
*/
INITIALIZING = 'INITIALIZING',
/**
* Prejoin page is being shown to the user
*/
PREJOIN_SHOWN = 'PREJOIN_SHOWN',
/**
* User has initiated the join process, waiting for token
*/
JOINING = 'JOINING',
/**
* Token received and room is ready to connect
*/
READY_TO_CONNECT = 'READY_TO_CONNECT',
/**
* Successfully connected to the room
*/
CONNECTED = 'CONNECTED',
/**
* Disconnected from the room
*/
DISCONNECTED = 'DISCONNECTED',
/**
* Error state
*/
ERROR = 'ERROR'
}
/**
* Interface representing the state information of the videoconference component
*/
export interface VideoconferenceStateInfo {
/**
* Current state of the videoconference
*/
state: VideoconferenceState;
/**
* Whether prejoin page should be visible
*/
showPrejoin: boolean;
/**
* Whether room is ready for connection
*/
isRoomReady: boolean;
/**
* Whether user is connected to the room
*/
isConnected: boolean;
/**
* Whether audio devices are available
*/
hasAudioDevices: boolean;
/**
* Whether video devices are available
*/
hasVideoDevices: boolean;
/**
* Whether user has initiated the join process
*/
hasUserInitiatedJoin: boolean;
/**
* Whether prejoin was shown to the user at least once
*/
wasPrejoinShown: boolean;
/**
* Whether the component is in loading state
*/
isLoading: boolean;
/**
* Error information if any
*/
error?: {
hasError: boolean;
message?: string;
tokenError?: any;
};
}