import { Clipboard } from '@angular/cdk/clipboard'; 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, RecordingManagerService, RoomService, SessionStorageService, WebComponentManagerService } from '@lib/services'; import { LeftEventReason, MeetRoom, 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, 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, Validators.minLength(4)]) }); showRecordingCard = 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 navigationService: NavigationService, protected participantTokenService: ParticipantService, protected recManagerService: RecordingManagerService, protected authService: AuthService, protected roomService: RoomService, protected meetingService: MeetingService, protected openviduService: OpenViduService, protected participantService: ParticipantService, protected componentParticipantService: ComponentParticipantService, protected appDataService: AppDataService, protected wcManagerService: WebComponentManagerService, protected sessionStorageService: SessionStorageService, protected featureConfService: FeatureConfigurationService, protected clipboard: Clipboard, protected notificationService: NotificationService, protected recordingService: RecordingManagerService ) { 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); 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 { await this.recManagerService.generateRecordingToken(this.roomId, this.roomSecret); const { recordings } = await this.recManagerService.listRecordings({ maxItems: 1, roomId: this.roomId, fields: 'recordingId' }); this.showRecordingCard = recordings.length > 0; } 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.participantTokenService.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(); this.participantTokenService.setParticipantName(this.participantName); try { await this.generateParticipantToken(); await this.addParticipantNameToUrl(); await this.roomService.loadRoomPreferences(this.roomId); this.showMeeting = true; // Subscribe to remote participants updates for showing/hiding the share meeting link component 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.participantTokenService.generateToken({ roomId: this.roomId, secret: this.roomSecret, participantName: this.participantName }); } 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: // Participant already exists. // Show the error message in participant name input form this.participantForm.get('name')?.setErrors({ participantExists: 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, (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => { const event = JSON.parse(new TextDecoder().decode(payload)); if (topic === MeetSignalType.MEET_ROOM_PREFERENCES_UPDATED) { const { preferences } = event as MeetRoomPreferencesUpdatedPayload; this.featureConfService.setRoomPreferences(preferences); } else if (topic === MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED) { const { participantIdentity, newRole, secret } = event as MeetParticipantRoleUpdatedPayload; if (participantIdentity === this.localParticipant!.name) { this.localParticipant!.meetRole = newRole; // TODO: Request for new token with the new role } else { const participant = this.remoteParticipants.find((p) => p.identity === participantIdentity); if (participant) { participant.meetRole = newRole; } } } } ); } 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 }); } /** * 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()) { const roomId = this.roomService.getRoomId(); this.meetingEndedByMe = true; await this.meetingService.endMeeting(roomId); } } async forceDisconnectParticipant(participant: CustomParticipantModel) { if (this.participantService.isModeratorParticipant()) { await this.meetingService.kickParticipant(this.roomId, participant.identity); } } async makeModerator(participant: CustomParticipantModel) { if (this.participantService.isModeratorParticipant()) { const newRole = ParticipantRole.MODERATOR; await this.meetingService.changeParticipantRole(this.roomId, participant.identity, newRole); } } async copyModeratorLink() { this.clipboard.copy(this.room!.moderatorRoomUrl); this.notificationService.showSnackbar('Moderator link copied to clipboard'); } async copySpeakerLink() { this.clipboard.copy(this.room!.speakerRoomUrl); this.notificationService.showSnackbar('Speaker link copied to clipboard'); } async onRecordingStartRequested(event: RecordingStartRequestedEvent) { try { await this.recManagerService.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.recManagerService.stopRecording(event.recordingId); } catch (error) { console.error(error); } } async onViewRecordingsClicked(recordingId?: any) { if (recordingId) { await this.recordingService.playRecording(recordingId); } else { window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank'); } } }