import { Clipboard } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; import { Component, OnInit, Signal } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule, MatIconButton } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; import { MatRippleModule } from '@angular/material/core'; import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute } from '@angular/router'; import { ShareMeetingLinkComponent } from '@lib/components/share-meeting-link/share-meeting-link.component'; import { ErrorReason } from '@lib/models'; import { CustomParticipantModel } from '@lib/models/custom-participant.model'; import { AppDataService, ApplicationFeatures, AuthService, FeatureConfigurationService, MeetingService, NavigationService, NotificationService, ParticipantService, RecordingService, RoomService, SessionStorageService, WebComponentManagerService } from '@lib/services'; import { LeftEventReason, MeetRoom, MeetRoomStatus, ParticipantRole, WebComponentEvent, WebComponentOutboundEventMessage } from '@lib/typings/ce'; import { MeetParticipantRoleUpdatedPayload, MeetRoomPreferencesUpdatedPayload, MeetSignalType } from '@lib/typings/ce/event.model'; import { ApiDirectiveModule, ParticipantService as ComponentParticipantService, DataPacket_Kind, OpenViduComponentsUiModule, OpenViduService, ParticipantLeftEvent, ParticipantLeftReason, ParticipantModel, RecordingStartRequestedEvent, RecordingStopRequestedEvent, RemoteParticipant, Room, RoomEvent } from 'openvidu-components-angular'; import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'app-meeting', templateUrl: './meeting.component.html', styleUrls: ['./meeting.component.scss'], standalone: true, imports: [ OpenViduComponentsUiModule, ApiDirectiveModule, CommonModule, MatFormFieldModule, MatInputModule, FormsModule, ReactiveFormsModule, MatCardModule, MatButtonModule, MatIconModule, MatIconButton, MatMenuModule, MatDividerModule, MatTooltipModule, MatRippleModule, ShareMeetingLinkComponent ] }) export class MeetingComponent implements OnInit { participantForm = new FormGroup({ name: new FormControl('', [Validators.required]) }); hasRecordings = false; showRecordingCard = false; roomClosed = false; showBackButton = true; backButtonText = 'Back'; room?: MeetRoom; roomId = ''; roomSecret = ''; participantName = ''; participantToken = ''; localParticipant?: CustomParticipantModel; remoteParticipants: CustomParticipantModel[] = []; showMeeting = false; features: Signal; meetingEndedByMe = false; private destroy$ = new Subject(); constructor( protected route: ActivatedRoute, protected roomService: RoomService, protected meetingService: MeetingService, protected participantService: ParticipantService, protected recordingService: RecordingService, protected featureConfService: FeatureConfigurationService, protected authService: AuthService, protected appDataService: AppDataService, protected sessionStorageService: SessionStorageService, protected wcManagerService: WebComponentManagerService, protected openviduService: OpenViduService, protected componentParticipantService: ComponentParticipantService, protected navigationService: NavigationService, protected notificationService: NotificationService, protected clipboard: Clipboard ) { this.features = this.featureConfService.features; } get roomName(): string { return this.room?.roomName || 'Room'; } get hostname(): string { return window.location.origin.replace('http://', '').replace('https://', ''); } async ngOnInit() { this.roomId = this.roomService.getRoomId(); this.roomSecret = this.roomService.getRoomSecret(); this.room = await this.roomService.getRoom(this.roomId); this.roomClosed = this.room.status === MeetRoomStatus.CLOSED; await this.setBackButtonText(); await this.checkForRecordings(); await this.initializeParticipantName(); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } /** * Sets the back button text based on the application mode and user role */ private async setBackButtonText() { const isStandaloneMode = this.appDataService.isStandaloneMode(); const redirection = this.navigationService.getLeaveRedirectURL(); const isAdmin = await this.authService.isAdmin(); if (isStandaloneMode && !redirection && !isAdmin) { // If in standalone mode, no redirection URL and not an admin, hide the back button this.showBackButton = false; return; } this.showBackButton = true; this.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back'; } /** * Checks if there are recordings in the room and updates the visibility of the recordings card. * * It is necessary to previously generate a recording token in order to list the recordings. * If token generation fails or the user does not have sufficient permissions to list recordings, * the error will be caught and the recordings card will be hidden (`showRecordingCard` will be set to `false`). * * If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`. */ private async checkForRecordings() { try { const { canRetrieveRecordings } = await this.recordingService.generateRecordingToken( this.roomId, this.roomSecret ); if (!canRetrieveRecordings) { this.showRecordingCard = false; return; } const { recordings } = await this.recordingService.listRecordings({ maxItems: 1, roomId: this.roomId, fields: 'recordingId' }); this.hasRecordings = recordings.length > 0; this.showRecordingCard = this.hasRecordings; } catch (error) { console.error('Error checking for recordings:', error); this.showRecordingCard = false; } } /** * Initializes the participant name in the form control. * * Retrieves the participant name from the ParticipantTokenService first, and if not available, * falls back to the authenticated username. Sets the retrieved name value in the * participant form's 'name' control if a valid name is found. * * @returns A promise that resolves when the participant name has been initialized */ private async initializeParticipantName() { // Apply participant name from ParticipantTokenService if set, otherwise use authenticated username const currentParticipantName = this.participantService.getParticipantName(); const username = await this.authService.getUsername(); const participantName = currentParticipantName || username; if (participantName) { this.participantForm.get('name')?.setValue(participantName); } } async goToRecordings() { try { await this.navigationService.navigateTo(`room/${this.roomId}/recordings`, { secret: this.roomSecret }); } catch (error) { console.error('Error navigating to recordings:', error); } } /** * Handles the back button click event and navigates accordingly * If in embedded mode, it closes the WebComponentManagerService * If the redirect URL is set, it navigates to that URL * If in standalone mode without a redirect URL, it navigates to the rooms page */ async goBack() { if (this.appDataService.isEmbeddedMode()) { this.wcManagerService.close(); } const redirectTo = this.navigationService.getLeaveRedirectURL(); if (redirectTo) { // Navigate to the specified redirect URL await this.navigationService.redirectTo(redirectTo); return; } if (this.appDataService.isStandaloneMode()) { // Navigate to rooms page await this.navigationService.navigateTo('/rooms'); } } async submitAccessMeeting() { const { valid, value } = this.participantForm; if (!valid || !value.name?.trim()) { // If the form is invalid, do not proceed console.warn('Participant form is invalid. Cannot access meeting.'); return; } this.participantName = value.name.trim(); try { await this.generateParticipantToken(); await this.addParticipantNameToUrl(); await this.roomService.loadRoomPreferences(this.roomId); this.showMeeting = true; // Subscribe to remote participants updates this.componentParticipantService.remoteParticipants$ .pipe(takeUntil(this.destroy$)) .subscribe((participants) => { this.remoteParticipants = participants as CustomParticipantModel[]; }); this.componentParticipantService.localParticipant$ .pipe(takeUntil(this.destroy$)) .subscribe((participant) => { this.localParticipant = participant as CustomParticipantModel; }); } catch (error) { console.error('Error accessing meeting:', error); } } /** * Generates a participant token for joining a meeting. * * @throws When participant already exists in the room (status 409) * @returns Promise that resolves when token is generated */ private async generateParticipantToken() { try { this.participantToken = await this.participantService.generateToken({ roomId: this.roomId, secret: this.roomSecret, participantName: this.participantName }); this.participantName = this.participantService.getParticipantName()!; } catch (error: any) { console.error('Error generating participant token:', error); switch (error.status) { case 400: // Invalid secret await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET, true); break; case 404: // Room not found await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM, true); break; case 409: // Room is closed await this.navigationService.redirectToErrorPage(ErrorReason.CLOSED_ROOM, true); break; default: await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR, true); } throw new Error('Error generating participant token'); } } /** * Add participant name as a query parameter to the URL */ private async addParticipantNameToUrl() { await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, { 'participant-name': this.participantName }); } onRoomCreated(room: Room) { room.on( RoomEvent.DataReceived, async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => { const event = JSON.parse(new TextDecoder().decode(payload)); switch (topic) { case 'recordingStopped': { // If a 'recordingStopped' event is received and there was no previous recordings, // update the hasRecordings flag and refresh the recording token if (this.hasRecordings) return; this.hasRecordings = true; try { await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret); } catch (error) { console.error('Error refreshing recording token:', error); } break; } case MeetSignalType.MEET_ROOM_PREFERENCES_UPDATED: { // Update room preferences const { preferences } = event as MeetRoomPreferencesUpdatedPayload; this.featureConfService.setRoomPreferences(preferences); // Refresh recording token if recording is enabled if (preferences.recordingPreferences.enabled) { try { await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret); } catch (error) { console.error('Error refreshing recording token:', error); } } break; } case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: { // Update participant role const { participantIdentity, newRole, secret } = event as MeetParticipantRoleUpdatedPayload; if (participantIdentity === this.localParticipant!.identity) { if (!secret) return; this.roomSecret = secret; this.roomService.setRoomSecret(secret, false); try { await this.participantService.refreshParticipantToken({ roomId: this.roomId, secret, participantName: this.participantName, participantIdentity }); this.localParticipant!.meetRole = newRole; this.notificationService.showSnackbar(`You have been assigned the role of ${newRole}`); } catch (error) { console.error('Error refreshing participant token to update role:', error); } } else { const participant = this.remoteParticipants.find((p) => p.identity === participantIdentity); if (participant) { participant.meetRole = newRole; } } break; } } } ); } onParticipantConnected(event: ParticipantModel) { const message: WebComponentOutboundEventMessage = { event: WebComponentEvent.JOINED, payload: { roomId: event.getProperties().room?.name || '', participantIdentity: event.identity } }; this.wcManagerService.sendMessageToParent(message); } async onParticipantLeft(event: ParticipantLeftEvent) { let leftReason = this.getReasonParamFromEvent(event.reason); if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) { leftReason = LeftEventReason.MEETING_ENDED_BY_SELF; } // Send LEFT event to the parent component const message: WebComponentOutboundEventMessage = { event: WebComponentEvent.LEFT, payload: { roomId: event.roomName, participantIdentity: event.participantName, reason: leftReason } }; this.wcManagerService.sendMessageToParent(message); // Remove the moderator secret from session storage if the participant left for a reason other than browser unload if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) { this.sessionStorageService.removeRoomSecret(event.roomName); } // Navigate to the disconnected page with the reason await this.navigationService.navigateTo('disconnected', { reason: leftReason }, true); } /** * Maps ParticipantLeftReason to LeftEventReason. * This method translates the technical reasons for a participant leaving the room * into user-friendly reasons that can be used in the UI or for logging purposes. * @param reason The technical reason for the participant leaving the room. * @returns The corresponding LeftEventReason. */ private getReasonParamFromEvent(reason: ParticipantLeftReason): LeftEventReason { const reasonMap: Record = { [ParticipantLeftReason.LEAVE]: LeftEventReason.VOLUNTARY_LEAVE, [ParticipantLeftReason.BROWSER_UNLOAD]: LeftEventReason.VOLUNTARY_LEAVE, [ParticipantLeftReason.NETWORK_DISCONNECT]: LeftEventReason.NETWORK_DISCONNECT, [ParticipantLeftReason.SIGNAL_CLOSE]: LeftEventReason.NETWORK_DISCONNECT, [ParticipantLeftReason.SERVER_SHUTDOWN]: LeftEventReason.SERVER_SHUTDOWN, [ParticipantLeftReason.PARTICIPANT_REMOVED]: LeftEventReason.PARTICIPANT_KICKED, [ParticipantLeftReason.ROOM_DELETED]: LeftEventReason.MEETING_ENDED, [ParticipantLeftReason.DUPLICATE_IDENTITY]: LeftEventReason.UNKNOWN, [ParticipantLeftReason.OTHER]: LeftEventReason.UNKNOWN }; return reasonMap[reason] ?? LeftEventReason.UNKNOWN; } async leaveMeeting() { await this.openviduService.disconnectRoom(); } async endMeeting() { if (!this.participantService.isModeratorParticipant()) return; this.meetingEndedByMe = true; try { await this.meetingService.endMeeting(this.roomId); } catch (error) { console.error('Error ending meeting:', error); this.notificationService.showSnackbar('Failed to end meeting'); } } async kickParticipant(participant: CustomParticipantModel) { if (!this.participantService.isModeratorParticipant()) return; try { await this.meetingService.kickParticipant(this.roomId, participant.identity); } catch (error) { console.error('Error kicking participant:', error); this.notificationService.showSnackbar('Failed to kick participant'); } } /** * Makes a participant as moderator. * @param participant The participant to make as moderator. */ async makeModerator(participant: CustomParticipantModel) { if (!this.participantService.isModeratorParticipant()) return; try { await this.meetingService.changeParticipantRole( this.roomId, participant.identity, ParticipantRole.MODERATOR ); } catch (error) { console.error('Error making participant moderator:', error); this.notificationService.showSnackbar('Failed to make participant moderator'); } } /** * Unmakes a participant as moderator. * @param participant The participant to unmake as moderator. */ async unmakeModerator(participant: CustomParticipantModel) { if (!this.participantService.isModeratorParticipant()) return; try { await this.meetingService.changeParticipantRole(this.roomId, participant.identity, ParticipantRole.SPEAKER); } catch (error) { console.error('Error unmaking participant moderator:', error); this.notificationService.showSnackbar('Failed to unmake participant moderator'); } } async copyModeratorLink() { this.clipboard.copy(this.room!.moderatorUrl); this.notificationService.showSnackbar('Moderator link copied to clipboard'); } async copySpeakerLink() { this.clipboard.copy(this.room!.speakerUrl); this.notificationService.showSnackbar('Speaker link copied to clipboard'); } async onRecordingStartRequested(event: RecordingStartRequestedEvent) { try { await this.recordingService.startRecording(event.roomName); } catch (error: unknown) { if ((error as any).status === 503) { console.error( `No egress service was able to register a request. Check your CPU usage or if there's any Media Node with enough CPU. Remember that by default, a recording uses 4 CPUs for each room.` ); } else { console.error(error); } } } async onRecordingStopRequested(event: RecordingStopRequestedEvent) { try { await this.recordingService.stopRecording(event.recordingId); } catch (error) { console.error(error); } } async onViewRecordingsClicked() { window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank'); } }