frontend: use content projection for configuring videoconference components
Refactored all components and services related to the meeting
This commit is contained in:
parent
fd998e7b6b
commit
40475dc372
@ -14,18 +14,11 @@ export * from './wizard-nav/wizard-nav.component';
|
||||
export * from './share-meeting-link/share-meeting-link.component';
|
||||
|
||||
// Meeting modular components
|
||||
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
|
||||
export * from './meeting-participant-panel/meeting-participant-panel.component';
|
||||
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
|
||||
export * from './meeting-share-link-overlay/meeting-share-link-overlay.component';
|
||||
export * from './meeting-lobby/meeting-lobby.component';
|
||||
export * from './meeting-layout/meeting-layout.component';
|
||||
|
||||
|
||||
// Meeting components
|
||||
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
|
||||
export * from './meeting-participant-panel/meeting-participant-panel.component';
|
||||
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
|
||||
export * from './meeting-share-link-overlay/meeting-share-link-overlay.component';
|
||||
export * from './meeting-lobby/meeting-lobby.component';
|
||||
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
<ov-layout [ovRemoteParticipants]="filteredRemoteParticipants()">
|
||||
<ov-layout>
|
||||
@if (additionalElementsInputs().showOverlay) {
|
||||
<!-- Additional elements plugin injection point -->
|
||||
<ng-template #layoutAdditionalElements>
|
||||
<ng-container
|
||||
[ngComponentOutlet]="plugins.layoutAdditionalElements!"
|
||||
[ngComponentOutletInputs]="additionalElementsInputs()"
|
||||
></ng-container>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<ng-template #stream let-track>
|
||||
<ov-stream [track]="track"></ov-stream>
|
||||
</ng-template>
|
||||
</ov-layout>
|
||||
</ov-layout>
|
||||
@ -4,8 +4,8 @@
|
||||
<div class="room-header">
|
||||
<mat-icon class="ov-room-icon room-icon">video_chat</mat-icon>
|
||||
<div class="room-info">
|
||||
<h1 class="room-title">{{ roomName }}</h1>
|
||||
@if (isE2EEEnabled) {
|
||||
<h1 class="room-title">{{ roomName() }}</h1>
|
||||
@if (isE2EEEnabled()) {
|
||||
<span class="encryption-badge" matTooltip="End-to-end encrypted">
|
||||
<mat-icon class="badge-icon">lock</mat-icon>
|
||||
This meeting is end-to-end encrypted
|
||||
@ -17,22 +17,22 @@
|
||||
<!-- Action Cards Grid -->
|
||||
<div class="action-cards-grid">
|
||||
<!-- Join Room Card -->
|
||||
<mat-card class="action-card primary-card fade-in" [ngClass]="{ 'room-closed-card': roomClosed }">
|
||||
<mat-card class="action-card primary-card fade-in" [ngClass]="{ 'room-closed-card': roomClosed() }">
|
||||
<mat-card-header class="card-header">
|
||||
<mat-icon class="ov-room-icon card-icon">{{ roomClosed ? 'lock' : 'meeting_room' }}</mat-icon>
|
||||
<mat-icon class="ov-room-icon card-icon">{{ roomClosed() ? 'lock' : 'meeting_room' }}</mat-icon>
|
||||
<div class="card-title-group">
|
||||
<mat-card-title>{{ roomClosed ? 'Room Closed' : 'Join Meeting' }}</mat-card-title>
|
||||
<mat-card-title>{{ roomClosed() ? 'Room Closed' : 'Join Meeting' }}</mat-card-title>
|
||||
<mat-card-subtitle>{{
|
||||
roomClosed
|
||||
roomClosed()
|
||||
? 'This room is not available for meetings'
|
||||
: 'Enter the room and start connecting'
|
||||
}}</mat-card-subtitle>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content class="card-content">
|
||||
@if (!roomClosed) {
|
||||
<form [formGroup]="participantForm" (ngSubmit)="onFormSubmit()" class="join-form">
|
||||
<mat-card-content class="card-content">
|
||||
@if (!roomClosed()) {
|
||||
<form [formGroup]="participantForm()" (ngSubmit)="onFormSubmit()" class="join-form">
|
||||
<!-- Participant Name Input -->
|
||||
<mat-form-field appearance="outline" class="name-field">
|
||||
<mat-label>Your display name</mat-label>
|
||||
@ -44,13 +44,13 @@
|
||||
required
|
||||
/>
|
||||
<mat-icon matSuffix class="ov-action-icon">person</mat-icon>
|
||||
@if (participantForm.get('name')?.hasError('required')) {
|
||||
@if (participantForm().get('name')?.hasError('required')) {
|
||||
<mat-error> The name is <strong>required</strong> </mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- E2EE Key Input (shown when E2EE is enabled) -->
|
||||
@if (isE2EEEnabled) {
|
||||
@if (isE2EEEnabled()) {
|
||||
<mat-form-field appearance="outline" class="e2eekey-field fade-in">
|
||||
<mat-label>Encryption Key</mat-label>
|
||||
<input
|
||||
@ -61,10 +61,10 @@
|
||||
formControlName="e2eeKey"
|
||||
required
|
||||
/>
|
||||
<mat-icon matSuffix class="ov-action-icon">vpn_key</mat-icon>
|
||||
@if (participantForm.get('e2eeKey')?.hasError('required')) {
|
||||
<mat-error> The encryption key is <strong>required</strong> </mat-error>
|
||||
}
|
||||
<mat-icon matSuffix class="ov-action-icon">vpn_key</mat-icon>
|
||||
@if (participantForm().get('e2eeKey')?.hasError('required')) {
|
||||
<mat-error> The encryption key is <strong>required</strong> </mat-error>
|
||||
}
|
||||
<mat-hint>This room requires an encryption key to join</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@ -75,7 +75,7 @@
|
||||
id="participant-name-submit"
|
||||
type="submit"
|
||||
class="join-button"
|
||||
[disabled]="!participantForm.valid"
|
||||
[disabled]="!participantForm().valid"
|
||||
>
|
||||
<span>Join Meeting</span>
|
||||
</button>
|
||||
@ -93,7 +93,7 @@
|
||||
</mat-card>
|
||||
|
||||
<!-- View Recordings Card -->
|
||||
@if (showRecordingCard) {
|
||||
@if (showRecordingCard()) {
|
||||
<mat-card class="action-card secondary-card fade-in-delayed">
|
||||
<mat-card-header class="card-header">
|
||||
<mat-icon class="ov-recording-icon card-icon">video_library</mat-icon>
|
||||
@ -126,16 +126,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Room URL Badge -->
|
||||
@if (!roomClosed && showShareLink) {
|
||||
<ov-share-meeting-link [meetingUrl]="meetingUrl" (copyClicked)="onCopyLinkClick()"></ov-share-meeting-link>
|
||||
@if (!roomClosed() && showShareLink()) {
|
||||
<ov-share-meeting-link [meetingUrl]="meetingUrl()" (copyClicked)="onCopyLinkClick()"></ov-share-meeting-link>
|
||||
}
|
||||
|
||||
<!-- Quick Actions -->
|
||||
@if (showBackButton) {
|
||||
@if (showBackButton()) {
|
||||
<div class="quick-actions fade-in-delayed-more">
|
||||
<button mat-button class="quick-action-button" (click)="onBackClick()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>{{ backButtonText }}</span>
|
||||
<span>{{ backButtonText() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { ShareMeetingLinkComponent } from '../../components';
|
||||
import { MeetingLobbyService } from '../../services/meeting/meeting-lobby.service';
|
||||
import { MeetingService } from '../../services/meeting/meeting.service';
|
||||
|
||||
/**
|
||||
* Reusable component for the meeting lobby page.
|
||||
@ -29,121 +31,36 @@ import { ShareMeetingLinkComponent } from '../../components';
|
||||
]
|
||||
})
|
||||
export class MeetingLobbyComponent {
|
||||
/**
|
||||
* The room name to display
|
||||
*/
|
||||
@Input({ required: true }) roomName = '';
|
||||
protected lobbyService = inject(MeetingLobbyService);
|
||||
protected meetingService = inject(MeetingService);
|
||||
|
||||
/**
|
||||
* The meeting URL to share
|
||||
*/
|
||||
@Input() meetingUrl = '';
|
||||
protected roomName = computed(() => this.lobbyService.state().room?.roomName);
|
||||
protected meetingUrl = computed(() => this.lobbyService.meetingUrl());
|
||||
protected roomClosed = computed(() => this.lobbyService.state().roomClosed);
|
||||
protected showRecordingCard = computed(() => this.lobbyService.state().showRecordingCard);
|
||||
protected showShareLink = computed(() => {
|
||||
const state = this.lobbyService.state();
|
||||
const canModerate = this.lobbyService.canModerateRoom();
|
||||
return !!state.room && !state.roomClosed && canModerate;
|
||||
});
|
||||
protected showBackButton = computed(() => this.lobbyService.state().showBackButton);
|
||||
protected backButtonText = computed(() => this.lobbyService.state().backButtonText);
|
||||
protected isE2EEEnabled = computed(() => this.lobbyService.state().hasRoomE2EEEnabled);
|
||||
protected participantForm = computed(() => this.lobbyService.state().participantForm);
|
||||
|
||||
/**
|
||||
* Whether the room is closed
|
||||
*/
|
||||
@Input() roomClosed = false;
|
||||
|
||||
/**
|
||||
* Whether to show the recording card
|
||||
*/
|
||||
@Input() showRecordingCard = false;
|
||||
|
||||
/**
|
||||
* Whether to show the share meeting link component
|
||||
*/
|
||||
@Input() showShareLink = false;
|
||||
|
||||
/**
|
||||
* Whether to show the back button
|
||||
*/
|
||||
@Input() showBackButton = false;
|
||||
|
||||
/**
|
||||
* Back button text
|
||||
*/
|
||||
@Input() backButtonText = 'Back';
|
||||
|
||||
/**
|
||||
* Whether E2EE is enabled for the meeting
|
||||
*/
|
||||
@Input() isE2EEEnabled = false;
|
||||
|
||||
/**
|
||||
* The participant form group
|
||||
*/
|
||||
@Input({ required: true }) participantForm!: FormGroup;
|
||||
|
||||
/**
|
||||
* Emitted when the form is submitted
|
||||
*/
|
||||
@Output() formSubmitted = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the view recordings button is clicked
|
||||
*/
|
||||
@Output() viewRecordingsClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the back button is clicked
|
||||
*/
|
||||
@Output() backClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the copy link button is clicked
|
||||
*/
|
||||
@Output() copyLinkClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when form is submitted
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() formSubmittedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when view recordings is clicked
|
||||
*/
|
||||
@Input() viewRecordingsClickedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when back button is clicked
|
||||
*/
|
||||
@Input() backClickedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when copy link is clicked
|
||||
*/
|
||||
@Input() copyLinkClickedFn?: () => void;
|
||||
|
||||
onFormSubmit(): void {
|
||||
if (this.formSubmittedFn) {
|
||||
this.formSubmittedFn();
|
||||
} else {
|
||||
this.formSubmitted.emit();
|
||||
}
|
||||
async onFormSubmit(): Promise<void> {
|
||||
await this.lobbyService.submitAccess();
|
||||
}
|
||||
|
||||
onViewRecordingsClick(): void {
|
||||
if (this.viewRecordingsClickedFn) {
|
||||
this.viewRecordingsClickedFn();
|
||||
} else {
|
||||
this.viewRecordingsClicked.emit();
|
||||
}
|
||||
async onViewRecordingsClick(): Promise<void> {
|
||||
await this.lobbyService.goToRecordings();
|
||||
}
|
||||
|
||||
onBackClick(): void {
|
||||
if (this.backClickedFn) {
|
||||
this.backClickedFn();
|
||||
} else {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
async onBackClick(): Promise<void> {
|
||||
await this.lobbyService.goBack();
|
||||
}
|
||||
|
||||
onCopyLinkClick(): void {
|
||||
if (this.copyLinkClickedFn) {
|
||||
this.copyLinkClickedFn();
|
||||
} else {
|
||||
this.copyLinkClicked.emit();
|
||||
}
|
||||
this.lobbyService.copyMeetingSpeakerLink();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
<div class="participant-item-container">
|
||||
<ov-participant-panel-item [participant]="participant">
|
||||
<!-- Moderator Badge -->
|
||||
<ng-container *ovParticipantPanelParticipantBadge>
|
||||
@if (showModeratorBadge) {
|
||||
<span class="moderator-badge" [attr.id]="'moderator-badge-' + participant.sid">
|
||||
<mat-icon [matTooltip]="moderatorBadgeTooltip" class="material-symbols-outlined">
|
||||
shield_person
|
||||
</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Moderation Controls -->
|
||||
@if (showModerationControls) {
|
||||
<div
|
||||
*ovParticipantPanelItemElements
|
||||
class="moderation-controls"
|
||||
[attr.id]="'moderation-controls-' + participant.sid"
|
||||
>
|
||||
<!-- Make Moderator Button -->
|
||||
@if (showMakeModerator) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onMakeModeratorClick()"
|
||||
[matTooltip]="makeModeratorTooltip"
|
||||
class="make-moderator-btn"
|
||||
[attr.id]="'make-moderator-btn-' + participant.sid"
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Unmake Moderator Button -->
|
||||
@if (showUnmakeModerator) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onUnmakeModeratorClick()"
|
||||
[matTooltip]="unmakeModeratorTooltip"
|
||||
class="remove-moderator-btn"
|
||||
[attr.id]="'remove-moderator-btn-' + participant.sid"
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">remove_moderator</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Kick Participant Button -->
|
||||
@if (showKickButton) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onKickParticipantClick()"
|
||||
[matTooltip]="kickParticipantTooltip"
|
||||
class="force-disconnect-btn"
|
||||
[attr.id]="'kick-participant-btn-' + participant.sid"
|
||||
>
|
||||
<mat-icon>call_end</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ov-participant-panel-item>
|
||||
</div>
|
||||
@ -1,128 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { OpenViduComponentsUiModule } from 'openvidu-components-angular';
|
||||
|
||||
/**
|
||||
* Reusable component for displaying participant panel items with moderation controls.
|
||||
* This component is agnostic and configurable via inputs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-participant-panel',
|
||||
templateUrl: './meeting-participant-panel.component.html',
|
||||
styleUrls: ['./meeting-participant-panel.component.scss'],
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule, OpenViduComponentsUiModule]
|
||||
})
|
||||
export class MeetingParticipantPanelComponent {
|
||||
/**
|
||||
* The participant to display
|
||||
*/
|
||||
@Input({ required: true }) participant: any;
|
||||
|
||||
/**
|
||||
* All participants in the meeting (used for determining moderation controls)
|
||||
*/
|
||||
@Input() allParticipants: any[] = [];
|
||||
|
||||
/**
|
||||
* Whether to show the moderator badge
|
||||
*/
|
||||
@Input() showModeratorBadge = false;
|
||||
|
||||
/**
|
||||
* Whether to show moderation controls (make/unmake moderator, kick)
|
||||
*/
|
||||
@Input() showModerationControls = false;
|
||||
|
||||
/**
|
||||
* Whether to show the "make moderator" button
|
||||
*/
|
||||
@Input() showMakeModerator = false;
|
||||
|
||||
/**
|
||||
* Whether to show the "unmake moderator" button
|
||||
*/
|
||||
@Input() showUnmakeModerator = false;
|
||||
|
||||
/**
|
||||
* Whether to show the "kick participant" button
|
||||
*/
|
||||
@Input() showKickButton = false;
|
||||
|
||||
/**
|
||||
* Moderator badge tooltip text
|
||||
*/
|
||||
@Input() moderatorBadgeTooltip = 'Moderator';
|
||||
|
||||
/**
|
||||
* Make moderator button tooltip text
|
||||
*/
|
||||
@Input() makeModeratorTooltip = 'Make participant moderator';
|
||||
|
||||
/**
|
||||
* Unmake moderator button tooltip text
|
||||
*/
|
||||
@Input() unmakeModeratorTooltip = 'Unmake participant moderator';
|
||||
|
||||
/**
|
||||
* Kick participant button tooltip text
|
||||
*/
|
||||
@Input() kickParticipantTooltip = 'Kick participant';
|
||||
|
||||
/**
|
||||
* Emitted when the make moderator button is clicked
|
||||
*/
|
||||
@Output() makeModeratorClicked = new EventEmitter<any>();
|
||||
|
||||
/**
|
||||
* Emitted when the unmake moderator button is clicked
|
||||
*/
|
||||
@Output() unmakeModeratorClicked = new EventEmitter<any>();
|
||||
|
||||
/**
|
||||
* Emitted when the kick participant button is clicked
|
||||
*/
|
||||
@Output() kickParticipantClicked = new EventEmitter<any>();
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when make moderator is clicked
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() makeModeratorClickedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when unmake moderator is clicked
|
||||
*/
|
||||
@Input() unmakeModeratorClickedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when kick participant is clicked
|
||||
*/
|
||||
@Input() kickParticipantClickedFn?: () => void;
|
||||
|
||||
onMakeModeratorClick(): void {
|
||||
if (this.makeModeratorClickedFn) {
|
||||
this.makeModeratorClickedFn();
|
||||
} else {
|
||||
this.makeModeratorClicked.emit(this.participant);
|
||||
}
|
||||
}
|
||||
|
||||
onUnmakeModeratorClick(): void {
|
||||
if (this.unmakeModeratorClickedFn) {
|
||||
this.unmakeModeratorClickedFn();
|
||||
} else {
|
||||
this.unmakeModeratorClicked.emit(this.participant);
|
||||
}
|
||||
}
|
||||
|
||||
onKickParticipantClick(): void {
|
||||
if (this.kickParticipantClickedFn) {
|
||||
this.kickParticipantClickedFn();
|
||||
} else {
|
||||
this.kickParticipantClicked.emit(this.participant);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
@if (showShareLink) {
|
||||
<div class="share-meeting-link-container">
|
||||
<ov-share-meeting-link [meetingUrl]="meetingUrl" (copyClicked)="onCopyClicked()"></ov-share-meeting-link>
|
||||
</div>
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component';
|
||||
|
||||
/**
|
||||
* Reusable component for displaying the share meeting link panel
|
||||
* inside the participants panel.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-share-link-panel',
|
||||
templateUrl: './meeting-share-link-panel.component.html',
|
||||
styleUrls: ['./meeting-share-link-panel.component.scss'],
|
||||
imports: [CommonModule, ShareMeetingLinkComponent]
|
||||
})
|
||||
export class MeetingShareLinkPanelComponent {
|
||||
/**
|
||||
* Controls whether the share link panel should be shown
|
||||
*/
|
||||
@Input() showShareLink = true;
|
||||
|
||||
/**
|
||||
* The meeting URL to share
|
||||
*/
|
||||
@Input({ required: true }) meetingUrl = '';
|
||||
|
||||
/**
|
||||
* Emitted when the copy button is clicked
|
||||
*/
|
||||
@Output() copyClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when copy button is clicked
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() copyClickedFn?: () => void;
|
||||
|
||||
onCopyClicked(): void {
|
||||
if (this.copyClickedFn) {
|
||||
this.copyClickedFn();
|
||||
} else {
|
||||
this.copyClicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
|
||||
/**
|
||||
* Reusable component for meeting toolbar additional buttons.
|
||||
* This component is agnostic and can be configured via inputs.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-toolbar-buttons',
|
||||
templateUrl: './meeting-toolbar-buttons.component.html',
|
||||
styleUrls: ['./meeting-toolbar-buttons.component.scss'],
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule, MatDividerModule]
|
||||
})
|
||||
export class MeetingToolbarButtonsComponent {
|
||||
/**
|
||||
* Whether to show the copy link button
|
||||
*/
|
||||
@Input() showCopyLinkButton = false;
|
||||
|
||||
/**
|
||||
* Whether to show the leave menu with options
|
||||
*/
|
||||
@Input() showLeaveMenu = false;
|
||||
|
||||
/**
|
||||
* Whether the device is mobile (affects button style)
|
||||
*/
|
||||
@Input() isMobile = false;
|
||||
|
||||
/**
|
||||
* Copy link button tooltip text
|
||||
*/
|
||||
@Input() copyLinkTooltip = 'Copy the meeting link';
|
||||
|
||||
/**
|
||||
* Copy link button text (for mobile)
|
||||
*/
|
||||
@Input() copyLinkText = 'Copy meeting link';
|
||||
|
||||
/**
|
||||
* Leave menu tooltip text
|
||||
*/
|
||||
@Input() leaveMenuTooltip = 'Leave options';
|
||||
|
||||
/**
|
||||
* Leave option text
|
||||
*/
|
||||
@Input() leaveOptionText = 'Leave meeting';
|
||||
|
||||
/**
|
||||
* End meeting option text
|
||||
*/
|
||||
@Input() endMeetingOptionText = 'End meeting for all';
|
||||
|
||||
/**
|
||||
* Emitted when the copy link button is clicked
|
||||
*/
|
||||
@Output() copyLinkClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the leave meeting option is clicked
|
||||
*/
|
||||
@Output() leaveMeetingClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emitted when the end meeting option is clicked
|
||||
*/
|
||||
@Output() endMeetingClicked = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when copy link button is clicked
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() copyLinkClickedFn?: () => void;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when leave meeting is clicked
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() leaveMeetingClickedFn?: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* Alternative to @Output: Function to call when end meeting is clicked
|
||||
* When using NgComponentOutlet, use this instead of the @Output above
|
||||
*/
|
||||
@Input() endMeetingClickedFn?: () => Promise<void>;
|
||||
|
||||
onCopyLinkClick(): void {
|
||||
if (this.copyLinkClickedFn) {
|
||||
this.copyLinkClickedFn();
|
||||
} else {
|
||||
this.copyLinkClicked.emit();
|
||||
}
|
||||
}
|
||||
|
||||
async onLeaveMeetingClick(): Promise<void> {
|
||||
if (this.leaveMeetingClickedFn) {
|
||||
await this.leaveMeetingClickedFn();
|
||||
} else {
|
||||
this.leaveMeetingClicked.emit();
|
||||
}
|
||||
}
|
||||
|
||||
async onEndMeetingClick(): Promise<void> {
|
||||
if (this.endMeetingClickedFn) {
|
||||
await this.endMeetingClickedFn();
|
||||
} else {
|
||||
this.endMeetingClicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
|
||||
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
|
||||
export * from './meeting-participant-panel-item/meeting-participant-panel-item.component';
|
||||
export * from './meeting-layout/meeting-layout.component';
|
||||
@ -0,0 +1,23 @@
|
||||
@if (meetingContextService.lkRoom()) {
|
||||
<ov-layout [ovRemoteParticipants]="filteredRemoteParticipants()">
|
||||
@if (showMeetingLinkOverlay()) {
|
||||
<ng-container *ovLayoutAdditionalElements>
|
||||
<div id="share-link-overlay" class="main-share-meeting-link-container fade-in-delayed OV_big">
|
||||
<ov-share-meeting-link
|
||||
class="main-share-meeting-link"
|
||||
[title]="linkOverlayTitle"
|
||||
[subtitle]="linkOverlaySubtitle"
|
||||
[titleSize]="linkOverlayTitleSize"
|
||||
[titleWeight]="linkOverlayTitleWeight"
|
||||
[meetingUrl]="meetingUrl()"
|
||||
(copyClicked)="onCopyMeetingLinkClicked()"
|
||||
></ov-share-meeting-link>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-template #stream let-track>
|
||||
<ov-stream [track]="track"></ov-stream>
|
||||
</ng-template>
|
||||
</ov-layout>
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
@use '../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
|
||||
.remote-participant {
|
||||
height: -webkit-fill-available;
|
||||
height: -moz-available;
|
||||
@ -76,3 +79,38 @@
|
||||
z-index: 999 !important;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.main-share-meeting-link-container {
|
||||
background-color: var(--ov-surface-color); // Use ov-components variable
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--ov-meet-radius-md);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
|
||||
.main-share-meeting-link {
|
||||
pointer-events: all;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in-delayed-more {
|
||||
animation: fadeIn 0.5s ease-in 0.9s both;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@ -8,8 +8,8 @@ import {
|
||||
OpenViduService
|
||||
} from 'openvidu-components-angular';
|
||||
import { MeetingLayoutComponent } from './meeting-layout.component';
|
||||
import { MeetLayoutService } from '../../services/layout.service';
|
||||
import { MeetLayoutMode } from '../../models/layout.model';
|
||||
import { MeetLayoutService } from '../../../services/layout.service';
|
||||
import { MeetLayoutMode } from '../../../models/layout.model';
|
||||
|
||||
describe('MeetingLayoutComponent', () => {
|
||||
let component: MeetingLayoutComponent;
|
||||
@ -1,18 +1,13 @@
|
||||
import { Component, signal, computed, effect, inject, DestroyRef, input, untracked, Type } from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { Component, signal, computed, effect, inject, DestroyRef, input, untracked } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Participant } from 'livekit-client';
|
||||
import {
|
||||
ParticipantModel,
|
||||
LoggerService,
|
||||
ParticipantService,
|
||||
OpenViduService,
|
||||
ILogger,
|
||||
OpenViduComponentsUiModule
|
||||
} from 'openvidu-components-angular';
|
||||
import { MeetLayoutMode } from '../../models/layout.model';
|
||||
import { MeetLayoutService } from '../../services/layout.service';
|
||||
import { MEETING_COMPONENTS_TOKEN, MeetingComponentsPlugins } from '../../customization';
|
||||
import { LoggerService, OpenViduService, ILogger, OpenViduComponentsUiModule } from 'openvidu-components-angular';
|
||||
import { MeetLayoutMode } from '../../../models/layout.model';
|
||||
import { CustomParticipantModel } from '../../../models';
|
||||
import { MeetLayoutService } from '../../../services/layout.service';
|
||||
import { MeetingContextService } from '../../../services/meeting/meeting-context.service';
|
||||
import { ShareMeetingLinkComponent } from '../../../components/share-meeting-link/share-meeting-link.component';
|
||||
import { MeetingService } from '../../../services/meeting/meeting.service';
|
||||
|
||||
/**
|
||||
* MeetingLayoutComponent - Intelligent layout component for scalable video conferencing
|
||||
@ -22,43 +17,40 @@ import { MEETING_COMPONENTS_TOKEN, MeetingComponentsPlugins } from '../../custom
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-layout',
|
||||
imports: [OpenViduComponentsUiModule, NgComponentOutlet],
|
||||
imports: [OpenViduComponentsUiModule, ShareMeetingLinkComponent],
|
||||
templateUrl: './meeting-layout.component.html',
|
||||
styleUrl: './meeting-layout.component.scss'
|
||||
})
|
||||
export class MeetingLayoutComponent {
|
||||
plugins: MeetingComponentsPlugins = inject(MEETING_COMPONENTS_TOKEN, { optional: true }) || {};
|
||||
|
||||
private readonly loggerSrv = inject(LoggerService);
|
||||
private readonly layoutService = inject(MeetLayoutService);
|
||||
private readonly participantService = inject(ParticipantService);
|
||||
private readonly openviduService = inject(OpenViduService);
|
||||
protected readonly openviduService = inject(OpenViduService);
|
||||
protected meetingContextService = inject(MeetingContextService);
|
||||
protected meetingService = inject(MeetingService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly log: ILogger = this.loggerSrv.get('MeetingLayoutComponent');
|
||||
protected readonly linkOverlayTitle = 'Start collaborating';
|
||||
protected readonly linkOverlaySubtitle = 'Share this link to bring others into the meeting';
|
||||
protected readonly linkOverlayTitleSize: 'sm' | 'md' | 'lg' | 'xl' = 'xl';
|
||||
protected readonly linkOverlayTitleWeight: 'normal' | 'bold' = 'bold';
|
||||
|
||||
/**
|
||||
* Maximum number of active remote speakers to show in the layout when the last speakers layout is enabled.
|
||||
* Higher values provide more context but may impact performance on lower-end devices.
|
||||
* @default 4
|
||||
*/
|
||||
readonly maxRemoteSpeakers = input<number>(4);
|
||||
|
||||
/**
|
||||
* Optional component to render additional elements in the layout (e.g., share link overlay)
|
||||
* This allows plugins to inject custom UI elements into the layout.
|
||||
*/
|
||||
readonly additionalElementsComponent = input<Type<any> | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Inputs to pass to the additional elements component
|
||||
*/
|
||||
readonly additionalElementsInputs = input<any>(undefined);
|
||||
|
||||
// Reactive state with Signals
|
||||
private readonly remoteParticipants = toSignal(this.participantService.remoteParticipants$, {
|
||||
initialValue: [] as ParticipantModel[]
|
||||
protected meetingUrl = computed(() => {
|
||||
return this.meetingContextService.meetingUrl();
|
||||
});
|
||||
|
||||
protected showMeetingLinkOverlay = computed(() => {
|
||||
const remoteParticipants = this.meetingContextService.remoteParticipants();
|
||||
return this.meetingContextService.canModerateRoom() && remoteParticipants.length === 0;
|
||||
});
|
||||
|
||||
// Reactive state with Signals - now using MeetingContextService
|
||||
private readonly remoteParticipants = computed(() => this.meetingContextService.remoteParticipants());
|
||||
|
||||
private readonly layoutMode = toSignal(this.layoutService.layoutMode$, {
|
||||
initialValue: MeetLayoutMode.LAST_SPEAKERS
|
||||
});
|
||||
@ -103,7 +95,7 @@ export class MeetingLayoutComponent {
|
||||
// Filter active speakers that still exist in remote participants
|
||||
const validActiveSpeakers = activeSpeakersOrder
|
||||
.map((identity) => participantsMap.get(identity))
|
||||
.filter((p): p is ParticipantModel => p !== undefined)
|
||||
.filter((p): p is CustomParticipantModel => p !== undefined)
|
||||
.slice(-maxSpeakers); // Take last N speakers (most recent)
|
||||
|
||||
// If we have fewer active speakers than max, fill with additional participants
|
||||
@ -120,8 +112,12 @@ export class MeetingLayoutComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Setup active speakers listener
|
||||
this.setupActiveSpeakersListener();
|
||||
effect(() => {
|
||||
const lkRoom = this.meetingContextService.lkRoom();
|
||||
if (lkRoom) {
|
||||
this.setupActiveSpeakersListener();
|
||||
}
|
||||
});
|
||||
|
||||
// Effect to log layout mode changes (development only)
|
||||
effect(() => {
|
||||
@ -157,17 +153,30 @@ export class MeetingLayoutComponent {
|
||||
});
|
||||
}
|
||||
|
||||
protected onCopyMeetingLinkClicked(): void {
|
||||
const room = this.meetingContextService.meetRoom();
|
||||
if (!room) {
|
||||
this.log.e('Cannot copy link: meeting room is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
this.meetingService.copyMeetingSpeakerLink(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the listener for active speakers changes from LiveKit
|
||||
* Uses efficient Set operations and early returns for performance
|
||||
*/
|
||||
private setupActiveSpeakersListener(): void {
|
||||
const room = this.openviduService.getRoom();
|
||||
if (!room) {
|
||||
this.log.e('Cannot setup active speakers listener: room is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register cleanup on component destroy
|
||||
this.destroyRef.onDestroy(() => {
|
||||
room.off('activeSpeakersChanged', this.handleActiveSpeakersChanged);
|
||||
this.log.d('Active speakers listener cleaned up');
|
||||
});
|
||||
|
||||
room.on('activeSpeakersChanged', this.handleActiveSpeakersChanged);
|
||||
@ -0,0 +1,67 @@
|
||||
<ng-template #template let-participantContext="participant" let-localParticipant="localParticipant">
|
||||
@let ctx = getDisplayProperties(participantContext, localParticipant);
|
||||
|
||||
<div class="participant-item-container">
|
||||
<ov-participant-panel-item [participant]="participantContext">
|
||||
<!-- Moderator Badge -->
|
||||
<ng-container *ovParticipantPanelParticipantBadge>
|
||||
@if (ctx.showModeratorBadge) {
|
||||
<span class="moderator-badge" [attr.id]="'moderator-badge-' + participantContext.sid">
|
||||
<mat-icon [matTooltip]="moderatorBadgeTooltip" class="material-symbols-outlined">
|
||||
shield_person
|
||||
</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Moderation Controls -->
|
||||
@if (ctx.showModerationControls) {
|
||||
<div
|
||||
*ovParticipantPanelItemElements
|
||||
class="moderation-controls"
|
||||
[attr.id]="'moderation-controls-' + participantContext.sid"
|
||||
>
|
||||
<!-- Make Moderator Button -->
|
||||
@if (ctx.showMakeModeratorButton) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onMakeModeratorClick(participantContext, localParticipant)"
|
||||
[matTooltip]="makeModeratorTooltip"
|
||||
class="make-moderator-btn"
|
||||
[attr.id]="'make-moderator-btn-' + participantContext.sid"
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Unmake Moderator Button -->
|
||||
@if (ctx.showUnmakeModeratorButton) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onUnmakeModeratorClick(participantContext, localParticipant)"
|
||||
[matTooltip]="unmakeModeratorTooltip"
|
||||
class="remove-moderator-btn"
|
||||
[attr.id]="'remove-moderator-btn-' + participantContext.sid"
|
||||
>
|
||||
<mat-icon class="material-symbols-outlined">remove_moderator</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Kick Participant Button -->
|
||||
@if (ctx.showKickButton) {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="onKickParticipantClick(participantContext, localParticipant)"
|
||||
[matTooltip]="kickParticipantTooltip"
|
||||
class="force-disconnect-btn"
|
||||
[attr.id]="'kick-participant-btn-' + participantContext.sid"
|
||||
>
|
||||
<mat-icon>call_end</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ov-participant-panel-item>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
@use '../../../../../../src/assets/styles/design-tokens';
|
||||
@use '../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.participant-item-container {
|
||||
width: 100%;
|
||||
@ -0,0 +1,139 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, TemplateRef, ViewChild, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { LoggerService, OpenViduComponentsUiModule } from 'openvidu-components-angular';
|
||||
import { CustomParticipantModel } from '../../../models';
|
||||
import { MeetingService } from '../../../services/meeting/meeting.service';
|
||||
import { MeetRoomMemberRole } from '@openvidu-meet/typings';
|
||||
|
||||
/**
|
||||
* Interface for computed participant display properties
|
||||
*/
|
||||
export interface ParticipantDisplayProperties {
|
||||
showModeratorBadge: boolean;
|
||||
showModerationControls: boolean;
|
||||
showMakeModeratorButton: boolean;
|
||||
showUnmakeModeratorButton: boolean;
|
||||
showKickButton: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for displaying participant panel items with moderation controls.
|
||||
* This component receives context from the template (participant, localParticipant).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-participant-panel-item',
|
||||
templateUrl: './meeting-participant-panel-item.component.html',
|
||||
styleUrls: ['./meeting-participant-panel-item.component.scss'],
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule, OpenViduComponentsUiModule]
|
||||
})
|
||||
export class MeetingParticipantPanelItemComponent {
|
||||
// Template reference for the component's template
|
||||
@ViewChild('template', { static: true }) template!: TemplateRef<any>;
|
||||
|
||||
protected meetingService: MeetingService = inject(MeetingService);
|
||||
protected loggerService = inject(LoggerService);
|
||||
protected log = this.loggerService.get('OpenVidu Meet - ParticipantPanelItem');
|
||||
|
||||
// Tooltips (could be made configurable in the future if needed)
|
||||
protected readonly moderatorBadgeTooltip = 'Moderator';
|
||||
protected readonly makeModeratorTooltip = 'Make participant moderator';
|
||||
protected readonly unmakeModeratorTooltip = 'Unmake participant moderator';
|
||||
protected readonly kickParticipantTooltip = 'Kick participant';
|
||||
|
||||
/**
|
||||
* Get or compute display properties for a participant
|
||||
*/
|
||||
protected getDisplayProperties(
|
||||
participant: CustomParticipantModel,
|
||||
localParticipant: CustomParticipantModel
|
||||
): ParticipantDisplayProperties {
|
||||
// Compute all display properties once
|
||||
const isLocalModerator = localParticipant.isModerator();
|
||||
const isParticipantLocal = participant.isLocal;
|
||||
const isParticipantModerator = participant.isModerator();
|
||||
const isParticipantOriginalModerator = participant.isOriginalModerator();
|
||||
|
||||
return {
|
||||
showModeratorBadge: isParticipantModerator,
|
||||
showModerationControls: isLocalModerator && !isParticipantLocal,
|
||||
showMakeModeratorButton: isLocalModerator && !isParticipantLocal && !isParticipantModerator,
|
||||
showUnmakeModeratorButton:
|
||||
isLocalModerator && !isParticipantLocal && isParticipantModerator && !isParticipantOriginalModerator,
|
||||
showKickButton: isLocalModerator && !isParticipantLocal && !isParticipantOriginalModerator
|
||||
};
|
||||
}
|
||||
|
||||
async onMakeModeratorClick(
|
||||
participantContext: CustomParticipantModel,
|
||||
localParticipant: CustomParticipantModel
|
||||
): Promise<void> {
|
||||
if (!localParticipant.isModerator()) return;
|
||||
|
||||
const roomId = localParticipant.roomName;
|
||||
|
||||
if (!roomId) {
|
||||
this.log.e('Cannot change participant role: local participant room name is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.meetingService.changeParticipantRole(
|
||||
roomId,
|
||||
participantContext.identity,
|
||||
MeetRoomMemberRole.MODERATOR
|
||||
);
|
||||
this.log.d('Moderator assigned successfully');
|
||||
} catch (error) {
|
||||
this.log.e('Error assigning moderator:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onUnmakeModeratorClick(
|
||||
participantContext: CustomParticipantModel,
|
||||
localParticipant: CustomParticipantModel
|
||||
): Promise<void> {
|
||||
if (!localParticipant.isModerator()) return;
|
||||
|
||||
const roomId = localParticipant.roomName;
|
||||
|
||||
if (!roomId) {
|
||||
this.log.e('Cannot change participant role: local participant room name is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.meetingService.changeParticipantRole(
|
||||
roomId,
|
||||
participantContext.identity,
|
||||
MeetRoomMemberRole.SPEAKER
|
||||
);
|
||||
this.log.d('Moderator unassigned successfully');
|
||||
} catch (error) {
|
||||
this.log.e('Error unassigning moderator:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async onKickParticipantClick(
|
||||
participantContext: CustomParticipantModel,
|
||||
localParticipant: CustomParticipantModel
|
||||
): Promise<void> {
|
||||
if (!localParticipant.isModerator()) return;
|
||||
|
||||
const roomId = localParticipant.roomName;
|
||||
|
||||
if (!roomId) {
|
||||
this.log.e('Cannot change participant role: local participant room name is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.meetingService.kickParticipant(roomId, participantContext.identity);
|
||||
this.log.d('Participant kicked successfully');
|
||||
} catch (error) {
|
||||
this.log.e('Error kicking participant:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
@if (showShareLink()) {
|
||||
<div class="share-meeting-link-container">
|
||||
<ov-share-meeting-link [meetingUrl]="meetingUrl()" (copyClicked)="onCopyClicked()"></ov-share-meeting-link>
|
||||
</div>
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { ShareMeetingLinkComponent } from '../../../components/share-meeting-link/share-meeting-link.component';
|
||||
import { MeetingContextService } from '../../../services/meeting/meeting-context.service';
|
||||
import { MeetingService } from '../../../services/meeting/meeting.service';
|
||||
import { LoggerService } from 'openvidu-components-angular';
|
||||
|
||||
/**
|
||||
* Reusable component for displaying the share meeting link panel
|
||||
* inside the participants panel.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-share-link-panel',
|
||||
templateUrl: './meeting-share-link-panel.component.html',
|
||||
styleUrls: ['./meeting-share-link-panel.component.scss'],
|
||||
imports: [CommonModule, ShareMeetingLinkComponent]
|
||||
})
|
||||
export class MeetingShareLinkPanelComponent {
|
||||
protected meetingContextService = inject(MeetingContextService);
|
||||
protected meetingService = inject(MeetingService);
|
||||
protected loggerService = inject(LoggerService);
|
||||
protected log = this.loggerService.get('OpenVidu Meet - MeetingShareLinkPanel');
|
||||
|
||||
/**
|
||||
* Computed signal to determine if the share link should be shown
|
||||
*/
|
||||
protected showShareLink = computed(() => {
|
||||
return this.meetingContextService.canModerateRoom();
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal for the meeting URL from context
|
||||
*/
|
||||
protected meetingUrl = computed(() => {
|
||||
return this.meetingContextService.meetingUrl();
|
||||
});
|
||||
|
||||
onCopyClicked(): void {
|
||||
const room = this.meetingContextService.meetRoom();
|
||||
if (!room) {
|
||||
this.log.e('Cannot copy link: meeting room is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
this.meetingService.copyMeetingSpeakerLink(room);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
<!-- Copy Link Button -->
|
||||
@if (showCopyLinkButton) {
|
||||
@if (isMobile) {
|
||||
@if (showCopyLinkButton()) {
|
||||
@if (isMobile()) {
|
||||
<button id="copy-speaker-link" mat-menu-item (click)="onCopyLinkClick()" [disableRipple]="true">
|
||||
<mat-icon>link</mat-icon>
|
||||
<span class="button-text">{{ copyLinkText }}</span>
|
||||
@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
<!-- Leave Menu -->
|
||||
@if (showLeaveMenu) {
|
||||
@if (showLeaveMenu()) {
|
||||
<button
|
||||
id="leave-btn"
|
||||
mat-icon-button
|
||||
@ -27,7 +27,7 @@
|
||||
[matTooltip]="leaveMenuTooltip"
|
||||
[disableRipple]="true"
|
||||
class="custom-leave-btn"
|
||||
[class.mobile-btn]="isMobile"
|
||||
[class.mobile-btn]="isMobile()"
|
||||
>
|
||||
<mat-icon>call_end</mat-icon>
|
||||
</button>
|
||||
@ -1,4 +1,4 @@
|
||||
@use '../../../../../../src/assets/styles/design-tokens';
|
||||
@use '../../../../../../../src/assets/styles/design-tokens';
|
||||
|
||||
.button-text {
|
||||
margin-left: 8px;
|
||||
@ -0,0 +1,77 @@
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MeetingContextService } from '../../../services/meeting/meeting-context.service';
|
||||
import { MeetingService } from '../../../services/meeting/meeting.service';
|
||||
import { LoggerService, OpenViduService, ViewportService } from 'openvidu-components-angular';
|
||||
|
||||
/**
|
||||
* Reusable component for meeting toolbar additional buttons.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ov-meeting-toolbar-buttons',
|
||||
templateUrl: './meeting-toolbar-buttons.component.html',
|
||||
styleUrls: ['./meeting-toolbar-buttons.component.scss'],
|
||||
imports: [CommonModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule, MatDividerModule]
|
||||
})
|
||||
export class MeetingToolbarButtonsComponent {
|
||||
protected meetingContextService = inject(MeetingContextService);
|
||||
protected meetingService = inject(MeetingService);
|
||||
protected loggerService = inject(LoggerService);
|
||||
protected log = this.loggerService.get('OpenVidu Meet - MeetingToolbarButtons');
|
||||
protected openviduService = inject(OpenViduService);
|
||||
protected readonly copyLinkTooltip = 'Copy the meeting link';
|
||||
protected readonly copyLinkText = 'Copy meeting link';
|
||||
protected readonly leaveMenuTooltip = 'Leave options';
|
||||
protected readonly leaveOptionText = 'Leave meeting';
|
||||
protected readonly endMeetingOptionText = 'End meeting for all';
|
||||
|
||||
/**
|
||||
* Whether to show the copy link button
|
||||
*/
|
||||
protected showCopyLinkButton = computed(() => {
|
||||
return this.meetingContextService.canModerateRoom();
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether to show the leave menu with options
|
||||
*/
|
||||
protected showLeaveMenu = computed(() => {
|
||||
return this.meetingContextService.canModerateRoom();
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the device is mobile (affects button style)
|
||||
*/
|
||||
protected isMobile = computed(() => {
|
||||
return this.meetingContextService.isMobile();
|
||||
});
|
||||
|
||||
onCopyLinkClick(): void {
|
||||
const room = this.meetingContextService.meetRoom();
|
||||
if (!room) {
|
||||
this.log.e('Cannot copy link: meeting room is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
this.meetingService.copyMeetingSpeakerLink(room);
|
||||
}
|
||||
|
||||
async onLeaveMeetingClick(): Promise<void> {
|
||||
await this.openviduService.disconnectRoom();
|
||||
}
|
||||
|
||||
async onEndMeetingClick(): Promise<void> {
|
||||
const roomId = this.meetingContextService.roomId();
|
||||
if (!roomId) {
|
||||
this.log.e('Cannot end meeting: room ID is undefined');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.meetingService.endMeeting(roomId);
|
||||
}
|
||||
}
|
||||
@ -3,3 +3,5 @@
|
||||
*/
|
||||
export * from './components/meeting-components-plugins.token';
|
||||
export * from './handlers/meeting-action-handler';
|
||||
|
||||
export * from './components/index';
|
||||
|
||||
@ -7,7 +7,7 @@ import { RoomService } from '../services/room.service';
|
||||
* Guard that prevents editing a room when there's an active meeting.
|
||||
* Redirects to /rooms if the room has an active meeting.
|
||||
*/
|
||||
export const checkRoomEditGuard: CanActivateFn = async (route) => {
|
||||
export const checkEditableRoomGuard: CanActivateFn = async (route) => {
|
||||
const roomService = inject(RoomService);
|
||||
const router = inject(Router);
|
||||
|
||||
|
||||
@ -2,11 +2,12 @@ import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn } from '@angular/router';
|
||||
import { WebComponentProperty } from '@openvidu-meet/typings';
|
||||
import { ErrorReason } from '../models';
|
||||
import { AppDataService, NavigationService, RoomMemberService, RoomService, SessionStorageService } from '../services';
|
||||
import { AppDataService, MeetingContextService, NavigationService, RoomMemberService, RoomService, SessionStorageService } from '../services';
|
||||
|
||||
export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => {
|
||||
const navigationService = inject(NavigationService);
|
||||
const roomService = inject(RoomService);
|
||||
const meetingContextService = inject(MeetingContextService);
|
||||
const roomMemberService = inject(RoomMemberService);
|
||||
const sessionStorageService = inject(SessionStorageService);
|
||||
|
||||
@ -33,7 +34,7 @@ export const extractRoomQueryParamsGuard: CanActivateFn = (route: ActivatedRoute
|
||||
roomService.setRoomSecret(secret);
|
||||
|
||||
if (e2eeKey) {
|
||||
roomService.setE2EEKey(e2eeKey);
|
||||
meetingContextService.setE2eeKey(e2eeKey);
|
||||
}
|
||||
|
||||
if (participantName) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
|
||||
import { ErrorReason } from '../models';
|
||||
import { NavigationService, RecordingService, RoomMemberService, RoomService } from '../services';
|
||||
import { MeetingContextService, NavigationService, RecordingService, RoomMemberService } from '../services';
|
||||
|
||||
/**
|
||||
* Guard to validate access to a room by generating a room member token.
|
||||
@ -31,12 +31,20 @@ export const validateRoomRecordingsAccessGuard: CanActivateFn = async (
|
||||
* @returns True if access is granted, or UrlTree for redirection
|
||||
*/
|
||||
const validateRoomAccessInternal = async (pageUrl: string, validateRecordingPermissions = false) => {
|
||||
const roomService = inject(RoomService);
|
||||
const roomMemberService = inject(RoomMemberService);
|
||||
const navigationService = inject(NavigationService);
|
||||
const meetingContextService = inject(MeetingContextService);
|
||||
|
||||
const roomId = roomService.getRoomId();
|
||||
const secret = roomService.getRoomSecret();
|
||||
const roomId = meetingContextService.roomId();
|
||||
if (!roomId) {
|
||||
console.error('Cannot validate room access: room ID is undefined');
|
||||
return navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM);
|
||||
}
|
||||
const secret = meetingContextService.roomSecret();
|
||||
if (!secret) {
|
||||
console.error('Cannot validate room access: room secret is undefined');
|
||||
return navigationService.redirectToErrorPage(ErrorReason.MISSING_ROOM_SECRET);
|
||||
}
|
||||
|
||||
try {
|
||||
await roomMemberService.generateToken(roomId, {
|
||||
|
||||
@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpReq
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, from, Observable, switchMap } from 'rxjs';
|
||||
import { AuthService, RoomMemberService, RoomService, TokenStorageService } from '../services';
|
||||
import { AuthService, MeetingContextService, RoomMemberService, RoomService, TokenStorageService } from '../services';
|
||||
|
||||
/**
|
||||
* Adds all necessary authorization headers to the request based on available tokens
|
||||
@ -34,7 +34,7 @@ const addAuthHeadersIfNeeded = (
|
||||
export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||
const router: Router = inject(Router);
|
||||
const authService: AuthService = inject(AuthService);
|
||||
const roomService = inject(RoomService);
|
||||
const meetingContextService = inject(MeetingContextService);
|
||||
const roomMemberService = inject(RoomMemberService);
|
||||
const tokenStorageService = inject(TokenStorageService);
|
||||
|
||||
@ -72,8 +72,10 @@ export const httpInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, ne
|
||||
|
||||
const refreshRoomMemberToken = (firstError: HttpErrorResponse): Observable<HttpEvent<unknown>> => {
|
||||
console.log('Refreshing room member token...');
|
||||
const roomId = roomService.getRoomId();
|
||||
const secret = roomService.getRoomSecret();
|
||||
const roomId = meetingContextService.roomId();
|
||||
if (!roomId) throw new Error('Cannot refresh room member token: room ID is undefined');
|
||||
const secret = meetingContextService.roomSecret();
|
||||
if (!secret) throw new Error('Cannot refresh room member token: room secret is undefined');
|
||||
const participantName = roomMemberService.getParticipantName();
|
||||
const participantIdentity = roomMemberService.getParticipantIdentity();
|
||||
const grantJoinMeetingPermission = !!participantIdentity; // Grant join permission if identity is set
|
||||
|
||||
@ -2,18 +2,19 @@ import { FormGroup } from '@angular/forms';
|
||||
import { MeetRoom } from '@openvidu-meet/typings';
|
||||
|
||||
/**
|
||||
* State interface representing the lobby state of a meeting
|
||||
* State interface representing the lobby phase of a meeting.
|
||||
*
|
||||
* IMPORTANT: This state is ONLY relevant during the lobby phase (before joining the meeting).
|
||||
* Once the participant joins the meeting, MeetingContextService becomes the single source of truth.
|
||||
*/
|
||||
export interface LobbyState {
|
||||
room?: MeetRoom;
|
||||
roomId: string;
|
||||
roomSecret: string;
|
||||
roomId?: string;
|
||||
roomClosed: boolean;
|
||||
hasRecordings: boolean;
|
||||
showRecordingCard: boolean;
|
||||
showBackButton: boolean;
|
||||
backButtonText: string;
|
||||
isE2EEEnabled: boolean;
|
||||
hasRoomE2EEEnabled: boolean;
|
||||
participantForm: FormGroup;
|
||||
roomMemberToken: string;
|
||||
roomMemberToken?: string;
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
@if (showPrejoin) {
|
||||
<!-- Prejoin screen (Lobby) -->
|
||||
@if (prejoinReady && plugins.lobby) {
|
||||
<ng-container [ngComponentOutlet]="plugins.lobby" [ngComponentOutletInputs]="lobbyInputs()"> </ng-container>
|
||||
} @else if (!prejoinReady) {
|
||||
@if (showLobby) {
|
||||
<!-- Lobby phase (before joining meeting) -->
|
||||
@if (isLobbyReady) {
|
||||
<ov-meeting-lobby></ov-meeting-lobby>
|
||||
} @else if (!isLobbyReady) {
|
||||
<div class="prejoin-loading-container">
|
||||
<mat-spinner diameter="30"></mat-spinner>
|
||||
<p class="prejoin-loading-text">Preparing your meeting...</p>
|
||||
@ -10,24 +10,24 @@
|
||||
} @else {
|
||||
<div class="prejoin-error-container">
|
||||
<mat-icon class="prejoin-error-icon">error_outline</mat-icon>
|
||||
<p class="prejoin-error-text">Unable to load the pre-join screen. Please try reloading the page.</p>
|
||||
<p class="prejoin-error-text">Unable to load the lobby. Please try reloading the page.</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<ov-videoconference
|
||||
[token]="roomMemberToken"
|
||||
[token]="roomMemberToken()!"
|
||||
[prejoin]="true"
|
||||
[prejoinDisplayParticipantName]="false"
|
||||
[videoEnabled]="features().videoEnabled"
|
||||
[audioEnabled]="features().audioEnabled"
|
||||
[e2eeKey]="e2eeKey"
|
||||
[toolbarRoomName]="roomName"
|
||||
[e2eeKey]="e2eeKey()"
|
||||
[toolbarRoomName]="roomName()"
|
||||
[toolbarCameraButton]="features().showCamera"
|
||||
[toolbarMicrophoneButton]="features().showMicrophone"
|
||||
[toolbarScreenshareButton]="features().showScreenShare"
|
||||
[toolbarLeaveButton]="!features().canModerateRoom"
|
||||
[toolbarRecordingButton]="features().canRecordRoom"
|
||||
[toolbarViewRecordingsButton]="features().canRetrieveRecordings && hasRecordings"
|
||||
[toolbarViewRecordingsButton]="features().canRetrieveRecordings && hasRecordings()"
|
||||
[toolbarBroadcastingButton]="false"
|
||||
[toolbarChatPanelButton]="features().showChat"
|
||||
[toolbarBackgroundEffectsButton]="features().showBackgrounds"
|
||||
@ -43,64 +43,40 @@
|
||||
externalView: true
|
||||
}"
|
||||
[recordingActivityStartStopRecordingButton]="features().canRecordRoom"
|
||||
[recordingActivityViewRecordingsButton]="features().canRetrieveRecordings && hasRecordings"
|
||||
[recordingActivityViewRecordingsButton]="features().canRetrieveRecordings && hasRecordings()"
|
||||
[recordingActivityShowRecordingsList]="false"
|
||||
[activitiesPanelBroadcastingActivity]="false"
|
||||
[showThemeSelector]="features().showThemeSelector"
|
||||
[showDisconnectionDialog]="false"
|
||||
(onRoomCreated)="onRoomCreated($event)"
|
||||
(onParticipantConnected)="eventHandler.onParticipantConnected($event)"
|
||||
(onParticipantLeft)="eventHandler.onParticipantLeft($event)"
|
||||
(onRecordingStartRequested)="eventHandler.onRecordingStartRequested($event)"
|
||||
(onRecordingStopRequested)="eventHandler.onRecordingStopRequested($event)"
|
||||
(onParticipantConnected)="eventHandlerService.onParticipantConnected($event)"
|
||||
(onParticipantLeft)="eventHandlerService.onParticipantLeft($event)"
|
||||
(onRecordingStartRequested)="eventHandlerService.onRecordingStartRequested($event)"
|
||||
(onRecordingStopRequested)="eventHandlerService.onRecordingStopRequested($event)"
|
||||
(onViewRecordingsClicked)="onViewRecordingsClicked()"
|
||||
>
|
||||
<!-- Toolbar Additional Buttons Plugin -->
|
||||
@if (plugins.toolbar?.additionalButtons) {
|
||||
<ng-container *ovToolbarAdditionalButtons>
|
||||
<ng-container
|
||||
[ngComponentOutlet]="plugins.toolbar!.additionalButtons!"
|
||||
[ngComponentOutletInputs]="toolbarAdditionalButtonsInputs()"
|
||||
></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
<!-- Toolbar Additional Buttons -->
|
||||
<div *ovToolbarAdditionalButtons>
|
||||
<ng-content select="ov-meeting-toolbar-buttons[slot='additional-buttons']"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar Leave Button Plugin -->
|
||||
@if (plugins.toolbar?.leaveButton) {
|
||||
<ng-container *ovToolbarLeaveButton>
|
||||
<ng-container
|
||||
[ngComponentOutlet]="plugins.toolbar!.leaveButton!"
|
||||
[ngComponentOutletInputs]="toolbarLeaveButtonInputs()"
|
||||
></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
<!-- Share Link Panel After Local Participant -->
|
||||
<div *ovParticipantPanelAfterLocalParticipant>
|
||||
<ng-content select="ov-meeting-share-link-panel[slot='after-local-participant']"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Participant Panel After Local Participant Plugin -->
|
||||
@if (plugins.participantPanel?.afterLocalParticipant) {
|
||||
<ng-container *ovParticipantPanelAfterLocalParticipant>
|
||||
<ng-container
|
||||
[ngComponentOutlet]="plugins.participantPanel!.afterLocalParticipant!"
|
||||
[ngComponentOutletInputs]="participantPanelAfterLocalInputs()"
|
||||
></ng-container>
|
||||
<!-- Participant Panel Item Template -->
|
||||
<div *ovParticipantPanelItem="let participant">
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="participantPanelItemTemplate"
|
||||
[ngTemplateOutletContext]="{ participant: participant, localParticipant: localParticipant() }"
|
||||
>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Custom layout component (CE uses MeetingLayoutComponent directly) -->
|
||||
<!-- Custom layout component -->
|
||||
<ng-container *ovLayout>
|
||||
<ov-meeting-layout
|
||||
[additionalElementsComponent]="layoutInputs().additionalElementsComponent"
|
||||
[additionalElementsInputs]="layoutInputs().additionalElementsInputs"
|
||||
></ov-meeting-layout>
|
||||
<ng-content select="ov-meeting-layout[slot='layout']"></ng-content>
|
||||
</ng-container>
|
||||
|
||||
<!-- Participant Panel Item Plugin -->
|
||||
@if (plugins.participantPanel?.item) {
|
||||
<ng-container *ovParticipantPanelItem="let participant">
|
||||
<ng-container
|
||||
[ngComponentOutlet]="plugins.participantPanel!.item!"
|
||||
[ngComponentOutletInputs]="participantPanelItemInputsMap().get(participant.identity)"
|
||||
></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
</ov-videoconference>
|
||||
}
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { CommonModule, NgComponentOutlet } from '@angular/common';
|
||||
import { Component, computed, effect, inject, OnInit, Signal, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, ContentChild, effect, inject, OnInit, Signal, signal, TemplateRef } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MeetRoom, MeetRoomMemberRole } from '@openvidu-meet/typings';
|
||||
import {
|
||||
ParticipantService as ComponentParticipantService,
|
||||
OpenViduComponentsUiModule,
|
||||
OpenViduService,
|
||||
OpenViduThemeMode,
|
||||
@ -15,22 +12,21 @@ import {
|
||||
Track,
|
||||
ViewportService
|
||||
} from 'openvidu-components-angular';
|
||||
import { combineLatest, Subject, takeUntil } from 'rxjs';
|
||||
import { MEETING_ACTION_HANDLER_TOKEN, MEETING_COMPONENTS_TOKEN, MeetingComponentsPlugins } from '../../customization';
|
||||
import { CustomParticipantModel } from '../../models';
|
||||
import { LobbyState } from '../../models/lobby.model';
|
||||
import { Subject } from 'rxjs';
|
||||
import { MeetingParticipantPanelItemComponent } from '../../customization';
|
||||
import {
|
||||
ApplicationFeatures,
|
||||
FeatureConfigurationService,
|
||||
GlobalConfigService,
|
||||
MeetingContextService,
|
||||
MeetingEventHandlerService,
|
||||
MeetingLobbyService,
|
||||
MeetingPluginManagerService,
|
||||
MeetingService,
|
||||
NotificationService,
|
||||
RoomMemberService,
|
||||
WebComponentManagerService
|
||||
} from '../../services';
|
||||
import { MeetingLobbyComponent } from '../../components/meeting-lobby/meeting-lobby.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ov-meeting',
|
||||
@ -41,53 +37,67 @@ import {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgComponentOutlet,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule
|
||||
MatProgressSpinnerModule,
|
||||
MeetingLobbyComponent
|
||||
],
|
||||
providers: [MeetingLobbyService, MeetingPluginManagerService, MeetingEventHandlerService]
|
||||
providers: [MeetingLobbyService, MeetingEventHandlerService]
|
||||
})
|
||||
export class MeetingComponent implements OnInit {
|
||||
lobbyState?: LobbyState;
|
||||
protected localParticipant = signal<CustomParticipantModel | undefined>(undefined);
|
||||
protected _participantPanelItem?: MeetingParticipantPanelItemComponent;
|
||||
|
||||
// Reactive signal for remote participants to trigger computed updates
|
||||
protected remoteParticipants = signal<CustomParticipantModel[]>([]);
|
||||
// Template reference for custom participant panel item
|
||||
@ContentChild(MeetingParticipantPanelItemComponent)
|
||||
set participantPanelItem(value: MeetingParticipantPanelItemComponent | undefined) {
|
||||
// Store the reference to the custom participant panel item component
|
||||
this._participantPanelItem = value;
|
||||
}
|
||||
get participantPanelItemTemplate(): TemplateRef<any> | undefined {
|
||||
return this._participantPanelItem?.template;
|
||||
}
|
||||
|
||||
// Signal to track participant updates (role changes, etc.) that don't change array references
|
||||
protected participantsVersion = signal<number>(0);
|
||||
|
||||
showPrejoin = true;
|
||||
prejoinReady = false;
|
||||
features: Signal<ApplicationFeatures>;
|
||||
|
||||
// Injected plugins
|
||||
plugins: MeetingComponentsPlugins;
|
||||
/**
|
||||
* Controls whether to show lobby (true) or meeting view (false)
|
||||
*/
|
||||
showLobby = true;
|
||||
isLobbyReady = false;
|
||||
|
||||
protected features: Signal<ApplicationFeatures>;
|
||||
protected meetingService = inject(MeetingService);
|
||||
protected participantService = inject(RoomMemberService);
|
||||
protected featureConfService = inject(FeatureConfigurationService);
|
||||
protected wcManagerService = inject(WebComponentManagerService);
|
||||
protected openviduService = inject(OpenViduService);
|
||||
protected ovComponentsParticipantService = inject(ComponentParticipantService);
|
||||
protected viewportService = inject(ViewportService);
|
||||
protected ovThemeService = inject(OpenViduThemeService);
|
||||
protected configService = inject(GlobalConfigService);
|
||||
protected clipboard = inject(Clipboard);
|
||||
protected notificationService = inject(NotificationService);
|
||||
protected lobbyService = inject(MeetingLobbyService);
|
||||
protected pluginManager = inject(MeetingPluginManagerService);
|
||||
|
||||
// Public for direct template binding (uses arrow functions to preserve 'this' context)
|
||||
public eventHandler = inject(MeetingEventHandlerService);
|
||||
|
||||
// Injected action handler (optional - falls back to default implementation)
|
||||
protected actionHandler = inject(MEETING_ACTION_HANDLER_TOKEN, { optional: true });
|
||||
protected meetingContextService = inject(MeetingContextService);
|
||||
protected eventHandlerService = inject(MeetingEventHandlerService);
|
||||
protected destroy$ = new Subject<void>();
|
||||
|
||||
// === LOBBY PHASE COMPUTED SIGNALS (when showLobby = true) ===
|
||||
protected participantName = computed(() => this.lobbyService.participantName());
|
||||
protected e2eeKey = computed(() => this.lobbyService.e2eeKeyValue());
|
||||
protected roomName = computed(() => this.lobbyService.roomName());
|
||||
protected roomMemberToken = computed(() => this.lobbyService.roomMemberToken());
|
||||
|
||||
// === MEETING PHASE COMPUTED SIGNALS (when showLobby = false) ===
|
||||
// These read from MeetingContextService (Single Source of Truth during meeting)
|
||||
protected localParticipant = computed(() => this.meetingContextService.localParticipant());
|
||||
protected remoteParticipants = computed(() => this.meetingContextService.remoteParticipants());
|
||||
protected hasRemoteParticipants = computed(() => this.remoteParticipants().length > 0);
|
||||
protected participantsVersion = computed(() => this.meetingContextService.participantsVersion());
|
||||
|
||||
// === SHARED COMPUTED SIGNALS (used in both phases) ===
|
||||
// Both lobby and meeting need these, so we read from MeetingContextService (Single Source of Truth)
|
||||
protected roomId = computed(() => this.meetingContextService.roomId());
|
||||
protected roomSecret = computed(() => this.meetingContextService.roomSecret());
|
||||
protected hasRecordings = computed(() => this.meetingContextService.hasRecordings());
|
||||
|
||||
constructor() {
|
||||
this.features = this.featureConfService.features;
|
||||
this.plugins = inject(MEETING_COMPONENTS_TOKEN, { optional: true }) || {};
|
||||
|
||||
// Change theme variables when custom theme is enabled
|
||||
effect(() => {
|
||||
@ -105,142 +115,23 @@ export class MeetingComponent implements OnInit {
|
||||
this.ovThemeService.resetThemeVariables();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Computed signals for plugin inputs
|
||||
protected toolbarAdditionalButtonsInputs = computed(() =>
|
||||
this.pluginManager.getToolbarAdditionalButtonsInputs(this.features().canModerateRoom, this.isMobile, () =>
|
||||
this.handleCopySpeakerLink()
|
||||
)
|
||||
);
|
||||
|
||||
protected toolbarLeaveButtonInputs = computed(() =>
|
||||
this.pluginManager.getToolbarLeaveButtonInputs(
|
||||
this.features().canModerateRoom,
|
||||
this.isMobile,
|
||||
() => this.openviduService.disconnectRoom(),
|
||||
() => this.endMeeting()
|
||||
)
|
||||
);
|
||||
|
||||
protected participantPanelAfterLocalInputs = computed(() =>
|
||||
this.pluginManager.getParticipantPanelAfterLocalInputs(
|
||||
this.features().canModerateRoom,
|
||||
`${this.hostname}/room/${this.roomId}`,
|
||||
() => this.handleCopySpeakerLink()
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Inputs for custom layout component (CE or PRO)
|
||||
* Includes additionalElementsComponent if provided via plugin
|
||||
*/
|
||||
protected layoutInputs = computed(() => {
|
||||
const showOverlay = this.onlyModeratorIsPresent;
|
||||
const meetingUrl = `${this.hostname}/room/${this.roomId}`;
|
||||
const onCopyLinkFn = () => this.handleCopySpeakerLink();
|
||||
const additionalElementsComponent = this.plugins.layoutAdditionalElements;
|
||||
return this.pluginManager.getLayoutInputs(showOverlay, meetingUrl, onCopyLinkFn, additionalElementsComponent);
|
||||
});
|
||||
|
||||
protected lobbyInputs = computed(() => {
|
||||
if (!this.lobbyState) return {};
|
||||
return this.pluginManager.getLobbyInputs(
|
||||
this.lobbyState,
|
||||
this.hostname,
|
||||
this.features().canModerateRoom,
|
||||
() => this.submitAccessMeeting(),
|
||||
() => this.lobbyService.goToRecordings(),
|
||||
() => this.lobbyService.goBack(),
|
||||
() => this.handleCopySpeakerLink()
|
||||
);
|
||||
});
|
||||
|
||||
protected participantPanelItemInputsMap = computed(() => {
|
||||
const local = this.localParticipant();
|
||||
const remotes = this.remoteParticipants();
|
||||
// Force reactivity by reading participantsVersion signal
|
||||
this.participantsVersion();
|
||||
const allParticipants: CustomParticipantModel[] = local ? [local, ...remotes] : remotes;
|
||||
|
||||
const inputsMap = new Map<string, any>();
|
||||
for (const participant of allParticipants) {
|
||||
const inputs = this.pluginManager.getParticipantPanelItemInputs(
|
||||
participant,
|
||||
allParticipants,
|
||||
(p) => this.handleMakeModerator(p),
|
||||
(p) => this.handleUnmakeModerator(p),
|
||||
(p) => this.handleKickParticipant(p)
|
||||
);
|
||||
inputsMap.set(participant.identity, inputs);
|
||||
}
|
||||
|
||||
return inputsMap;
|
||||
});
|
||||
|
||||
get participantName(): string {
|
||||
return this.lobbyService.participantName;
|
||||
}
|
||||
get e2eeKey(): string {
|
||||
return this.lobbyService.e2eeKey;
|
||||
}
|
||||
|
||||
get roomMemberToken(): string {
|
||||
return this.lobbyState!.roomMemberToken;
|
||||
}
|
||||
|
||||
get room(): MeetRoom | undefined {
|
||||
return this.lobbyState?.room;
|
||||
}
|
||||
|
||||
get roomName(): string {
|
||||
return this.lobbyState?.room?.roomName || 'Room';
|
||||
}
|
||||
|
||||
get roomId(): string {
|
||||
return this.lobbyState?.roomId || '';
|
||||
}
|
||||
|
||||
get roomSecret(): string {
|
||||
return this.lobbyState?.roomSecret || '';
|
||||
}
|
||||
|
||||
set roomSecret(value: string) {
|
||||
if (this.lobbyState) {
|
||||
this.lobbyState.roomSecret = value;
|
||||
}
|
||||
}
|
||||
|
||||
get onlyModeratorIsPresent(): boolean {
|
||||
return this.features().canModerateRoom && !this.hasRemoteParticipants;
|
||||
}
|
||||
|
||||
get hasRemoteParticipants(): boolean {
|
||||
return this.remoteParticipants().length > 0;
|
||||
}
|
||||
|
||||
get hasRecordings(): boolean {
|
||||
return this.lobbyState?.hasRecordings || false;
|
||||
}
|
||||
|
||||
set hasRecordings(value: boolean) {
|
||||
if (this.lobbyState) {
|
||||
this.lobbyState.hasRecordings = value;
|
||||
}
|
||||
}
|
||||
|
||||
get hostname(): string {
|
||||
return window.location.origin.replace('http://', '').replace('https://', '');
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.viewportService.isMobile();
|
||||
// Observe lobby state changes reactively
|
||||
// When roomMemberToken is set, transition from lobby to meeting
|
||||
effect(async () => {
|
||||
const token = this.roomMemberToken();
|
||||
if (token && this.showLobby) {
|
||||
// The meeting view must be shown before loading the appearance config
|
||||
this.showLobby = false;
|
||||
await this.configService.loadRoomsAppearanceConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.lobbyState = await this.lobbyService.initialize();
|
||||
this.prejoinReady = true;
|
||||
await this.lobbyService.initialize();
|
||||
this.isLobbyReady = true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing lobby state:', error);
|
||||
this.notificationService.showDialog({
|
||||
@ -255,79 +146,58 @@ export class MeetingComponent implements OnInit {
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
|
||||
// Clear meeting context when component is destroyed
|
||||
this.meetingContextService.clearContext();
|
||||
}
|
||||
|
||||
async submitAccessMeeting() {
|
||||
try {
|
||||
await this.lobbyService.submitAccess();
|
||||
// async onRoomConnected() {
|
||||
// try {
|
||||
// // Suscribirse solo para actualizar el estado de video pin
|
||||
// // Los participantes se actualizan automáticamente en MeetingContextService
|
||||
// combineLatest([
|
||||
// this.ovComponentsParticipantService.remoteParticipants$,
|
||||
// this.ovComponentsParticipantService.localParticipant$
|
||||
// ])
|
||||
// .pipe(takeUntil(this.destroy$))
|
||||
// .subscribe(() => {
|
||||
// this.updateVideoPinState();
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error('Error accessing meeting:', error);
|
||||
// }
|
||||
// }
|
||||
|
||||
// The meeting view must be shown before loading the appearance config,
|
||||
// as it contains theme information that might be applied immediately
|
||||
// when the meeting view is rendered
|
||||
this.showPrejoin = false;
|
||||
await this.configService.loadRoomsAppearanceConfig();
|
||||
onRoomCreated(lkRoom: Room) {
|
||||
// At this point, user has joined the meeting and MeetingContextService becomes the Single Source of Truth
|
||||
// MeetingContextService has been updated during lobby initialization with roomId, roomSecret, hasRecordings
|
||||
// All subsequent updates (hasRecordings, roomSecret, participants) go to MeetingContextService
|
||||
|
||||
combineLatest([
|
||||
this.ovComponentsParticipantService.remoteParticipants$,
|
||||
this.ovComponentsParticipantService.localParticipant$
|
||||
])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(([participants, local]) => {
|
||||
this.remoteParticipants.set(participants as CustomParticipantModel[]);
|
||||
this.localParticipant.set(local as CustomParticipantModel);
|
||||
// Store LiveKit room in context
|
||||
this.meetingContextService.setLkRoom(lkRoom);
|
||||
|
||||
// Update action handler context if provided
|
||||
if (this.actionHandler) {
|
||||
this.actionHandler.roomId = this.roomId;
|
||||
this.actionHandler.roomSecret = this.roomSecret;
|
||||
this.actionHandler.localParticipant = this.localParticipant();
|
||||
}
|
||||
|
||||
this.updateVideoPinState();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error accessing meeting:', error);
|
||||
}
|
||||
// Setup LK room event listeners
|
||||
this.eventHandlerService.setupRoomListeners(lkRoom);
|
||||
}
|
||||
|
||||
onRoomCreated(room: Room) {
|
||||
this.eventHandler.setupRoomListeners(room, {
|
||||
roomId: this.roomId,
|
||||
roomSecret: this.roomSecret,
|
||||
participantName: this.participantName,
|
||||
localParticipant: () => this.localParticipant(),
|
||||
remoteParticipants: () => this.remoteParticipants(),
|
||||
onHasRecordingsChanged: (hasRecordings) => {
|
||||
this.hasRecordings = hasRecordings;
|
||||
},
|
||||
onRoomSecretChanged: (secret) => {
|
||||
this.roomSecret = secret;
|
||||
},
|
||||
onParticipantRoleUpdated: () => {
|
||||
// Increment version to trigger reactivity in participant panel items
|
||||
this.participantsVersion.update((v) => v + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
// async leaveMeeting() {
|
||||
// await this.openviduService.disconnectRoom();
|
||||
// }
|
||||
|
||||
async leaveMeeting() {
|
||||
await this.openviduService.disconnectRoom();
|
||||
}
|
||||
// async endMeeting() {
|
||||
// if (!this.participantService.isModerator()) return;
|
||||
|
||||
async endMeeting() {
|
||||
if (!this.participantService.isModerator()) return;
|
||||
// this.meetingContextService.setMeetingEndedBy('self');
|
||||
|
||||
this.eventHandler.setMeetingEndedByMe(true);
|
||||
|
||||
try {
|
||||
await this.meetingService.endMeeting(this.roomId);
|
||||
} catch (error) {
|
||||
console.error('Error ending meeting:', error);
|
||||
}
|
||||
}
|
||||
// try {
|
||||
// await this.meetingService.endMeeting(this.roomId()!);
|
||||
// } catch (error) {
|
||||
// console.error('Error ending meeting:', error);
|
||||
// }
|
||||
// }
|
||||
|
||||
async onViewRecordingsClicked() {
|
||||
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
|
||||
window.open(`/room/${this.roomId()}/recordings?secret=${this.roomSecret()}`, '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -335,107 +205,17 @@ export class MeetingComponent implements OnInit {
|
||||
* remote participants and local screen sharing state.
|
||||
*/
|
||||
protected updateVideoPinState(): void {
|
||||
if (!this.localParticipant) return;
|
||||
const localParticipant = this.localParticipant();
|
||||
if (!localParticipant) return;
|
||||
|
||||
const isSharing = this.localParticipant()?.isScreenShareEnabled;
|
||||
const isSharing = localParticipant.isScreenShareEnabled;
|
||||
|
||||
if (this.hasRemoteParticipants && isSharing) {
|
||||
if (this.hasRemoteParticipants() && isSharing) {
|
||||
// Pin the local screen share to appear bigger
|
||||
this.localParticipant()?.setVideoPinnedBySource(Track.Source.ScreenShare, true);
|
||||
localParticipant.setVideoPinnedBySource(Track.Source.ScreenShare, true);
|
||||
} else {
|
||||
// Unpin everything if no remote participants or not sharing
|
||||
this.localParticipant()?.setAllVideoPinned(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Event handler wrappers - delegates to actionHandler if provided, otherwise uses default implementation
|
||||
*/
|
||||
protected async handleKickParticipant(participant: CustomParticipantModel) {
|
||||
if (this.actionHandler) {
|
||||
await this.actionHandler.kickParticipant(participant);
|
||||
} else {
|
||||
// Default implementation
|
||||
if (!this.participantService.isModerator()) return;
|
||||
|
||||
try {
|
||||
await this.meetingService.kickParticipant(this.roomId, participant.identity);
|
||||
console.log('Participant kicked successfully');
|
||||
} catch (error) {
|
||||
console.error('Error kicking participant:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleMakeModerator(participant: CustomParticipantModel) {
|
||||
if (this.actionHandler) {
|
||||
await this.actionHandler.makeModerator(participant);
|
||||
} else {
|
||||
// Default implementation
|
||||
if (!this.participantService.isModerator()) return;
|
||||
|
||||
try {
|
||||
await this.meetingService.changeParticipantRole(
|
||||
this.roomId,
|
||||
participant.identity,
|
||||
MeetRoomMemberRole.MODERATOR
|
||||
);
|
||||
console.log('Moderator assigned successfully');
|
||||
} catch (error) {
|
||||
console.error('Error assigning moderator:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleUnmakeModerator(participant: CustomParticipantModel) {
|
||||
if (this.actionHandler) {
|
||||
await this.actionHandler.unmakeModerator(participant);
|
||||
} else {
|
||||
// Default implementation
|
||||
if (!this.participantService.isModerator()) return;
|
||||
|
||||
try {
|
||||
await this.meetingService.changeParticipantRole(
|
||||
this.roomId,
|
||||
participant.identity,
|
||||
MeetRoomMemberRole.SPEAKER
|
||||
);
|
||||
console.log('Moderator unassigned successfully');
|
||||
} catch (error) {
|
||||
console.error('Error unassigning moderator:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// private async handleCopyModeratorLink() {
|
||||
// if (this.actionHandler) {
|
||||
// await this.actionHandler.copyModeratorLink();
|
||||
// } else {
|
||||
// // Default implementation
|
||||
// try {
|
||||
// this.clipboard.copy(this.room!.moderatorUrl);
|
||||
// this.notificationService.showSnackbar('Moderator link copied to clipboard');
|
||||
|
||||
// console.log('Moderator link copied to clipboard');
|
||||
// } catch (error) {
|
||||
// console.error('Failed to copy moderator link:', error);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
protected async handleCopySpeakerLink() {
|
||||
if (this.actionHandler) {
|
||||
await this.actionHandler.copySpeakerLink();
|
||||
} else {
|
||||
// Default implementation
|
||||
try {
|
||||
const speakerLink = this.room!.speakerUrl;
|
||||
this.clipboard.copy(speakerLink);
|
||||
this.notificationService.showSnackbar('Speaker link copied to clipboard');
|
||||
console.log('Speaker link copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy speaker link:', error);
|
||||
}
|
||||
localParticipant.setAllVideoPinned(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
@ -8,11 +8,11 @@ import { MeetRecordingFilters, MeetRecordingInfo } from '@openvidu-meet/typings'
|
||||
import { ILogger, LoggerService } from 'openvidu-components-angular';
|
||||
import { RecordingListsComponent, RecordingTableAction } from '../../components';
|
||||
import {
|
||||
MeetingContextService,
|
||||
NavigationService,
|
||||
NotificationService,
|
||||
RecordingService,
|
||||
RoomMemberService,
|
||||
RoomService
|
||||
RoomMemberService
|
||||
} from '../../services';
|
||||
|
||||
@Component({
|
||||
@ -38,15 +38,15 @@ export class RoomRecordingsComponent implements OnInit {
|
||||
|
||||
protected log: ILogger;
|
||||
|
||||
constructor(
|
||||
protected loggerService: LoggerService,
|
||||
protected recordingService: RecordingService,
|
||||
protected roomService: RoomService,
|
||||
protected roomMemberService: RoomMemberService,
|
||||
protected notificationService: NotificationService,
|
||||
protected navigationService: NavigationService,
|
||||
protected route: ActivatedRoute
|
||||
) {
|
||||
protected readonly loggerService = inject(LoggerService);
|
||||
protected readonly recordingService = inject(RecordingService);
|
||||
protected readonly roomMemberService = inject(RoomMemberService);
|
||||
protected readonly notificationService = inject(NotificationService);
|
||||
protected readonly navigationService = inject(NavigationService);
|
||||
protected readonly meetingContextService = inject(MeetingContextService);
|
||||
protected readonly route = inject(ActivatedRoute);
|
||||
|
||||
constructor() {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - RoomRecordingsComponent');
|
||||
}
|
||||
|
||||
@ -76,8 +76,10 @@ export class RoomRecordingsComponent implements OnInit {
|
||||
|
||||
async goBackToRoom() {
|
||||
try {
|
||||
const roomSecret = this.meetingContextService.roomSecret();
|
||||
if (!roomSecret) throw new Error('Cannot navigate back to room: room secret is undefined');
|
||||
await this.navigationService.navigateTo(`/room/${this.roomId}`, {
|
||||
secret: this.roomService.getRoomSecret()
|
||||
secret: roomSecret
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.e('Error navigating back to room:', error);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { WebComponentProperty } from '@openvidu-meet/typings';
|
||||
import {
|
||||
checkRoomEditGuard,
|
||||
checkEditableRoomGuard,
|
||||
checkUserAuthenticatedGuard,
|
||||
checkUserNotAuthenticatedGuard,
|
||||
extractRecordingQueryParamsGuard,
|
||||
@ -89,7 +89,7 @@ export const baseRoutes: Routes = [
|
||||
{
|
||||
path: 'rooms/:roomId/edit',
|
||||
component: RoomWizardComponent,
|
||||
canActivate: [checkRoomEditGuard]
|
||||
canActivate: [checkEditableRoomGuard]
|
||||
},
|
||||
{
|
||||
path: 'recordings',
|
||||
|
||||
@ -7,8 +7,8 @@ export * from './room.service';
|
||||
export * from './room-member.service';
|
||||
export * from './meeting/meeting.service';
|
||||
export * from './meeting/meeting-lobby.service';
|
||||
export * from './meeting/meeting-plugin-manager.service';
|
||||
export * from './meeting/meeting-event-handler.service';
|
||||
export * from './meeting/meeting-context.service';
|
||||
export * from './feature-configuration.service';
|
||||
export * from './recording.service';
|
||||
export * from './webcomponent-manager.service';
|
||||
|
||||
@ -0,0 +1,234 @@
|
||||
import { Injectable, signal, computed, inject, DestroyRef } from '@angular/core';
|
||||
import { MeetRoom } from 'node_modules/@openvidu-meet/typings/dist/room';
|
||||
import { Room, ParticipantService, ViewportService } from 'openvidu-components-angular';
|
||||
import { CustomParticipantModel } from '../../models';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FeatureConfigurationService } from '../feature-configuration.service';
|
||||
|
||||
/**
|
||||
* Central service for managing meeting context and state during the MEETING PHASE.
|
||||
*
|
||||
* This service is the SINGLE SOURCE OF TRUTH for all meeting-related state once a participant has joined.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MeetingContextService {
|
||||
private readonly ovParticipantService = inject(ParticipantService);
|
||||
private readonly featureConfigService = inject(FeatureConfigurationService);
|
||||
private readonly viewportService = inject(ViewportService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private isSubscribed = false;
|
||||
private readonly _meetRoom = signal<MeetRoom | undefined>(undefined);
|
||||
private readonly _lkRoom = signal<Room | undefined>(undefined);
|
||||
private readonly _roomId = signal<string | undefined>(undefined);
|
||||
private readonly _meetingUrl = signal<string>('');
|
||||
private readonly _e2eeKey = signal<string>('');
|
||||
private readonly _roomSecret = signal<string | undefined>(undefined);
|
||||
private readonly _hasRecordings = signal<boolean>(false);
|
||||
private readonly _meetingEndedBy = signal<'self' | 'other' | null>(null);
|
||||
private readonly _participantsVersion = signal<number>(0);
|
||||
private readonly _localParticipant = signal<CustomParticipantModel | undefined>(undefined);
|
||||
private readonly _remoteParticipants = signal<CustomParticipantModel[]>([]);
|
||||
|
||||
/**
|
||||
* Readonly signal for the current room
|
||||
*/
|
||||
readonly meetRoom = this._meetRoom.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the current room ID
|
||||
*/
|
||||
readonly roomId = this._roomId.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the current lk room
|
||||
*/
|
||||
readonly lkRoom = this._lkRoom.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the meeting URL
|
||||
*/
|
||||
readonly meetingUrl = this._meetingUrl.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the E2EE key
|
||||
*/
|
||||
readonly e2eeKey = this._e2eeKey.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the room secret
|
||||
*/
|
||||
readonly roomSecret = this._roomSecret.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for whether the room has recordings
|
||||
*/
|
||||
readonly hasRecordings = this._hasRecordings.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for who ended the meeting ('self', 'other', or null)
|
||||
*/
|
||||
readonly meetingEndedBy = this._meetingEndedBy.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for participants version (increments on role changes)
|
||||
* Used to trigger reactivity when participant properties change without array reference changes
|
||||
*/
|
||||
readonly participantsVersion = this._participantsVersion.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the local participant
|
||||
*/
|
||||
readonly localParticipant = this._localParticipant.asReadonly();
|
||||
|
||||
/**
|
||||
* Readonly signal for the remote participants
|
||||
*/
|
||||
readonly remoteParticipants = this._remoteParticipants.asReadonly();
|
||||
|
||||
/**
|
||||
* Computed signal that combines local and remote participants
|
||||
*/
|
||||
readonly allParticipants = computed(() => {
|
||||
const local = this._localParticipant();
|
||||
const remotes = this._remoteParticipants();
|
||||
return local ? [local, ...remotes] : remotes;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal for whether the current user can moderate the room
|
||||
* Derived from FeatureConfigurationService
|
||||
*/
|
||||
readonly canModerateRoom = computed(() => this.featureConfigService.features().canModerateRoom);
|
||||
|
||||
/**
|
||||
* Computed signal for whether the device is mobile
|
||||
* Derived from ViewportService for responsive UI
|
||||
*/
|
||||
readonly isMobile = computed(() => this.viewportService.isMobile());
|
||||
|
||||
/**
|
||||
* Sets the room ID in context
|
||||
* @param roomId The room ID
|
||||
*/
|
||||
setRoomId(roomId: string): void {
|
||||
this._roomId.set(roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the meeting context with meet room information
|
||||
* @param room The room object
|
||||
*/
|
||||
setMeetRoom(room: MeetRoom): void {
|
||||
this._meetRoom.set(room);
|
||||
this.setRoomId(room.roomId);
|
||||
this.setMeetingUrl(room.roomId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the LiveKit Room instance in context
|
||||
* @param room
|
||||
*/
|
||||
setLkRoom(room: Room) {
|
||||
this._lkRoom.set(room);
|
||||
// Subscribe to participants only once when lkRoom is set
|
||||
if (!this.isSubscribed) {
|
||||
this.subscribeToParticipants();
|
||||
this.isSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local and remote participants from the OpenVidu Components ParticipantService
|
||||
*/
|
||||
protected subscribeToParticipants(): void {
|
||||
this.ovParticipantService.localParticipant$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((participant) => {
|
||||
this._localParticipant.set(participant as CustomParticipantModel);
|
||||
});
|
||||
|
||||
this.ovParticipantService.remoteParticipants$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((participants) => {
|
||||
this._remoteParticipants.set(participants as CustomParticipantModel[]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the meeting URL based on room ID
|
||||
* @param roomId The room ID
|
||||
*/
|
||||
private setMeetingUrl(roomId: string): void {
|
||||
const hostname = window.location.origin.replace('http://', '').replace('https://', '');
|
||||
const meetingUrl = roomId ? `${hostname}/room/${roomId}` : '';
|
||||
this._meetingUrl.set(meetingUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the E2EE key in context
|
||||
* @param key The E2EE key
|
||||
*/
|
||||
setE2eeKey(key: string): void {
|
||||
this._e2eeKey.set(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether E2EE is enabled (has a key set)
|
||||
* @returns true if E2EE is enabled, false otherwise
|
||||
*/
|
||||
isE2eeEnabled(): boolean {
|
||||
return this._e2eeKey().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the room secret in context
|
||||
* @param secret The room secret
|
||||
*/
|
||||
setRoomSecret(secret: string): void {
|
||||
this._roomSecret.set(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates whether the room has recordings
|
||||
* @param hasRecordings True if recordings exist
|
||||
*/
|
||||
setHasRecordings(hasRecordings: boolean): void {
|
||||
this._hasRecordings.set(hasRecordings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets who ended the meeting
|
||||
* @param by 'self' if ended by this user, 'other' if ended by someone else
|
||||
*/
|
||||
setMeetingEndedBy(by: 'self' | 'other' | null): void {
|
||||
this._meetingEndedBy.set(by);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the participants version counter
|
||||
* Used to trigger reactivity when participant properties (like role) change
|
||||
*/
|
||||
incrementParticipantsVersion(): void {
|
||||
this._participantsVersion.update((v) => v + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the meeting context
|
||||
*/
|
||||
clearContext(): void {
|
||||
this._meetRoom.set(undefined);
|
||||
this._lkRoom.set(undefined);
|
||||
this._roomId.set(undefined);
|
||||
this._meetingUrl.set('');
|
||||
this._e2eeKey.set('');
|
||||
this._roomSecret.set(undefined);
|
||||
this._hasRecordings.set(false);
|
||||
this._meetingEndedBy.set(null);
|
||||
this._participantsVersion.set(0);
|
||||
this._localParticipant.set(undefined);
|
||||
this._remoteParticipants.set([]);
|
||||
this.isSubscribed = false;
|
||||
}
|
||||
}
|
||||
@ -21,10 +21,10 @@ import {
|
||||
import { CustomParticipantModel } from '../../models';
|
||||
import {
|
||||
FeatureConfigurationService,
|
||||
MeetingContextService,
|
||||
NavigationService,
|
||||
RecordingService,
|
||||
RoomMemberService,
|
||||
RoomService,
|
||||
SessionStorageService,
|
||||
TokenStorageService,
|
||||
WebComponentManagerService
|
||||
@ -33,39 +33,20 @@ import {
|
||||
/**
|
||||
* Service that handles all LiveKit/OpenVidu room events.
|
||||
*
|
||||
* This service encapsulates all event handling logic previously in MeetingComponent,
|
||||
* providing a clean separation of concerns and making the component more maintainable.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Setup and manage room event listeners
|
||||
* - Handle data received events (recording stopped, config updates, role changes)
|
||||
* - Handle participant lifecycle events (connected, left)
|
||||
* - Handle recording events (start, stop)
|
||||
* - Map technical reasons to user-friendly reasons
|
||||
* - Manage meeting ended state
|
||||
* - Navigate to disconnected page with appropriate reason
|
||||
*
|
||||
* Benefits:
|
||||
* - Reduces MeetingComponent size by ~200 lines
|
||||
* - All event logic in one place (easier to test and maintain)
|
||||
* - Clear API for event handling
|
||||
* - Reusable across different components if needed
|
||||
* This service encapsulates all event handling logic and updates the MeetingContextService
|
||||
* as the single source of truth for meeting state.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MeetingEventHandlerService {
|
||||
// Injected services
|
||||
protected meetingContext = inject(MeetingContextService);
|
||||
protected featureConfService = inject(FeatureConfigurationService);
|
||||
protected recordingService = inject(RecordingService);
|
||||
protected roomMemberService = inject(RoomMemberService);
|
||||
protected roomService = inject(RoomService);
|
||||
protected sessionStorageService = inject(SessionStorageService);
|
||||
protected tokenStorageService = inject(TokenStorageService);
|
||||
protected wcManagerService = inject(WebComponentManagerService);
|
||||
protected navigationService = inject(NavigationService);
|
||||
|
||||
// Internal state
|
||||
private meetingEndedByMe = false;
|
||||
|
||||
// ============================================
|
||||
// PUBLIC METHODS - Room Event Handlers
|
||||
// ============================================
|
||||
@ -75,21 +56,16 @@ export class MeetingEventHandlerService {
|
||||
* This is the main entry point for room event handling.
|
||||
*
|
||||
* @param room The LiveKit Room instance
|
||||
* @param context Context object containing all necessary data and callbacks
|
||||
*/
|
||||
setupRoomListeners(
|
||||
room: Room,
|
||||
context: {
|
||||
roomId: string;
|
||||
roomSecret: string;
|
||||
participantName: string;
|
||||
localParticipant: () => CustomParticipantModel | undefined;
|
||||
remoteParticipants: () => CustomParticipantModel[];
|
||||
onHasRecordingsChanged: (hasRecordings: boolean) => void;
|
||||
onRoomSecretChanged: (secret: string) => void;
|
||||
onParticipantRoleUpdated?: () => void;
|
||||
}
|
||||
): void {
|
||||
setupRoomListeners(room: Room): void {
|
||||
this.setupDataReceivedListener(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the DataReceived event listener for handling room signals
|
||||
* @param room The LiveKit Room instance
|
||||
*/
|
||||
private setupDataReceivedListener(room: Room): void {
|
||||
room.on(
|
||||
RoomEvent.DataReceived,
|
||||
async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => {
|
||||
@ -109,24 +85,17 @@ export class MeetingEventHandlerService {
|
||||
|
||||
switch (topic) {
|
||||
case 'recordingStopped':
|
||||
// Notify that recordings are now available
|
||||
context.onHasRecordingsChanged(true);
|
||||
// Update hasRecordings in MeetingContextService
|
||||
this.meetingContext.setHasRecordings(true);
|
||||
break;
|
||||
|
||||
case MeetSignalType.MEET_ROOM_CONFIG_UPDATED:
|
||||
await this.handleRoomConfigUpdated(event, context.roomId, context.roomSecret);
|
||||
// Room cannot be updated if a meeting is ongoing
|
||||
// await this.handleRoomConfigUpdated(event);
|
||||
break;
|
||||
|
||||
case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED:
|
||||
await this.handleParticipantRoleUpdated(
|
||||
event,
|
||||
context.roomId,
|
||||
context.participantName,
|
||||
context.localParticipant,
|
||||
context.remoteParticipants,
|
||||
context.onRoomSecretChanged,
|
||||
context.onParticipantRoleUpdated
|
||||
);
|
||||
await this.handleParticipantRoleUpdated(event);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
@ -170,7 +139,8 @@ export class MeetingEventHandlerService {
|
||||
let leftReason = this.mapLeftReason(event.reason);
|
||||
|
||||
// If meeting was ended by this user, update reason
|
||||
if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) {
|
||||
const meetingEndedBy = this.meetingContext.meetingEndedBy();
|
||||
if (leftReason === LeftEventReason.MEETING_ENDED && meetingEndedBy === 'self') {
|
||||
leftReason = LeftEventReason.MEETING_ENDED_BY_SELF;
|
||||
}
|
||||
|
||||
@ -236,16 +206,6 @@ export class MeetingEventHandlerService {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the "meeting ended by me" flag.
|
||||
* This is used to differentiate between meeting ended by this user vs ended by someone else.
|
||||
*
|
||||
* @param value True if this user ended the meeting
|
||||
*/
|
||||
setMeetingEndedByMe(value: boolean): void {
|
||||
this.meetingEndedByMe = value;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRIVATE METHODS - Event Handlers
|
||||
// ============================================
|
||||
@ -253,12 +213,9 @@ export class MeetingEventHandlerService {
|
||||
/**
|
||||
* Handles room config updated event.
|
||||
* Updates feature config and refreshes room member token if needed.
|
||||
* Obtains roomId and roomSecret from MeetingContextService.
|
||||
*/
|
||||
private async handleRoomConfigUpdated(
|
||||
event: MeetRoomConfigUpdatedPayload,
|
||||
roomId: string,
|
||||
roomSecret: string
|
||||
): Promise<void> {
|
||||
private async handleRoomConfigUpdated(event: MeetRoomConfigUpdatedPayload): Promise<void> {
|
||||
const { config } = event;
|
||||
|
||||
// Update feature configuration
|
||||
@ -267,8 +224,16 @@ export class MeetingEventHandlerService {
|
||||
// Refresh room member token if recording is enabled
|
||||
if (config.recording.enabled) {
|
||||
try {
|
||||
const roomId = this.meetingContext.roomId();
|
||||
const roomSecret = this.meetingContext.roomSecret();
|
||||
const participantName = this.roomMemberService.getParticipantName();
|
||||
const participantIdentity = this.roomMemberService.getParticipantIdentity();
|
||||
|
||||
if (!roomId || !roomSecret) {
|
||||
console.error('Room ID or secret not available for token refresh');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.roomMemberService.generateToken(roomId, {
|
||||
secret: roomSecret,
|
||||
grantJoinMeetingPermission: true,
|
||||
@ -284,26 +249,21 @@ export class MeetingEventHandlerService {
|
||||
/**
|
||||
* Handles participant role updated event.
|
||||
* Updates local or remote participant role and refreshes room member token if needed.
|
||||
* Obtains all necessary data from MeetingContextService.
|
||||
*/
|
||||
private async handleParticipantRoleUpdated(
|
||||
event: MeetParticipantRoleUpdatedPayload,
|
||||
roomId: string,
|
||||
participantName: string,
|
||||
localParticipant: () => CustomParticipantModel | undefined,
|
||||
remoteParticipants: () => CustomParticipantModel[],
|
||||
onRoomSecretChanged: (secret: string) => void,
|
||||
onParticipantRoleUpdated?: () => void
|
||||
): Promise<void> {
|
||||
private async handleParticipantRoleUpdated(event: MeetParticipantRoleUpdatedPayload): Promise<void> {
|
||||
const { participantIdentity, newRole, secret } = event;
|
||||
const local = localParticipant();
|
||||
const roomId = this.meetingContext.roomId();
|
||||
const local = this.meetingContext.localParticipant();
|
||||
const participantName = this.roomMemberService.getParticipantName();
|
||||
|
||||
// Check if the role update is for the local participant
|
||||
if (local && participantIdentity === local.identity) {
|
||||
if (!secret) return;
|
||||
if (!secret || !roomId) return;
|
||||
|
||||
// Update room secret
|
||||
onRoomSecretChanged(secret);
|
||||
this.roomService.setRoomSecret(secret, false);
|
||||
// Update room secret in context
|
||||
this.meetingContext.setRoomSecret(secret);
|
||||
this.sessionStorageService.setRoomSecret(secret);
|
||||
|
||||
try {
|
||||
// Refresh participant token with new role
|
||||
@ -318,19 +278,20 @@ export class MeetingEventHandlerService {
|
||||
local.meetRole = newRole;
|
||||
console.log(`You have been assigned the role of ${newRole}`);
|
||||
|
||||
// Notify component that participant role was updated
|
||||
onParticipantRoleUpdated?.();
|
||||
// Increment version to trigger reactivity
|
||||
this.meetingContext.incrementParticipantsVersion();
|
||||
} catch (error) {
|
||||
console.error('Error refreshing room member token:', error);
|
||||
}
|
||||
} else {
|
||||
// Update remote participant role
|
||||
const participant = remoteParticipants().find((p) => p.identity === participantIdentity);
|
||||
const remoteParticipants = this.meetingContext.remoteParticipants();
|
||||
const participant = remoteParticipants.find((p) => p.identity === participantIdentity);
|
||||
if (participant) {
|
||||
participant.meetRole = newRole;
|
||||
|
||||
// Notify component that participant role was updated
|
||||
onParticipantRoleUpdated?.();
|
||||
// Increment version to trigger reactivity
|
||||
this.meetingContext.incrementParticipantsVersion();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { MeetRoomStatus } from '@openvidu-meet/typings';
|
||||
import {
|
||||
AppDataService,
|
||||
AuthService,
|
||||
MeetingContextService,
|
||||
MeetingService,
|
||||
NavigationService,
|
||||
RecordingService,
|
||||
RoomMemberService,
|
||||
@ -13,104 +15,182 @@ import {
|
||||
} from '..';
|
||||
import { ErrorReason } from '../../models';
|
||||
import { LobbyState } from '../../models/lobby.model';
|
||||
import { LoggerService } from 'openvidu-components-angular';
|
||||
|
||||
/**
|
||||
* Service that manages the meeting lobby state and operations.
|
||||
* Service that manages the meeting lobby phase state and operations.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Initialize and maintain lobby state
|
||||
* - Validate participant information
|
||||
* - Check for recordings availability
|
||||
* - Handle navigation (back button, recordings)
|
||||
* This service is ONLY responsible for the LOBBY PHASE - the period before a participant joins the meeting.
|
||||
*
|
||||
* This service coordinates multiple domain services to provide
|
||||
* a simplified interface for the MeetingComponent.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MeetingLobbyService {
|
||||
private state: LobbyState = {
|
||||
roomId: '',
|
||||
roomSecret: '',
|
||||
/**
|
||||
* Reactive signal for lobby state.
|
||||
* This state is only relevant during the lobby phase.
|
||||
*/
|
||||
private readonly _state = signal<LobbyState>({
|
||||
roomId: undefined,
|
||||
roomClosed: false,
|
||||
hasRecordings: false,
|
||||
showRecordingCard: false,
|
||||
showBackButton: true,
|
||||
backButtonText: 'Back',
|
||||
isE2EEEnabled: false,
|
||||
hasRoomE2EEEnabled: false,
|
||||
participantForm: new FormGroup({
|
||||
name: new FormControl('', [Validators.required]),
|
||||
e2eeKey: new FormControl('')
|
||||
}),
|
||||
roomMemberToken: ''
|
||||
};
|
||||
roomMemberToken: undefined
|
||||
});
|
||||
|
||||
protected roomService: RoomService = inject(RoomService);
|
||||
protected meetingContextService: MeetingContextService = inject(MeetingContextService);
|
||||
protected meetingService: MeetingService = inject(MeetingService);
|
||||
protected recordingService: RecordingService = inject(RecordingService);
|
||||
protected authService: AuthService = inject(AuthService);
|
||||
protected roomMemberService: RoomMemberService = inject(RoomMemberService);
|
||||
protected navigationService: NavigationService = inject(NavigationService);
|
||||
protected appDataService: AppDataService = inject(AppDataService);
|
||||
protected wcManagerService: WebComponentManagerService = inject(WebComponentManagerService);
|
||||
protected loggerService = inject(LoggerService);
|
||||
protected log = this.loggerService.get('OpenVidu Meet - MeetingLobbyService');
|
||||
protected route: ActivatedRoute = inject(ActivatedRoute);
|
||||
|
||||
/**
|
||||
* Gets the current lobby state
|
||||
* Readonly signal for lobby state - components can use computed() with this
|
||||
*/
|
||||
get lobbyState(): LobbyState {
|
||||
return this.state;
|
||||
}
|
||||
readonly state = this._state.asReadonly();
|
||||
|
||||
set participantName(name: string) {
|
||||
this.state.participantForm.get('name')?.setValue(name);
|
||||
}
|
||||
/**
|
||||
* Computed signal for meeting URL derived from MeetingContextService
|
||||
* This ensures a single source of truth for the meeting URL
|
||||
*/
|
||||
readonly meetingUrl = computed(() => this.meetingContextService.meetingUrl());
|
||||
|
||||
get participantName(): string {
|
||||
const { valid, value } = this.state.participantForm;
|
||||
/**
|
||||
* Computed signal for whether the current user can moderate the room
|
||||
* Derived from MeetingContextService
|
||||
*/
|
||||
readonly canModerateRoom = computed(() => this.meetingContextService.canModerateRoom());
|
||||
|
||||
/**
|
||||
* Computed signal for participant name - optimized to avoid repeated form access
|
||||
*/
|
||||
readonly participantName = computed(() => {
|
||||
const { valid, value } = this._state().participantForm;
|
||||
if (!valid || !value.name?.trim()) {
|
||||
return '';
|
||||
}
|
||||
return value.name.trim();
|
||||
}
|
||||
});
|
||||
|
||||
set e2eeKey(key: string) {
|
||||
this.state.participantForm.get('e2eeKey')?.setValue(key);
|
||||
}
|
||||
|
||||
get e2eeKey(): string {
|
||||
const { valid, value } = this.state.participantForm;
|
||||
/**
|
||||
* Computed signal for E2EE key - optimized to avoid repeated form access
|
||||
*/
|
||||
readonly e2eeKeyValue = computed(() => {
|
||||
const { valid, value } = this._state().participantForm;
|
||||
if (!valid || !value.e2eeKey?.trim()) {
|
||||
return '';
|
||||
}
|
||||
return value.e2eeKey.trim();
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed signal for room member token
|
||||
*/
|
||||
readonly roomMemberToken = computed(() => this._state().roomMemberToken);
|
||||
|
||||
/**
|
||||
* Computed signal for room ID
|
||||
*/
|
||||
readonly roomId = computed(() => this._state().roomId);
|
||||
|
||||
/**
|
||||
* Computed signal for room secret.
|
||||
* Obtained from MeetingContextService
|
||||
*/
|
||||
readonly roomSecret = computed(() => this.meetingContextService.roomSecret());
|
||||
|
||||
/**
|
||||
* Computed signal for room name
|
||||
*/
|
||||
readonly roomName = computed(() => this._state().room?.roomName);
|
||||
|
||||
/**
|
||||
* Computed signal for has recordings.
|
||||
* Obtained from MeetingContextService
|
||||
*/
|
||||
readonly hasRecordings = computed(() => this.meetingContextService.hasRecordings());
|
||||
|
||||
/**
|
||||
* Setter for participant name
|
||||
*/
|
||||
setParticipantName(name: string): void {
|
||||
this._state().participantForm.get('name')?.setValue(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setter for E2EE key
|
||||
*/
|
||||
setE2eeKey(key: string): void {
|
||||
this._state().participantForm.get('e2eeKey')?.setValue(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the lobby state by fetching room data and configuring UI
|
||||
*/
|
||||
async initialize(): Promise<LobbyState> {
|
||||
this.state.roomId = this.roomService.getRoomId();
|
||||
this.state.roomSecret = this.roomService.getRoomSecret();
|
||||
this.state.room = await this.roomService.getRoom(this.state.roomId);
|
||||
this.state.roomClosed = this.state.room.status === MeetRoomStatus.CLOSED;
|
||||
this.state.isE2EEEnabled = this.state.room.config.e2ee.enabled;
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
const roomId = this.meetingContextService.roomId();
|
||||
if (!roomId) throw new Error('Room ID is not set in Meeting Context');
|
||||
|
||||
// If E2EE is enabled, require e2eeKey
|
||||
if (this.state.isE2EEEnabled) {
|
||||
this.state.participantForm.get('e2eeKey')?.setValidators([Validators.required]);
|
||||
this.e2eeKey = this.roomService.getE2EEKey();
|
||||
this._state.update((state) => ({ ...state, roomId }));
|
||||
|
||||
if (this.e2eeKey) {
|
||||
// when e2eeKey is already set (e.g., from URL or webcomponent), populate and disable field
|
||||
this.state.participantForm.get('e2eeKey')?.disable();
|
||||
const [room] = await Promise.all([
|
||||
this.roomService.getRoom(roomId),
|
||||
this.setBackButtonText(),
|
||||
this.checkForRecordings(),
|
||||
this.initializeParticipantName()
|
||||
]);
|
||||
|
||||
const roomClosed = room.status === MeetRoomStatus.CLOSED;
|
||||
const hasRoomE2EEEnabled = room.config?.e2ee?.enabled || false;
|
||||
|
||||
this._state.update((state) => ({
|
||||
...state,
|
||||
room,
|
||||
roomClosed,
|
||||
hasRoomE2EEEnabled
|
||||
}));
|
||||
|
||||
this.meetingContextService.setMeetRoom(room);
|
||||
|
||||
if (hasRoomE2EEEnabled) {
|
||||
// If E2EE is enabled, make the e2eeKey form control required
|
||||
const form = this._state().participantForm;
|
||||
form.get('e2eeKey')?.setValidators([Validators.required]);
|
||||
const contextE2eeKey = this.meetingContextService.e2eeKey();
|
||||
if (contextE2eeKey) {
|
||||
this.setE2eeKey(contextE2eeKey);
|
||||
// fill the e2eeKey form control if already set in context (e.g., from URL param)
|
||||
form.get('e2eeKey')?.disable();
|
||||
}
|
||||
form.get('e2eeKey')?.updateValueAndValidity();
|
||||
}
|
||||
this.state.participantForm.get('e2eeKey')?.updateValueAndValidity();
|
||||
} catch (error) {
|
||||
this.clearLobbyState();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.setBackButtonText();
|
||||
await this.checkForRecordings();
|
||||
await this.initializeParticipantName();
|
||||
|
||||
return this.state;
|
||||
/**
|
||||
* Copies the meeting speaker link to clipboard
|
||||
*/
|
||||
copyMeetingSpeakerLink() {
|
||||
const { room } = this.state();
|
||||
if (room) {
|
||||
this.meetingService.copyMeetingSpeakerLink(room);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,7 +217,7 @@ export class MeetingLobbyService {
|
||||
await this.navigationService.navigateTo('/rooms');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling back navigation:', error);
|
||||
this.log.e('Error handling back navigation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,36 +226,43 @@ export class MeetingLobbyService {
|
||||
*/
|
||||
async goToRecordings(): Promise<void> {
|
||||
try {
|
||||
await this.navigationService.navigateTo(`room/${this.state.roomId}/recordings`, {
|
||||
secret: this.state.roomSecret
|
||||
const roomId = this._state().roomId;
|
||||
const roomSecret = this.meetingContextService.roomSecret();
|
||||
await this.navigationService.navigateTo(`room/${roomId}/recordings`, {
|
||||
secret: roomSecret
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error navigating to recordings:', error);
|
||||
this.log.e('Error navigating to recordings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async submitAccess(): Promise<void> {
|
||||
const sanitized = this.participantName.trim(); // remove leading/trailing spaces
|
||||
const sanitized = this.participantName().trim(); // remove leading/trailing spaces
|
||||
|
||||
if (!sanitized) {
|
||||
console.error('Participant form is invalid. Cannot access meeting.');
|
||||
this.log.e('Participant form is invalid. Cannot access meeting.');
|
||||
throw new Error('Participant form is invalid');
|
||||
}
|
||||
this.participantName = sanitized;
|
||||
this.setParticipantName(sanitized);
|
||||
|
||||
// For E2EE rooms, validate passkey
|
||||
if (this.state.isE2EEEnabled && !this.e2eeKey) {
|
||||
console.warn('E2EE key is required for encrypted rooms.');
|
||||
return;
|
||||
const { hasRoomE2EEEnabled, roomId } = this._state();
|
||||
if (hasRoomE2EEEnabled) {
|
||||
const e2eeKey = this.e2eeKeyValue();
|
||||
if (!e2eeKey) {
|
||||
this.log.w('E2EE key is required for encrypted rooms.');
|
||||
return;
|
||||
}
|
||||
this.meetingContextService.setE2eeKey(e2eeKey);
|
||||
}
|
||||
|
||||
await this.generateRoomMemberToken();
|
||||
await this.addParticipantNameToUrl();
|
||||
await this.roomService.loadRoomConfig(this.state.roomId);
|
||||
await Promise.all([
|
||||
this.generateRoomMemberToken(),
|
||||
this.addParticipantNameToUrl(),
|
||||
this.roomService.loadRoomConfig(roomId!)
|
||||
]);
|
||||
}
|
||||
|
||||
// Protected helper methods
|
||||
|
||||
/**
|
||||
* Sets the back button text based on the application mode and user role
|
||||
*/
|
||||
@ -185,12 +272,12 @@ export class MeetingLobbyService {
|
||||
const isAdmin = await this.authService.isAdmin();
|
||||
|
||||
if (isStandaloneMode && !redirection && !isAdmin) {
|
||||
this.state.showBackButton = false;
|
||||
this._state.update((state) => ({ ...state, showBackButton: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.showBackButton = true;
|
||||
this.state.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back';
|
||||
const backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back';
|
||||
this._state.update((state) => ({ ...state, showBackButton: true, backButtonText }));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -199,28 +286,38 @@ export class MeetingLobbyService {
|
||||
* If the user does not have sufficient permissions to list recordings,
|
||||
* the recordings card will be hidden (`showRecordingCard` will be set to `false`).
|
||||
*
|
||||
* If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`.
|
||||
* If recordings exist, stores in MeetingContextService and shows recording card UI.
|
||||
*/
|
||||
protected async checkForRecordings(): Promise<void> {
|
||||
try {
|
||||
const canRetrieveRecordings = this.roomMemberService.canRetrieveRecordings();
|
||||
|
||||
if (!canRetrieveRecordings) {
|
||||
this.state.showRecordingCard = false;
|
||||
this._state.update((state) => ({ ...state, showRecordingCard: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomId } = this._state();
|
||||
if (!roomId) throw new Error('Room ID is not set in lobby state');
|
||||
const { recordings } = await this.recordingService.listRecordings({
|
||||
maxItems: 1,
|
||||
roomId: this.state.roomId,
|
||||
roomId,
|
||||
fields: 'recordingId'
|
||||
});
|
||||
|
||||
this.state.hasRecordings = recordings.length > 0;
|
||||
this.state.showRecordingCard = this.state.hasRecordings;
|
||||
const hasRecordings = recordings.length > 0;
|
||||
|
||||
// Store in MeetingContextService (Single Source of Truth)
|
||||
this.meetingContextService.setHasRecordings(hasRecordings);
|
||||
|
||||
// Update only UI flag locally
|
||||
this._state.update((state) => ({
|
||||
...state,
|
||||
showRecordingCard: hasRecordings
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error checking for recordings:', error);
|
||||
this.state.showRecordingCard = false;
|
||||
this.log.e('Error checking for recordings:', error);
|
||||
this._state.update((state) => ({ ...state, showRecordingCard: false }));
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,7 +337,7 @@ export class MeetingLobbyService {
|
||||
const participantName = currentParticipantName || username;
|
||||
|
||||
if (participantName) {
|
||||
this.participantName = participantName;
|
||||
this.setParticipantName(participantName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -251,18 +348,22 @@ export class MeetingLobbyService {
|
||||
*/
|
||||
protected async generateRoomMemberToken() {
|
||||
try {
|
||||
this.state.roomMemberToken = await this.roomMemberService.generateToken(
|
||||
this.state.roomId,
|
||||
const roomId = this._state().roomId;
|
||||
const roomSecret = this.meetingContextService.roomSecret();
|
||||
const roomMemberToken = await this.roomMemberService.generateToken(
|
||||
roomId!,
|
||||
{
|
||||
secret: this.state.roomSecret,
|
||||
secret: roomSecret!,
|
||||
grantJoinMeetingPermission: true,
|
||||
participantName: this.participantName
|
||||
participantName: this.participantName()
|
||||
},
|
||||
this.e2eeKey
|
||||
this.e2eeKeyValue()
|
||||
);
|
||||
this.participantName = this.roomMemberService.getParticipantName()!;
|
||||
const updatedName = this.roomMemberService.getParticipantName()!;
|
||||
this.setParticipantName(updatedName);
|
||||
this._state.update((state) => ({ ...state, roomMemberToken }));
|
||||
} catch (error: any) {
|
||||
console.error('Error generating room member token:', error);
|
||||
this.log.e('Error generating room member token:', error);
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
// Invalid secret
|
||||
@ -289,7 +390,23 @@ export class MeetingLobbyService {
|
||||
*/
|
||||
protected async addParticipantNameToUrl() {
|
||||
await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, {
|
||||
'participant-name': this.participantName
|
||||
'participant-name': this.participantName()
|
||||
});
|
||||
}
|
||||
|
||||
protected clearLobbyState() {
|
||||
this._state.set({
|
||||
roomId: undefined,
|
||||
roomClosed: false,
|
||||
showRecordingCard: false,
|
||||
showBackButton: true,
|
||||
backButtonText: 'Back',
|
||||
hasRoomE2EEEnabled: false,
|
||||
participantForm: new FormGroup({
|
||||
name: new FormControl('', [Validators.required]),
|
||||
e2eeKey: new FormControl('')
|
||||
}),
|
||||
roomMemberToken: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
import { Inject, Injectable, Optional } from '@angular/core';
|
||||
import { MEETING_ACTION_HANDLER_TOKEN, MeetingActionHandler, ParticipantControls } from '../../customization';
|
||||
import { CustomParticipantModel, LobbyState } from '../../models';
|
||||
import { RoomMemberService } from '../room-member.service';
|
||||
|
||||
/**
|
||||
* Service that manages plugin inputs and configurations for the MeetingComponent.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Prepare input objects for toolbar plugins
|
||||
* - Prepare input objects for participant panel plugins
|
||||
* - Prepare input objects for layout plugins
|
||||
* - Prepare input objects for lobby plugin
|
||||
* - Calculate participant control visibility based on roles and permissions
|
||||
*
|
||||
* This service acts as a bridge between the MeetingComponent and the plugin components,
|
||||
* encapsulating the logic for determining what inputs each plugin should receive.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MeetingPluginManagerService {
|
||||
constructor(
|
||||
private roomMemberService: RoomMemberService,
|
||||
@Optional() @Inject(MEETING_ACTION_HANDLER_TOKEN) private actionHandler?: MeetingActionHandler
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the toolbar additional buttons plugin
|
||||
*/
|
||||
getToolbarAdditionalButtonsInputs(canModerateRoom: boolean, isMobile: boolean, onCopyLink: () => void) {
|
||||
return {
|
||||
showCopyLinkButton: canModerateRoom,
|
||||
showLeaveMenu: false,
|
||||
isMobile,
|
||||
copyLinkClickedFn: onCopyLink
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the toolbar leave button plugin
|
||||
*/
|
||||
getToolbarLeaveButtonInputs(
|
||||
canModerateRoom: boolean,
|
||||
isMobile: boolean,
|
||||
onLeave: () => Promise<void>,
|
||||
onEnd: () => Promise<void>
|
||||
) {
|
||||
return {
|
||||
showCopyLinkButton: false,
|
||||
showLeaveMenu: canModerateRoom,
|
||||
isMobile,
|
||||
leaveMeetingClickedFn: onLeave,
|
||||
endMeetingClickedFn: onEnd
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the participant panel "after local participant" plugin
|
||||
*/
|
||||
getParticipantPanelAfterLocalInputs(canModerateRoom: boolean, meetingUrl: string, onCopyLink: () => void) {
|
||||
return {
|
||||
showShareLink: canModerateRoom,
|
||||
meetingUrl,
|
||||
copyClickedFn: onCopyLink
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the layout component (CE or PRO)
|
||||
*
|
||||
* This method prepares all inputs needed by the layout component including:
|
||||
* - Additional elements component to be rendered inside the layout
|
||||
* - Inputs object to pass to the additional elements component
|
||||
*/
|
||||
getLayoutInputs(
|
||||
showOverlay: boolean,
|
||||
meetingUrl: string,
|
||||
onCopyLink: () => void,
|
||||
additionalElementsComponent?: any
|
||||
) {
|
||||
return {
|
||||
additionalElementsComponent,
|
||||
additionalElementsInputs: {
|
||||
showOverlay,
|
||||
meetingUrl,
|
||||
copyClickedFn: onCopyLink
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the participant panel item plugin
|
||||
*/
|
||||
getParticipantPanelItemInputs(
|
||||
participant: CustomParticipantModel,
|
||||
allParticipants: CustomParticipantModel[],
|
||||
onMakeModerator: (p: CustomParticipantModel) => void,
|
||||
onUnmakeModerator: (p: CustomParticipantModel) => void,
|
||||
onKick: (p: CustomParticipantModel) => void
|
||||
) {
|
||||
const controls = this.getParticipantControls(participant);
|
||||
|
||||
return {
|
||||
participant,
|
||||
allParticipants,
|
||||
showModeratorBadge: controls.showModeratorBadge,
|
||||
showModerationControls: controls.showModerationControls,
|
||||
showMakeModerator: controls.showMakeModerator,
|
||||
showUnmakeModerator: controls.showUnmakeModerator,
|
||||
showKickButton: controls.showKickButton,
|
||||
makeModeratorClickedFn: () => onMakeModerator(participant),
|
||||
unmakeModeratorClickedFn: () => onUnmakeModerator(participant),
|
||||
kickParticipantClickedFn: () => onKick(participant)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares inputs for the lobby plugin
|
||||
*/
|
||||
getLobbyInputs(
|
||||
lobbyState: LobbyState,
|
||||
hostname: string,
|
||||
canModerateRoom: boolean,
|
||||
onFormSubmit: () => void,
|
||||
onViewRecordings: () => void,
|
||||
onBack: () => void,
|
||||
onCopyLink: () => void
|
||||
) {
|
||||
const {
|
||||
room,
|
||||
roomId,
|
||||
roomClosed,
|
||||
showRecordingCard,
|
||||
showBackButton,
|
||||
backButtonText,
|
||||
isE2EEEnabled,
|
||||
participantForm
|
||||
} = lobbyState;
|
||||
const meetingUrl = `${hostname}/room/${roomId}`;
|
||||
const showShareLink = !roomClosed && canModerateRoom;
|
||||
|
||||
return {
|
||||
roomName: room?.roomName || 'Room',
|
||||
meetingUrl,
|
||||
roomClosed,
|
||||
showRecordingCard,
|
||||
showShareLink,
|
||||
showBackButton,
|
||||
backButtonText,
|
||||
isE2EEEnabled,
|
||||
participantForm,
|
||||
formSubmittedFn: onFormSubmit,
|
||||
viewRecordingsClickedFn: onViewRecordings,
|
||||
backClickedFn: onBack,
|
||||
copyLinkClickedFn: onCopyLink
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets participant controls based on action handler or default logic
|
||||
*/
|
||||
private getParticipantControls(participant: CustomParticipantModel): ParticipantControls {
|
||||
if (this.actionHandler) {
|
||||
return this.actionHandler.getParticipantControls(participant);
|
||||
}
|
||||
|
||||
// Default implementation
|
||||
return this.getDefaultParticipantControls(participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation for calculating participant control visibility.
|
||||
*
|
||||
* Rules:
|
||||
* - Only moderators can see moderation controls
|
||||
* - Local participant never sees controls on themselves
|
||||
* - A moderator who was promoted (not original) cannot remove the moderator role from original moderators
|
||||
* - A moderator who was promoted (not original) cannot kick original moderators
|
||||
* - The moderator badge is shown based on the current role, not original role
|
||||
*/
|
||||
protected getDefaultParticipantControls(participant: CustomParticipantModel): ParticipantControls {
|
||||
const isCurrentUser = participant.isLocal;
|
||||
const currentUserIsModerator = this.roomMemberService.isModerator();
|
||||
const participantIsModerator = participant.isModerator();
|
||||
const participantIsOriginalModerator = participant.isOriginalModerator();
|
||||
|
||||
// Calculate if current moderator can revoke the moderator role from the target participant
|
||||
// Only allow if target is not an original moderator
|
||||
const canRevokeModeratorRole =
|
||||
currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator;
|
||||
|
||||
// Calculate if current moderator can kick the target participant
|
||||
// Only allow if target is not an original moderator
|
||||
const canKickParticipant = currentUserIsModerator && !isCurrentUser && !participantIsOriginalModerator;
|
||||
|
||||
return {
|
||||
showModeratorBadge: participantIsModerator,
|
||||
showModerationControls: currentUserIsModerator && !isCurrentUser,
|
||||
showMakeModerator: currentUserIsModerator && !isCurrentUser && !participantIsModerator,
|
||||
showUnmakeModerator: canRevokeModeratorRole,
|
||||
showKickButton: canKickParticipant
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,30 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { LoggerService } from 'openvidu-components-angular';
|
||||
import { HttpService } from '..';
|
||||
import { HttpService } from '../http.service';
|
||||
import { MeetRoom } from 'node_modules/@openvidu-meet/typings/dist/room';
|
||||
import { NotificationService } from '../notification.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MeetingService {
|
||||
protected readonly MEETINGS_API = `${HttpService.INTERNAL_API_PATH_PREFIX}/meetings`;
|
||||
protected loggerService: LoggerService = inject(LoggerService);
|
||||
protected notificationService = inject(NotificationService);
|
||||
|
||||
protected log;
|
||||
protected httpService: HttpService = inject(HttpService);
|
||||
protected clipboard = inject(Clipboard);
|
||||
|
||||
constructor(
|
||||
protected loggerService: LoggerService,
|
||||
protected httpService: HttpService
|
||||
) {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - MeetingService');
|
||||
protected log = this.loggerService.get('OpenVidu Meet - MeetingService');
|
||||
|
||||
/**
|
||||
* Copies the meeting speaker link to the clipboard.
|
||||
*/
|
||||
copyMeetingSpeakerLink(room: MeetRoom): void {
|
||||
const speakerLink = room.speakerUrl;
|
||||
this.clipboard.copy(speakerLink);
|
||||
this.notificationService.showSnackbar('Speaker link copied to clipboard');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
MeetRoom,
|
||||
MeetRoomConfig,
|
||||
@ -11,8 +11,19 @@ import {
|
||||
MeetRoomStatus
|
||||
} from '@openvidu-meet/typings';
|
||||
import { LoggerService } from 'openvidu-components-angular';
|
||||
import { FeatureConfigurationService, HttpService, SessionStorageService } from '../services';
|
||||
import { FeatureConfigurationService, HttpService, MeetingContextService, SessionStorageService } from '../services';
|
||||
|
||||
/**
|
||||
* RoomService - Persistence Layer for Room Data
|
||||
*
|
||||
* This service acts as a PERSISTENCE LAYER for room-related data and CRUD operations.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Persist room data (roomId, roomSecret) in SessionStorage for page refresh/reload
|
||||
* - Automatically sync persisted data to MeetingContextService (Single Source of Truth)
|
||||
* - Provide HTTP API methods for room CRUD operations
|
||||
* - Load and update room configuration
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@ -23,8 +34,8 @@ export class RoomService {
|
||||
protected roomId: string = '';
|
||||
protected roomSecret: string = '';
|
||||
protected e2eeKey: string = '';
|
||||
|
||||
protected log;
|
||||
protected meetingContext = inject(MeetingContextService);
|
||||
|
||||
constructor(
|
||||
protected loggerService: LoggerService,
|
||||
@ -35,32 +46,47 @@ export class RoomService {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - RoomService');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the room ID in memory and automatically syncs to MeetingContextService.
|
||||
* This ensures persistence and reactivity across the application.
|
||||
*
|
||||
* @param roomId - The room identifier to store
|
||||
*/
|
||||
setRoomId(roomId: string) {
|
||||
this.roomId = roomId;
|
||||
// Auto-sync to MeetingContextService (Single Source of Truth for runtime)
|
||||
this.meetingContext.setRoomId(roomId);
|
||||
}
|
||||
|
||||
getRoomId(): string {
|
||||
return this.roomId;
|
||||
/**
|
||||
* Loads persisted room state from internal storage to MeetingContextService.
|
||||
* Should be called during application initialization to restore state after page reload.
|
||||
*
|
||||
* This method transfers data from RoomService → MeetingContextService,
|
||||
* making it available as reactive signals throughout the application.
|
||||
*/
|
||||
loadPersistedStateToContext(): void {
|
||||
if (this.roomId) {
|
||||
this.meetingContext.setRoomId(this.roomId);
|
||||
}
|
||||
if (this.roomSecret) {
|
||||
this.meetingContext.setRoomSecret(this.roomSecret);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the room secret in memory, session storage, and automatically syncs to MeetingContextService.
|
||||
*
|
||||
* @param secret - The room secret to store
|
||||
* @param updateStorage - Whether to persist in SessionStorage (default: true)
|
||||
*/
|
||||
setRoomSecret(secret: string, updateStorage = true) {
|
||||
this.roomSecret = secret;
|
||||
if (updateStorage) {
|
||||
this.sessionStorageService.setRoomSecret(secret);
|
||||
}
|
||||
}
|
||||
|
||||
getRoomSecret(): string {
|
||||
return this.roomSecret;
|
||||
}
|
||||
|
||||
setE2EEKey(e2eeKey: string) {
|
||||
this.e2eeKey = e2eeKey;
|
||||
this.sessionStorageService.setE2EEKey(e2eeKey);
|
||||
}
|
||||
|
||||
getE2EEKey(): string {
|
||||
return this.e2eeKey;
|
||||
// Auto-sync to MeetingContextService (Single Source of Truth for runtime)
|
||||
this.meetingContext.setRoomSecret(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -211,7 +237,7 @@ export class RoomService {
|
||||
try {
|
||||
const config = await this.getRoomConfig(roomId);
|
||||
this.featureConfService.setRoomConfig(config);
|
||||
console.log('Room config loaded:', config);
|
||||
this.log.d('Room config loaded:', config);
|
||||
} catch (error) {
|
||||
this.log.e('Error loading room config', error);
|
||||
throw new Error('Failed to load room config');
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
WebComponentCommand,
|
||||
WebComponentEvent,
|
||||
@ -6,7 +6,7 @@ import {
|
||||
WebComponentOutboundEventMessage
|
||||
} from '@openvidu-meet/typings';
|
||||
import { LoggerService, OpenViduService } from 'openvidu-components-angular';
|
||||
import { MeetingService, RoomMemberService, RoomService } from '../services';
|
||||
import { MeetingContextService, MeetingService, RoomMemberService } from '../services';
|
||||
|
||||
/**
|
||||
* Service to manage the commands from OpenVidu Meet WebComponent/Iframe.
|
||||
@ -23,14 +23,13 @@ export class WebComponentManagerService {
|
||||
protected boundHandleMessage: (event: MessageEvent) => Promise<void>;
|
||||
|
||||
protected log;
|
||||
protected readonly meetingContextService = inject(MeetingContextService);
|
||||
protected readonly roomMemberService = inject(RoomMemberService);
|
||||
protected readonly openviduService = inject(OpenViduService);
|
||||
protected readonly meetingService = inject(MeetingService);
|
||||
protected readonly loggerService = inject(LoggerService);
|
||||
|
||||
constructor(
|
||||
protected loggerService: LoggerService,
|
||||
protected roomMemberService: RoomMemberService,
|
||||
protected openviduService: OpenViduService,
|
||||
protected roomService: RoomService,
|
||||
protected meetingService: MeetingService
|
||||
) {
|
||||
constructor() {
|
||||
this.log = this.loggerService.get('OpenVidu Meet - WebComponentManagerService');
|
||||
this.boundHandleMessage = this.handleMessage.bind(this);
|
||||
}
|
||||
@ -122,7 +121,8 @@ export class WebComponentManagerService {
|
||||
|
||||
try {
|
||||
this.log.d('Ending meeting...');
|
||||
const roomId = this.roomService.getRoomId();
|
||||
const roomId = this.meetingContextService.roomId();
|
||||
if (!roomId) throw new Error('Room ID is undefined while trying to end meeting');
|
||||
await this.meetingService.endMeeting(roomId);
|
||||
} catch (error) {
|
||||
this.log.e('Error ending meeting:', error);
|
||||
@ -148,7 +148,8 @@ export class WebComponentManagerService {
|
||||
|
||||
try {
|
||||
this.log.d(`Kicking participant '${participantIdentity}' from the meeting...`);
|
||||
const roomId = this.roomService.getRoomId();
|
||||
const roomId = this.meetingContextService.roomId();
|
||||
if (!roomId) throw new Error('Room ID is undefined while trying to kick participant');
|
||||
await this.meetingService.kickParticipant(roomId, participantIdentity);
|
||||
} catch (error) {
|
||||
this.log.e(`Error kicking participant '${participantIdentity}':`, error);
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { baseRoutes, MeetingComponent } from '@openvidu-meet/shared-components';
|
||||
import { MEETING_CE_PROVIDERS } from './customization';
|
||||
import { baseRoutes } from '@openvidu-meet/shared-components';
|
||||
import { AppCeMeetingComponent } from './customization/pages/app-ce-meeting/app-ce-meeting.component';
|
||||
|
||||
/**
|
||||
* CE routes configure the plugin system using library components.
|
||||
* The library's MeetingComponent uses NgComponentOutlet to render plugins dynamically.
|
||||
* The library's MeetingComponent uses content projection to allow customization
|
||||
*/
|
||||
const routes = baseRoutes;
|
||||
const meetingRoute = routes.find((route) => route.path === 'room/:room-id')!;
|
||||
meetingRoute.component = MeetingComponent;
|
||||
meetingRoute.providers = MEETING_CE_PROVIDERS;
|
||||
meetingRoute.component = AppCeMeetingComponent;
|
||||
|
||||
export const ceRoutes: Routes = routes;
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import {
|
||||
MEETING_COMPONENTS_TOKEN,
|
||||
MeetingLobbyComponent,
|
||||
MeetingParticipantPanelComponent,
|
||||
MeetingShareLinkOverlayComponent,
|
||||
MeetingShareLinkPanelComponent,
|
||||
MeetingToolbarButtonsComponent
|
||||
} from '@openvidu-meet/shared-components';
|
||||
|
||||
/**
|
||||
* CE Meeting Providers
|
||||
*
|
||||
* Configures the plugin system using library components directly.
|
||||
* No wrappers needed - library components receive @Input properties directly through NgComponentOutlet.
|
||||
*
|
||||
* The library's MeetingComponent:
|
||||
* - Uses NgComponentOutlet to render plugins dynamically
|
||||
* - Prepares inputs via helper methods (getToolbarAdditionalButtonsInputs, etc.)
|
||||
* - Passes these inputs to plugins via [ngComponentOutletInputs]
|
||||
*
|
||||
* CE uses library components as plugins without any customization.
|
||||
* PRO will later define its own custom components to override CE behavior.
|
||||
*/
|
||||
export const MEETING_CE_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: MEETING_COMPONENTS_TOKEN,
|
||||
useValue: {
|
||||
toolbar: {
|
||||
additionalButtons: MeetingToolbarButtonsComponent,
|
||||
leaveButton: MeetingToolbarButtonsComponent
|
||||
},
|
||||
participantPanel: {
|
||||
item: MeetingParticipantPanelComponent,
|
||||
afterLocalParticipant: MeetingShareLinkPanelComponent
|
||||
},
|
||||
layoutAdditionalElements: MeetingShareLinkOverlayComponent,
|
||||
lobby: MeetingLobbyComponent
|
||||
}
|
||||
}
|
||||
// {
|
||||
// provide: MEETING_ACTION_HANDLER,
|
||||
// useValue: {
|
||||
// copySpeakerLink: () => {
|
||||
// console.log('Copy speaker link clicked');
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
];
|
||||
@ -0,0 +1,6 @@
|
||||
<ov-meeting>
|
||||
<ov-meeting-toolbar-buttons slot="additional-buttons"></ov-meeting-toolbar-buttons>
|
||||
<ov-meeting-share-link-panel slot="after-local-participant"></ov-meeting-share-link-panel>
|
||||
<ov-meeting-participant-panel-item slot="participant-panel-item"></ov-meeting-participant-panel-item>
|
||||
<ov-meeting-layout slot="layout"></ov-meeting-layout>
|
||||
</ov-meeting>
|
||||
@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AppCeMeetingComponent } from './app-ce-meeting.component';
|
||||
|
||||
describe('AppCeMeetingComponent', () => {
|
||||
let component: AppCeMeetingComponent;
|
||||
let fixture: ComponentFixture<AppCeMeetingComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppCeMeetingComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AppCeMeetingComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
MeetingComponent,
|
||||
MeetingLayoutComponent,
|
||||
MeetingParticipantPanelItemComponent,
|
||||
MeetingShareLinkPanelComponent,
|
||||
MeetingToolbarButtonsComponent
|
||||
} from '@openvidu-meet/shared-components';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ce-ov-meeting',
|
||||
imports: [
|
||||
MeetingComponent,
|
||||
MeetingToolbarButtonsComponent,
|
||||
MeetingShareLinkPanelComponent,
|
||||
MeetingParticipantPanelItemComponent,
|
||||
MeetingLayoutComponent
|
||||
],
|
||||
templateUrl: './app-ce-meeting.component.html',
|
||||
styleUrl: './app-ce-meeting.component.scss'
|
||||
})
|
||||
export class AppCeMeetingComponent {}
|
||||
Loading…
x
Reference in New Issue
Block a user