From 2aa3bc1177124d7d63a254a1ab6f4dd39105bd42 Mon Sep 17 00:00:00 2001 From: Carlos Santos <4a.santos@gmail.com> Date: Mon, 9 Jun 2025 13:41:17 +0200 Subject: [PATCH] frontend: implement NavigationService and RecordingManagerService with error handling and navigation methods --- .../src/lib/models/navigation.model.ts | 5 ++ .../pages/video-room/video-room.component.ts | 61 +++++----------- .../lib/services/context/context.service.ts | 2 +- .../src/lib/services/index.ts | 2 + .../navigation/navigation.service.spec.ts | 16 +++++ .../services/navigation/navigation.service.ts | 71 +++++++++++++++++++ .../recording-manager.service.spec.ts | 16 +++++ .../recording-manager.service.ts | 37 ++++++++++ 8 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.spec.ts create mode 100644 frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.ts diff --git a/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts b/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts new file mode 100644 index 0000000..9d74c0a --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/models/navigation.model.ts @@ -0,0 +1,5 @@ +export interface ErrorRedirectReason { + 'invalid-secret': string; + 'invalid-room': string; + 'internal-error': string; +} diff --git a/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts b/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts index de10d81..2ba05b8 100644 --- a/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts +++ b/frontend/projects/shared-meet-components/src/lib/pages/video-room/video-room.component.ts @@ -1,11 +1,11 @@ -import { Location } from '@angular/common'; +// import { Location } from '@angular/common'; import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { MeetRecordingAccess, MeetRoomPreferences, OpenViduMeetPermissions, ParticipantRole } from '@lib/typings/ce'; import { ApiDirectiveModule, @@ -27,6 +27,8 @@ import { WebComponentManagerService } from '../../services'; import { ParticipantTokenService } from '@lib/services/participant-token/participant-token.service'; +import { RecordingManagerService } from '@lib/services/recording-manager/recording-manager.service'; +import { NavigationService } from '@lib/services/navigation/navigation.service'; @Component({ selector: 'app-video-room', @@ -84,10 +86,10 @@ export class VideoRoomComponent implements OnInit, OnDestroy { constructor( protected httpService: HttpService, + protected navigationService: NavigationService, protected participantTokenService: ParticipantTokenService, - protected router: Router, + protected recManagerService: RecordingManagerService, protected route: ActivatedRoute, - protected location: Location, protected authService: AuthService, protected ctxService: ContextService, protected roomService: RoomService, @@ -122,7 +124,6 @@ export class VideoRoomComponent implements OnInit, OnDestroy { await this.generateParticipantToken(); await this.replaceUrlQueryParams(); await this.loadRoomPreferences(); - this.updateFeatureConfiguration(); this.showRoom = true; } catch (error) { console.error('Error accessing room:', error); @@ -169,7 +170,8 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.participantName, this.roomSecret ); - this.participantToken = token; + // The components library needs the token to be set in the 'onTokenRequested' method + // this.participantToken = token; this.participantRole = role; this.participantPermissions = permissions; } catch (error: any) { @@ -177,11 +179,11 @@ export class VideoRoomComponent implements OnInit, OnDestroy { switch (error.status) { case 400: // Invalid secret - this.redirectToErrorPage('invalid-secret'); + await this.navigationService.redirectToErrorPage('invalid-secret'); break; case 404: // Room not found - this.redirectToErrorPage('invalid-room'); + await this.navigationService.redirectToErrorPage('invalid-room'); break; case 409: // Participant already exists. @@ -189,7 +191,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.participantForm.get('name')?.setErrors({ participantExists: true }); throw new Error('Participant already exists in the room'); default: - this.redirectToErrorPage('internal-error'); + await this.navigationService.redirectToErrorPage('internal-error'); } } } @@ -210,14 +212,10 @@ export class VideoRoomComponent implements OnInit, OnDestroy { } // Replace secret and participant name in the URL query parameters - const queryParams = { - ...this.route.snapshot.queryParams, + this.navigationService.updateUrlQueryParams(this.route, { secret: secretQueryParam, 'participant-name': this.participantName - }; - const urlTree = this.router.createUrlTree([], { queryParams, queryParamsHandling: 'merge' }); - const newUrl = this.router.serializeUrl(urlTree); - this.location.replaceState(newUrl); + }); } private async getRoomSecrets(): Promise<{ moderatorSecret: string; publisherSecret: string }> { @@ -230,10 +228,8 @@ export class VideoRoomComponent implements OnInit, OnDestroy { return { publisherSecret, moderatorSecret }; } - goToRecordings() { - this.router.navigate([`room/${this.roomId}/recordings`], { - queryParams: { secret: this.roomSecret } - }); + async goToRecordings() { + await this.navigationService.goToRecordings(this.roomId, this.roomSecret); } onParticipantConnected(event: ParticipantModel) { @@ -247,7 +243,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.wcManagerService.sendMessageToParent(message); } - onParticipantLeft(event: ParticipantLeftEvent) { + async onParticipantLeft(event: ParticipantLeftEvent) { console.warn('Participant left the room. Redirecting to:'); const redirectURL = this.ctxService.getLeaveRedirectURL() || '/disconnected'; const isExternalURL = /^https?:\/\//.test(redirectURL); @@ -279,13 +275,12 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.sessionStorageService.removeModeratorSecret(event.roomName); } - this.redirectTo(redirectURL, isExternalURL); + await this.navigationService.redirectTo(redirectURL, isExternalURL); } async onRecordingStartRequested(event: RecordingStartRequestedEvent) { try { - const { roomName: roomId } = event; - await this.httpService.startRecording(roomId); + await this.recManagerService.startRecording(event.roomName); } catch (error) { console.error(error); } @@ -293,11 +288,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy { async onRecordingStopRequested(event: RecordingStopRequestedEvent) { try { - const { recordingId } = event; - - if (!recordingId) throw new Error('Recording ID not found when stopping recording'); - - await this.httpService.stopRecording(recordingId); + await this.recManagerService.stopRecording(event.recordingId); } catch (error) { console.error(error); } @@ -335,18 +326,4 @@ export class VideoRoomComponent implements OnInit, OnDestroy { this.featureFlags.showRecording = this.participantPermissions.canRecord; } } - - private redirectTo(url: string, isExternal: boolean) { - if (isExternal) { - console.log('Redirecting to external URL:', url); - window.location.href = url; - } else { - console.log('Redirecting to internal route:', url); - this.router.navigate([url], { replaceUrl: true }); - } - } - - private redirectToErrorPage(reason: string) { - this.router.navigate(['error'], { queryParams: { reason } }); - } } diff --git a/frontend/projects/shared-meet-components/src/lib/services/context/context.service.ts b/frontend/projects/shared-meet-components/src/lib/services/context/context.service.ts index 64a7779..ff64f0a 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/context/context.service.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/context/context.service.ts @@ -131,7 +131,7 @@ export class ContextService { this.context.participantRole = decodedToken.metadata.role; // Update feature configuration based on the new token - this.updateFeatureConfiguration(); + // this.updateFeatureConfiguration(); } catch (error: any) { this.log.e('Error setting token in context', error); throw new Error('Error setting token', error); diff --git a/frontend/projects/shared-meet-components/src/lib/services/index.ts b/frontend/projects/shared-meet-components/src/lib/services/index.ts index bb81984..fbedb64 100644 --- a/frontend/projects/shared-meet-components/src/lib/services/index.ts +++ b/frontend/projects/shared-meet-components/src/lib/services/index.ts @@ -7,3 +7,5 @@ export * from './notification/notification.service'; export * from './webcomponent-manager/webcomponent-manager.service'; export * from './session-storage/session-storage.service'; export * from './participant-token/participant-token.service'; +export * from './recording-manager/recording-manager.service'; +export * from './navigation/navigation.service'; \ No newline at end of file diff --git a/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.spec.ts b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.spec.ts new file mode 100644 index 0000000..3faeea3 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { NavigationService } from './navigation.service'; + +describe('NavigationService', () => { + let service: NavigationService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(NavigationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts new file mode 100644 index 0000000..f69f0c3 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/navigation/navigation.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { Location } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ErrorRedirectReason } from '@lib/models/navigation.model'; + +@Injectable({ + providedIn: 'root' +}) +export class NavigationService { + constructor( + private router: Router, + private location: Location + ) {} + + /** + * Redirects to internal or external URLs + */ + async redirectTo(url: string, isExternal: boolean = false): Promise { + if (isExternal) { + console.log('Redirecting to external URL:', url); + window.location.href = url; + } else { + console.log('Redirecting to internal route:', url); + try { + await this.router.navigate([url], { replaceUrl: true }); + } catch (error) { + console.error('Error navigating to internal route:', error); + } + } + } + + /** + * Redirects to error page with specific reason + */ + async redirectToErrorPage(reason: keyof ErrorRedirectReason): Promise { + try { + await this.router.navigate(['error'], { queryParams: { reason } }); + } catch (error) { + console.error('Error redirecting to error page:', error); + } + } + + /** + * Updates URL query parameters without navigation + */ + updateUrlQueryParams(route: ActivatedRoute, newParams: Record): void { + const queryParams = { + ...route.snapshot.queryParams, + ...newParams + }; + const urlTree = this.router.createUrlTree([], { + queryParams, + queryParamsHandling: 'merge' + }); + const newUrl = this.router.serializeUrl(urlTree); + this.location.replaceState(newUrl); + } + + /** + * Navigates to recordings page + */ + async goToRecordings(roomId: string, secret: string): Promise { + try { + await this.router.navigate([`room/${roomId}/recordings`], { + queryParams: { secret } + }); + } catch (error) { + console.error('Error navigating to recordings:', error); + } + } +} diff --git a/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.spec.ts b/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.spec.ts new file mode 100644 index 0000000..45fc339 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { RecordingManagerService } from './recording-manager.service'; + +describe('RecordingManagerService', () => { + let service: RecordingManagerService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RecordingManagerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.ts b/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.ts new file mode 100644 index 0000000..2792235 --- /dev/null +++ b/frontend/projects/shared-meet-components/src/lib/services/recording-manager/recording-manager.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { HttpService } from '../http/http.service'; + +@Injectable({ + providedIn: 'root' +}) +export class RecordingManagerService { + constructor(private httpService: HttpService) {} + + /** + * Starts recording for a room + */ + async startRecording(roomId: string): Promise { + try { + await this.httpService.startRecording(roomId); + } catch (error) { + console.error('Error starting recording:', error); + throw error; + } + } + + /** + * Stops recording by recording ID + */ + async stopRecording(recordingId: string | undefined): Promise { + if (!recordingId) { + throw new Error('Recording ID not found when stopping recording'); + } + + try { + await this.httpService.stopRecording(recordingId); + } catch (error) { + console.error('Error stopping recording:', error); + throw error; + } + } +}