frontend: use content projection for configuring videoconference components

Refactored all components and services related to the meeting
This commit is contained in:
Carlos Santos 2025-11-17 16:47:50 +01:00
parent fd998e7b6b
commit 40475dc372
47 changed files with 1298 additions and 1432 deletions

View File

@ -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';

View File

@ -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>

View File

@ -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>
}

View File

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

View File

@ -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>

View File

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

View File

@ -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>
}

View File

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

View File

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

View File

@ -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';

View File

@ -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>
}

View File

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

View File

@ -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;

View File

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

View File

@ -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>

View File

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

View File

@ -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>
}

View File

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

View File

@ -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>

View File

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

View File

@ -3,3 +3,5 @@
*/
export * from './components/meeting-components-plugins.token';
export * from './handlers/meeting-action-handler';
export * from './components/index';

View File

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

View File

@ -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) {

View File

@ -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, {

View File

@ -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

View File

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

View File

@ -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>
}

View File

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

View File

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

View File

@ -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',

View File

@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@ -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');
}
/**

View File

@ -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');

View File

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

View File

@ -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;

View File

@ -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');
// }
// }
// }
];

View File

@ -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>

View File

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

View File

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