frontend: implement NavigationService and RecordingManagerService with error handling and navigation methods

This commit is contained in:
Carlos Santos 2025-06-09 13:41:17 +02:00
parent b2f1e2194a
commit 2aa3bc1177
8 changed files with 167 additions and 43 deletions

View File

@ -0,0 +1,5 @@
export interface ErrorRedirectReason {
'invalid-secret': string;
'invalid-room': string;
'internal-error': string;
}

View File

@ -1,11 +1,11 @@
import { Location } from '@angular/common'; // import { Location } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; 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 { MeetRecordingAccess, MeetRoomPreferences, OpenViduMeetPermissions, ParticipantRole } from '@lib/typings/ce';
import { import {
ApiDirectiveModule, ApiDirectiveModule,
@ -27,6 +27,8 @@ import {
WebComponentManagerService WebComponentManagerService
} from '../../services'; } from '../../services';
import { ParticipantTokenService } from '@lib/services/participant-token/participant-token.service'; 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({ @Component({
selector: 'app-video-room', selector: 'app-video-room',
@ -84,10 +86,10 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
constructor( constructor(
protected httpService: HttpService, protected httpService: HttpService,
protected navigationService: NavigationService,
protected participantTokenService: ParticipantTokenService, protected participantTokenService: ParticipantTokenService,
protected router: Router, protected recManagerService: RecordingManagerService,
protected route: ActivatedRoute, protected route: ActivatedRoute,
protected location: Location,
protected authService: AuthService, protected authService: AuthService,
protected ctxService: ContextService, protected ctxService: ContextService,
protected roomService: RoomService, protected roomService: RoomService,
@ -122,7 +124,6 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
await this.generateParticipantToken(); await this.generateParticipantToken();
await this.replaceUrlQueryParams(); await this.replaceUrlQueryParams();
await this.loadRoomPreferences(); await this.loadRoomPreferences();
this.updateFeatureConfiguration();
this.showRoom = true; this.showRoom = true;
} catch (error) { } catch (error) {
console.error('Error accessing room:', error); console.error('Error accessing room:', error);
@ -169,7 +170,8 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.participantName, this.participantName,
this.roomSecret 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.participantRole = role;
this.participantPermissions = permissions; this.participantPermissions = permissions;
} catch (error: any) { } catch (error: any) {
@ -177,11 +179,11 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
switch (error.status) { switch (error.status) {
case 400: case 400:
// Invalid secret // Invalid secret
this.redirectToErrorPage('invalid-secret'); await this.navigationService.redirectToErrorPage('invalid-secret');
break; break;
case 404: case 404:
// Room not found // Room not found
this.redirectToErrorPage('invalid-room'); await this.navigationService.redirectToErrorPage('invalid-room');
break; break;
case 409: case 409:
// Participant already exists. // Participant already exists.
@ -189,7 +191,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.participantForm.get('name')?.setErrors({ participantExists: true }); this.participantForm.get('name')?.setErrors({ participantExists: true });
throw new Error('Participant already exists in the room'); throw new Error('Participant already exists in the room');
default: 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 // Replace secret and participant name in the URL query parameters
const queryParams = { this.navigationService.updateUrlQueryParams(this.route, {
...this.route.snapshot.queryParams,
secret: secretQueryParam, secret: secretQueryParam,
'participant-name': this.participantName '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 }> { private async getRoomSecrets(): Promise<{ moderatorSecret: string; publisherSecret: string }> {
@ -230,10 +228,8 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
return { publisherSecret, moderatorSecret }; return { publisherSecret, moderatorSecret };
} }
goToRecordings() { async goToRecordings() {
this.router.navigate([`room/${this.roomId}/recordings`], { await this.navigationService.goToRecordings(this.roomId, this.roomSecret);
queryParams: { secret: this.roomSecret }
});
} }
onParticipantConnected(event: ParticipantModel) { onParticipantConnected(event: ParticipantModel) {
@ -247,7 +243,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.wcManagerService.sendMessageToParent(message); this.wcManagerService.sendMessageToParent(message);
} }
onParticipantLeft(event: ParticipantLeftEvent) { async onParticipantLeft(event: ParticipantLeftEvent) {
console.warn('Participant left the room. Redirecting to:'); console.warn('Participant left the room. Redirecting to:');
const redirectURL = this.ctxService.getLeaveRedirectURL() || '/disconnected'; const redirectURL = this.ctxService.getLeaveRedirectURL() || '/disconnected';
const isExternalURL = /^https?:\/\//.test(redirectURL); const isExternalURL = /^https?:\/\//.test(redirectURL);
@ -279,13 +275,12 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.sessionStorageService.removeModeratorSecret(event.roomName); this.sessionStorageService.removeModeratorSecret(event.roomName);
} }
this.redirectTo(redirectURL, isExternalURL); await this.navigationService.redirectTo(redirectURL, isExternalURL);
} }
async onRecordingStartRequested(event: RecordingStartRequestedEvent) { async onRecordingStartRequested(event: RecordingStartRequestedEvent) {
try { try {
const { roomName: roomId } = event; await this.recManagerService.startRecording(event.roomName);
await this.httpService.startRecording(roomId);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -293,11 +288,7 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
async onRecordingStopRequested(event: RecordingStopRequestedEvent) { async onRecordingStopRequested(event: RecordingStopRequestedEvent) {
try { try {
const { recordingId } = event; await this.recManagerService.stopRecording(event.recordingId);
if (!recordingId) throw new Error('Recording ID not found when stopping recording');
await this.httpService.stopRecording(recordingId);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@ -335,18 +326,4 @@ export class VideoRoomComponent implements OnInit, OnDestroy {
this.featureFlags.showRecording = this.participantPermissions.canRecord; 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 } });
}
} }

View File

@ -131,7 +131,7 @@ export class ContextService {
this.context.participantRole = decodedToken.metadata.role; this.context.participantRole = decodedToken.metadata.role;
// Update feature configuration based on the new token // Update feature configuration based on the new token
this.updateFeatureConfiguration(); // this.updateFeatureConfiguration();
} catch (error: any) { } catch (error: any) {
this.log.e('Error setting token in context', error); this.log.e('Error setting token in context', error);
throw new Error('Error setting token', error); throw new Error('Error setting token', error);

View File

@ -7,3 +7,5 @@ export * from './notification/notification.service';
export * from './webcomponent-manager/webcomponent-manager.service'; export * from './webcomponent-manager/webcomponent-manager.service';
export * from './session-storage/session-storage.service'; export * from './session-storage/session-storage.service';
export * from './participant-token/participant-token.service'; export * from './participant-token/participant-token.service';
export * from './recording-manager/recording-manager.service';
export * from './navigation/navigation.service';

View File

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

View File

@ -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<void> {
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<void> {
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<string, any>): 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<void> {
try {
await this.router.navigate([`room/${roomId}/recordings`], {
queryParams: { secret }
});
} catch (error) {
console.error('Error navigating to recordings:', error);
}
}
}

View File

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

View File

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