frontend: Refactor meeting component for allowing customization
- Implemented MeetingParticipantPanelComponent for displaying participant details with moderation controls. - Created MeetingShareLinkOverlayComponent for sharing meeting links when no participants are present. - Developed MeetingShareLinkPanelComponent for sharing links within the participant panel. - Introduced MeetingToolbarButtonsComponent for additional toolbar actions like copying links and leaving meetings. - Refactored MeetingComponent to utilize new components for participant management and sharing links. - Updated styles for new components and removed redundant styles from MeetingComponent. frontend: implement CE-specific meeting component with routing and UI elements frontend: Enhance meeting components with plugin system - Added alternative function inputs for event handling in MeetingLobbyComponent, MeetingParticipantPanelComponent, MeetingShareLinkOverlayComponent, MeetingShareLinkPanelComponent, and MeetingToolbarButtonsComponent. - Introduced MeetingComponentsPlugins interface and MEETING_COMPONENTS_TOKEN for dynamic component injection. - Updated MeetingComponent to utilize NgComponentOutlet for rendering plugins and prepare inputs for plugin components. - Removed CE-specific MeetingCEComponent and its associated files, integrating its functionality directly into MeetingComponent. - Created MEETING_CE_PROVIDERS to configure the plugin system using library components directly. - Updated routing to use the new MeetingComponent with plugin support. frontend: Update meeting component to display prejoin screen with lobby plugin Moves meeting service to a subdirectory Moves the meeting service to its own subdirectory for better organization. Updates imports to reflect the new location. frontend: Refactor dialog component to conditionally render action buttons frontend: Implement lobby state management and enhance prejoin screen functionality frontend: Refactor MeetingComponent to streamline service injections and constructor frontend: Remove unused participantToken variable and add getter for lobbyState participantToken frontend: Rename lobby.service to meeting-lobby.service frontend: Refactor MeetingComponent to use MeetingPluginManagerService for plugin inputs and remove deprecated methods meet.sh: launch testapp with dev command backend: Added webhook config in .env.test Adds web component events e2e tests Introduces end-to-end tests for web component events, covering scenarios such as joining, leaving, and handling meeting closure. The tests verify correct event emission and payload structure, including reason codes for leave events. Also, add `test_localstorage_state.json` to git ignore, removing the file. frontend: Added meeting event handler service frontend: Enhances meeting component reactivity Refactors the meeting component to use signals for reactive updates. This improves performance by reducing unnecessary re-renders and simplifies state management. - Moves event handling to a dedicated service. - Introduces signals for participant lists and updates. - Implements caching for participant panel inputs. - Improves moderator control visibility logic. webcomponent: Added moderation e2e tests refactor(meeting): optimize participant panel item inputs handling frontend: fix moderator badge rendering in participant panel refactor(meeting): remove unused services and streamline constructor logic refactor(meeting): update leave and end meeting handlers to return promises
This commit is contained in:
parent
f4376934d7
commit
ad4ae2a69d
3
.gitignore
vendored
3
.gitignore
vendored
@ -47,4 +47,5 @@ pnpm-debug.log*
|
|||||||
**/**/docs/webcomponent-commands.md
|
**/**/docs/webcomponent-commands.md
|
||||||
**/**/docs/webcomponent-events.md
|
**/**/docs/webcomponent-events.md
|
||||||
|
|
||||||
**/**/meet-pro
|
**/**/meet-pro
|
||||||
|
**/**/test_localstorage_state.json
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
USE_HTTPS=false
|
USE_HTTPS=false
|
||||||
MEET_LOG_LEVEL=verbose
|
MEET_LOG_LEVEL=verbose
|
||||||
SERVER_CORS_ORIGIN=*
|
SERVER_CORS_ORIGIN=*
|
||||||
MEET_INITIAL_API_KEY=meet-api-key
|
MEET_INITIAL_API_KEY=meet-api-key
|
||||||
|
MEET_INITIAL_WEBHOOK_ENABLED=true
|
||||||
|
MEET_INITIAL_WEBHOOK_URL=http://localhost:5080/webhook
|
||||||
@ -21,19 +21,25 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions class="dialog-action">
|
@if (shouldShowActions()) {
|
||||||
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
|
<mat-dialog-actions class="dialog-action">
|
||||||
{{ data.cancelText ?? 'Cancel' }}
|
@if (shouldShowCancelButton()) {
|
||||||
</button>
|
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
|
||||||
<button
|
{{ data.cancelText ?? 'Cancel' }}
|
||||||
mat-flat-button
|
</button>
|
||||||
mat-dialog-close
|
}
|
||||||
cdkFocusInitial
|
@if (shouldShowConfirmButton()) {
|
||||||
(click)="close('confirm')"
|
<button
|
||||||
class="confirm-button"
|
mat-flat-button
|
||||||
[class.force]="force"
|
mat-dialog-close
|
||||||
>
|
cdkFocusInitial
|
||||||
{{ data.confirmText ?? 'Confirm' }}
|
(click)="close('confirm')"
|
||||||
</button>
|
class="confirm-button"
|
||||||
</mat-dialog-actions>
|
[class.force]="force"
|
||||||
|
>
|
||||||
|
{{ data.confirmText ?? 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</mat-dialog-actions>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,4 +46,16 @@ export class DialogComponent {
|
|||||||
this.data.cancelCallback();
|
this.data.cancelCallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shouldShowActions(): boolean {
|
||||||
|
return this.data.showActions !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowConfirmButton(): boolean {
|
||||||
|
return this.data.showConfirmButton !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShowCancelButton(): boolean {
|
||||||
|
return this.data.showCancelButton !== false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export * from './console-nav/console-nav.component';
|
export * from './console-nav/console-nav.component';
|
||||||
export * from './dialogs/basic-dialog/dialog.component';
|
export * from './dialogs/basic-dialog/dialog.component';
|
||||||
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
|
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
|
||||||
|
export * from './dialogs/delete-room-dialog/delete-room-dialog.component';
|
||||||
export * from './logo-selector/logo-selector.component';
|
export * from './logo-selector/logo-selector.component';
|
||||||
export * from './pro-feature-badge/pro-feature-badge.component';
|
export * from './pro-feature-badge/pro-feature-badge.component';
|
||||||
export * from './recording-lists/recording-lists.component';
|
export * from './recording-lists/recording-lists.component';
|
||||||
@ -12,6 +13,18 @@ export * from './step-indicator/step-indicator.component';
|
|||||||
export * from './wizard-nav/wizard-nav.component';
|
export * from './wizard-nav/wizard-nav.component';
|
||||||
export * from './share-meeting-link/share-meeting-link.component';
|
export * from './share-meeting-link/share-meeting-link.component';
|
||||||
|
|
||||||
export * from './dialogs/basic-dialog/dialog.component';
|
// Meeting modular components
|
||||||
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
|
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
|
||||||
export * from './dialogs/delete-room-dialog/delete-room-dialog.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';
|
||||||
|
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,116 @@
|
|||||||
|
<div class="ov-page-container">
|
||||||
|
<div class="room-access-container fade-in">
|
||||||
|
<!-- Header Section -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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-header class="card-header">
|
||||||
|
<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-subtitle>{{
|
||||||
|
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-form-field appearance="outline" class="name-field">
|
||||||
|
<mat-label>Your display name</mat-label>
|
||||||
|
<input
|
||||||
|
id="participant-name-input"
|
||||||
|
matInput
|
||||||
|
placeholder="Enter your name"
|
||||||
|
formControlName="name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<mat-icon matSuffix class="ov-action-icon">person</mat-icon>
|
||||||
|
@if (participantForm.get('name')?.hasError('required')) {
|
||||||
|
<mat-error> The name is <strong>required</strong> </mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
id="participant-name-submit"
|
||||||
|
type="submit"
|
||||||
|
class="join-button"
|
||||||
|
[disabled]="participantForm.invalid"
|
||||||
|
>
|
||||||
|
<span>Join Meeting</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
} @else {
|
||||||
|
<div class="room-closed-message">
|
||||||
|
<mat-icon class="warning-icon">warning</mat-icon>
|
||||||
|
<p>
|
||||||
|
Sorry, this room is closed. You cannot join at this time. Please contact the meeting
|
||||||
|
organizer for more information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
||||||
|
<!-- View Recordings Card -->
|
||||||
|
@if (showRecordingsCard) {
|
||||||
|
<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>
|
||||||
|
<div class="card-title-group">
|
||||||
|
<mat-card-title>View Recordings</mat-card-title>
|
||||||
|
<mat-card-subtitle>Browse and manage past recordings</mat-card-subtitle>
|
||||||
|
</div>
|
||||||
|
</mat-card-header>
|
||||||
|
|
||||||
|
<mat-card-content class="card-content">
|
||||||
|
<div class="recordings-info">
|
||||||
|
<p class="recordings-description">
|
||||||
|
Access previously recorded meetings from this room. You can watch, download, or manage
|
||||||
|
existing recordings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="view-recordings-btn"
|
||||||
|
mat-stroked-button
|
||||||
|
color="accent"
|
||||||
|
(click)="onViewRecordingsClick()"
|
||||||
|
class="recordings-button"
|
||||||
|
>
|
||||||
|
<span>Browse Recordings</span>
|
||||||
|
</button>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Room URL Badge -->
|
||||||
|
@if (!roomClosed && showShareLink) {
|
||||||
|
<ov-share-meeting-link [meetingUrl]="meetingUrl" (copyClicked)="onCopyLinkClick()"></ov-share-meeting-link>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
@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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -0,0 +1,276 @@
|
|||||||
|
@use '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
|
// Room Access Container - Main layout using design tokens
|
||||||
|
.room-access-container {
|
||||||
|
@include design-tokens.ov-container;
|
||||||
|
@include design-tokens.ov-page-content;
|
||||||
|
padding-top: var(--ov-meet-spacing-xxl);
|
||||||
|
background: var(--ov-meet-background-color);
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room Header - Clean title section
|
||||||
|
.room-header {
|
||||||
|
@include design-tokens.ov-flex-center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
margin-bottom: var(--ov-meet-spacing-xxl);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.room-icon {
|
||||||
|
@include design-tokens.ov-icon(xl);
|
||||||
|
color: var(--ov-meet-icon-rooms);
|
||||||
|
margin-bottom: var(--ov-meet-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-info {
|
||||||
|
.room-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ov-meet-font-size-hero);
|
||||||
|
font-weight: var(--ov-meet-font-weight-light);
|
||||||
|
color: var(--ov-meet-text-primary);
|
||||||
|
line-height: var(--ov-meet-line-height-tight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Cards Grid - Responsive layout
|
||||||
|
.action-cards-grid {
|
||||||
|
@include design-tokens.ov-grid-responsive(320px);
|
||||||
|
gap: var(--ov-meet-spacing-xl);
|
||||||
|
margin-bottom: var(--ov-meet-spacing-xxl);
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
// When there's only one card, limit its width to maintain visual consistency
|
||||||
|
&:has(.action-card:only-child) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include design-tokens.ov-tablet-down {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--ov-meet-spacing-lg);
|
||||||
|
|
||||||
|
// On tablets and mobile, single cards should use full width
|
||||||
|
&:has(.action-card:only-child) {
|
||||||
|
.action-card {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action Card Base - Consistent card styling
|
||||||
|
.action-card {
|
||||||
|
@include design-tokens.ov-card;
|
||||||
|
@include design-tokens.ov-hover-lift(-4px);
|
||||||
|
@include design-tokens.ov-theme-transition;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// Card Header
|
||||||
|
.card-header {
|
||||||
|
padding: var(--ov-meet-spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--ov-meet-border-color-light);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
@include design-tokens.ov-icon(lg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-group {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ov-meet-font-size-xl);
|
||||||
|
font-weight: var(--ov-meet-font-weight-semibold);
|
||||||
|
color: var(--ov-meet-text-primary);
|
||||||
|
line-height: var(--ov-meet-line-height-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-mdc-card-subtitle {
|
||||||
|
margin: var(--ov-meet-spacing-xs) 0 0 0;
|
||||||
|
font-size: var(--ov-meet-font-size-sm);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
line-height: var(--ov-meet-line-height-normal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card Content
|
||||||
|
.card-content {
|
||||||
|
padding: var(--ov-meet-spacing-lg);
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Card - Join meeting styling
|
||||||
|
.primary-card {
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-primary-light) 180%);
|
||||||
|
color: var(--ov-meet-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.room-closed-card {
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-warning) 180%);
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
color: var(--ov-meet-color-warning) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-closed-message {
|
||||||
|
@include design-tokens.ov-flex-center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-md);
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.warning-icon {
|
||||||
|
@include design-tokens.ov-icon(xl);
|
||||||
|
color: var(--ov-meet-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ov-meet-font-size-md);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
line-height: var(--ov-meet-line-height-relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary Card - Recordings styling
|
||||||
|
.secondary-card {
|
||||||
|
.card-header {
|
||||||
|
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-accent) 180%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join Form - Form styling
|
||||||
|
.join-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-lg);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.name-field {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.mat-mdc-form-field-icon-suffix {
|
||||||
|
color: var(--ov-meet-text-hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.join-button {
|
||||||
|
@include design-tokens.ov-button-base;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
margin-top: auto;
|
||||||
|
background-color: var(--ov-meet-color-secondary);
|
||||||
|
color: var(--ov-meet-text-on-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recordings Info - Content for recordings card
|
||||||
|
.recordings-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ov-meet-spacing-lg);
|
||||||
|
|
||||||
|
.recordings-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--ov-meet-font-size-md);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
line-height: var(--ov-meet-line-height-relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recordings-button {
|
||||||
|
@include design-tokens.ov-button-base;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Actions - Footer actions
|
||||||
|
.quick-actions {
|
||||||
|
@include design-tokens.ov-flex-center;
|
||||||
|
margin-top: var(--ov-meet-spacing-xl);
|
||||||
|
|
||||||
|
.quick-action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ov-meet-spacing-sm);
|
||||||
|
color: var(--ov-meet-text-secondary);
|
||||||
|
@include design-tokens.ov-theme-transition;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ov-meet-text-primary);
|
||||||
|
background-color: var(--ov-meet-surface-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@include design-tokens.ov-mobile-down {
|
||||||
|
.room-access-container {
|
||||||
|
padding: 0;
|
||||||
|
padding-top: var(--ov-meet-spacing-sm);
|
||||||
|
margin-bottom: var(--ov-meet-spacing-xxl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-header {
|
||||||
|
margin-bottom: var(--ov-meet-spacing-xl);
|
||||||
|
|
||||||
|
.room-info .room-title {
|
||||||
|
font-size: var(--ov-meet-font-size-xxl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-card {
|
||||||
|
min-height: auto;
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: var(--ov-meet-spacing-md);
|
||||||
|
|
||||||
|
.card-title-group {
|
||||||
|
.mat-mdc-card-title {
|
||||||
|
font-size: var(--ov-meet-font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: var(--ov-meet-spacing-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormGroup, 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 '../share-meeting-link/share-meeting-link.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable component for the meeting lobby page.
|
||||||
|
* Displays the form to join the meeting and optional recordings card.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ov-meeting-lobby',
|
||||||
|
templateUrl: './meeting-lobby.component.html',
|
||||||
|
styleUrls: ['./meeting-lobby.component.scss'],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
MatCardModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
ShareMeetingLinkComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class MeetingLobbyComponent {
|
||||||
|
/**
|
||||||
|
* The room name to display
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) roomName = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The meeting URL to share
|
||||||
|
*/
|
||||||
|
@Input() meetingUrl = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the room is closed
|
||||||
|
*/
|
||||||
|
@Input() roomClosed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the recordings card
|
||||||
|
*/
|
||||||
|
@Input() showRecordingsCard = 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onViewRecordingsClick(): void {
|
||||||
|
if (this.viewRecordingsClickedFn) {
|
||||||
|
this.viewRecordingsClickedFn();
|
||||||
|
} else {
|
||||||
|
this.viewRecordingsClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackClick(): void {
|
||||||
|
if (this.backClickedFn) {
|
||||||
|
this.backClickedFn();
|
||||||
|
} else {
|
||||||
|
this.backClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCopyLinkClick(): void {
|
||||||
|
if (this.copyLinkClickedFn) {
|
||||||
|
this.copyLinkClickedFn();
|
||||||
|
} else {
|
||||||
|
this.copyLinkClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
@use '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
|
.participant-item-container {
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
::ng-deep .participant-container {
|
||||||
|
padding: 2px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moderator-badge {
|
||||||
|
color: var(--ov-meet-color-warning);
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.force-disconnect-btn,
|
||||||
|
.remove-moderator-btn {
|
||||||
|
color: var(--ov-meet-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-moderator-btn {
|
||||||
|
color: var(--ov-meet-color-warning);
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
@if (showOverlay) {
|
||||||
|
<div id="share-link-overlay" class="main-share-meeting-link-container fade-in-delayed-more OV_big">
|
||||||
|
<ov-share-meeting-link
|
||||||
|
class="main-share-meeting-link"
|
||||||
|
[title]="title"
|
||||||
|
[subtitle]="subtitle"
|
||||||
|
[titleSize]="titleSize"
|
||||||
|
[titleWeight]="titleWeight"
|
||||||
|
[meetingUrl]="meetingUrl"
|
||||||
|
(copyClicked)="onCopyClicked()"
|
||||||
|
></ov-share-meeting-link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
@use '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
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 overlay
|
||||||
|
* when there are no remote participants in the meeting.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ov-meeting-share-link-overlay',
|
||||||
|
templateUrl: './meeting-share-link-overlay.component.html',
|
||||||
|
styleUrls: ['./meeting-share-link-overlay.component.scss'],
|
||||||
|
imports: [CommonModule, ShareMeetingLinkComponent]
|
||||||
|
})
|
||||||
|
export class MeetingShareLinkOverlayComponent {
|
||||||
|
/**
|
||||||
|
* Controls whether the overlay should be shown
|
||||||
|
*/
|
||||||
|
@Input() showOverlay = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The meeting URL to share
|
||||||
|
*/
|
||||||
|
@Input({ required: true }) meetingUrl = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title text for the overlay
|
||||||
|
*/
|
||||||
|
@Input() title = 'Start collaborating';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitle text for the overlay
|
||||||
|
*/
|
||||||
|
@Input() subtitle = 'Share this link to bring others into the meeting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title size (sm, md, lg, xl)
|
||||||
|
*/
|
||||||
|
@Input() titleSize: 'sm' | 'md' | 'lg' | 'xl' = 'xl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title weight (normal, bold)
|
||||||
|
*/
|
||||||
|
@Input() titleWeight: 'normal' | 'bold' = 'bold';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
@if (showShareLink) {
|
||||||
|
<div class="share-meeting-link-container">
|
||||||
|
<ov-share-meeting-link [meetingUrl]="meetingUrl" (copyClicked)="onCopyClicked()"></ov-share-meeting-link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
.share-meeting-link-container {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
<!-- Copy Link Button -->
|
||||||
|
@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>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
id="copy-speaker-link"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="onCopyLinkClick()"
|
||||||
|
[disableRipple]="true"
|
||||||
|
[matTooltip]="copyLinkTooltip"
|
||||||
|
>
|
||||||
|
<mat-icon>link</mat-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Leave Menu -->
|
||||||
|
@if (showLeaveMenu) {
|
||||||
|
<button
|
||||||
|
id="leave-btn"
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="leaveMenu"
|
||||||
|
[matTooltip]="leaveMenuTooltip"
|
||||||
|
[disableRipple]="true"
|
||||||
|
class="custom-leave-btn"
|
||||||
|
[class.mobile-btn]="isMobile"
|
||||||
|
>
|
||||||
|
<mat-icon>call_end</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #leaveMenu="matMenu">
|
||||||
|
<button mat-menu-item (click)="onLeaveMeetingClick()" id="leave-option">
|
||||||
|
<mat-icon>logout</mat-icon>
|
||||||
|
<span>{{ leaveOptionText }}</span>
|
||||||
|
</button>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<button mat-menu-item (click)="onEndMeetingClick()" id="end-meeting-option">
|
||||||
|
<mat-icon>no_meeting_room</mat-icon>
|
||||||
|
<span>{{ endMeetingOptionText }}</span>
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
}
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
@use '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global styling for leave button when in toolbar
|
||||||
|
::ng-deep {
|
||||||
|
#media-buttons-container .custom-leave-btn {
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--ov-meet-color-error) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border-radius: var(--ov-meet-radius-md) !important;
|
||||||
|
width: 65px;
|
||||||
|
margin: 6px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable component for meeting toolbar additional buttons.
|
||||||
|
* This component is agnostic and can be configured via inputs.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ov-meeting-toolbar-buttons',
|
||||||
|
templateUrl: './meeting-toolbar-buttons.component.html',
|
||||||
|
styleUrls: ['./meeting-toolbar-buttons.component.scss'],
|
||||||
|
imports: [CommonModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule, MatDividerModule]
|
||||||
|
})
|
||||||
|
export class MeetingToolbarButtonsComponent {
|
||||||
|
/**
|
||||||
|
* Whether to show the copy link button
|
||||||
|
*/
|
||||||
|
@Input() showCopyLinkButton = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the leave menu with options
|
||||||
|
*/
|
||||||
|
@Input() showLeaveMenu = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the device is mobile (affects button style)
|
||||||
|
*/
|
||||||
|
@Input() isMobile = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy link button tooltip text
|
||||||
|
*/
|
||||||
|
@Input() copyLinkTooltip = 'Copy the meeting link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy link button text (for mobile)
|
||||||
|
*/
|
||||||
|
@Input() copyLinkText = 'Copy meeting link';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave menu tooltip text
|
||||||
|
*/
|
||||||
|
@Input() leaveMenuTooltip = 'Leave options';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave option text
|
||||||
|
*/
|
||||||
|
@Input() leaveOptionText = 'Leave meeting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End meeting option text
|
||||||
|
*/
|
||||||
|
@Input() endMeetingOptionText = 'End meeting for all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when the copy link button is clicked
|
||||||
|
*/
|
||||||
|
@Output() copyLinkClicked = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when the leave meeting option is clicked
|
||||||
|
*/
|
||||||
|
@Output() leaveMeetingClicked = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when the end meeting option is clicked
|
||||||
|
*/
|
||||||
|
@Output() endMeetingClicked = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative to @Output: Function to call when copy link button is clicked
|
||||||
|
* When using NgComponentOutlet, use this instead of the @Output above
|
||||||
|
*/
|
||||||
|
@Input() copyLinkClickedFn?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative to @Output: Function to call when leave meeting is clicked
|
||||||
|
* When using NgComponentOutlet, use this instead of the @Output above
|
||||||
|
*/
|
||||||
|
@Input() leaveMeetingClickedFn?: () => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alternative to @Output: Function to call when end meeting is clicked
|
||||||
|
* When using NgComponentOutlet, use this instead of the @Output above
|
||||||
|
*/
|
||||||
|
@Input() endMeetingClickedFn?: () => Promise<void>;
|
||||||
|
|
||||||
|
onCopyLinkClick(): void {
|
||||||
|
if (this.copyLinkClickedFn) {
|
||||||
|
this.copyLinkClickedFn();
|
||||||
|
} else {
|
||||||
|
this.copyLinkClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLeaveMeetingClick(): Promise<void> {
|
||||||
|
if (this.leaveMeetingClickedFn) {
|
||||||
|
await this.leaveMeetingClickedFn();
|
||||||
|
} else {
|
||||||
|
this.leaveMeetingClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onEndMeetingClick(): Promise<void> {
|
||||||
|
if (this.endMeetingClickedFn) {
|
||||||
|
await this.endMeetingClickedFn();
|
||||||
|
} else {
|
||||||
|
this.endMeetingClicked.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { InjectionToken, Type } from '@angular/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for registering custom components to be used in the meeting view.
|
||||||
|
* Each property represents a slot where a custom component can be injected.
|
||||||
|
*/
|
||||||
|
export interface MeetingComponentsPlugins {
|
||||||
|
/**
|
||||||
|
* Toolbar-related plugin components
|
||||||
|
*/
|
||||||
|
toolbar?: {
|
||||||
|
/**
|
||||||
|
* Additional buttons to show in the toolbar (e.g., copy link, settings)
|
||||||
|
*/
|
||||||
|
additionalButtons?: Type<any>;
|
||||||
|
/**
|
||||||
|
* Custom leave button component (only shown for moderators)
|
||||||
|
*/
|
||||||
|
leaveButton?: Type<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Participant panel-related plugin components
|
||||||
|
*/
|
||||||
|
participantPanel?: {
|
||||||
|
/**
|
||||||
|
* Custom component to render each participant item in the panel
|
||||||
|
*/
|
||||||
|
item?: Type<any>;
|
||||||
|
/**
|
||||||
|
* Component to show after the local participant in the panel
|
||||||
|
*/
|
||||||
|
afterLocalParticipant?: Type<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout-related plugin components
|
||||||
|
*/
|
||||||
|
layout?: {
|
||||||
|
/**
|
||||||
|
* Additional elements to show in the main layout (e.g., overlays, banners)
|
||||||
|
*/
|
||||||
|
additionalElements?: Type<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lobby-related plugin components
|
||||||
|
*/
|
||||||
|
lobby?: Type<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection token for registering meeting plugins.
|
||||||
|
* Apps (CE/PRO) should provide their custom components using this token.
|
||||||
|
*/
|
||||||
|
export const MEETING_COMPONENTS_TOKEN = new InjectionToken<MeetingComponentsPlugins>('MEETING_COMPONENTS_TOKEN');
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
import { CustomParticipantModel } from '../../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining the controls to show for a participant in the participant panel.
|
||||||
|
*/
|
||||||
|
export interface ParticipantControls {
|
||||||
|
/**
|
||||||
|
* Whether to show the moderator badge
|
||||||
|
*/
|
||||||
|
showModeratorBadge: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show moderation controls (make/unmake moderator, kick)
|
||||||
|
*/
|
||||||
|
showModerationControls: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the "Make Moderator" button
|
||||||
|
*/
|
||||||
|
showMakeModerator: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the "Remove Moderator" button
|
||||||
|
*/
|
||||||
|
showUnmakeModerator: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show the "Kick" button
|
||||||
|
*/
|
||||||
|
showKickButton: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class defining the actions that can be performed in a meeting.
|
||||||
|
* Apps (CE/PRO) must extend this class and provide their implementation.
|
||||||
|
*/
|
||||||
|
export abstract class MeetingActionHandler {
|
||||||
|
/**
|
||||||
|
* Room ID - will be set by MeetingComponent
|
||||||
|
*/
|
||||||
|
roomId = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room secret - will be set by MeetingComponent
|
||||||
|
*/
|
||||||
|
roomSecret = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local participant - will be set by MeetingComponent
|
||||||
|
*/
|
||||||
|
localParticipant?: CustomParticipantModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kicks a participant from the meeting
|
||||||
|
*/
|
||||||
|
abstract kickParticipant(participant: CustomParticipantModel): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a participant a moderator
|
||||||
|
*/
|
||||||
|
abstract makeModerator(participant: CustomParticipantModel): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes moderator role from a participant
|
||||||
|
*/
|
||||||
|
abstract unmakeModerator(participant: CustomParticipantModel): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the moderator link to clipboard
|
||||||
|
*/
|
||||||
|
abstract copyModeratorLink(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the speaker link to clipboard
|
||||||
|
*/
|
||||||
|
abstract copySpeakerLink(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the controls to show for a participant based on permissions and roles
|
||||||
|
*/
|
||||||
|
abstract getParticipantControls(participant: CustomParticipantModel): ParticipantControls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injection token for the meeting action handler.
|
||||||
|
* Apps (CE/PRO) should provide their implementation using this token.
|
||||||
|
*/
|
||||||
|
export const MEETING_ACTION_HANDLER_TOKEN = new InjectionToken<MeetingActionHandler>('MEETING_ACTION_HANDLER_TOKEN');
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Index file for customization exports
|
||||||
|
*/
|
||||||
|
export * from './components/meeting-components-plugins.token';
|
||||||
|
export * from './handlers/meeting-action-handler';
|
||||||
@ -4,3 +4,4 @@ export * from './navigation.model';
|
|||||||
export * from './notification.model';
|
export * from './notification.model';
|
||||||
export * from './sidenav.model';
|
export * from './sidenav.model';
|
||||||
export * from './wizard.model';
|
export * from './wizard.model';
|
||||||
|
export * from './lobby.model';
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { MeetRoom } from '@openvidu-meet/typings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State interface representing the lobby state of a meeting
|
||||||
|
*/
|
||||||
|
export interface LobbyState {
|
||||||
|
room?: MeetRoom;
|
||||||
|
roomId: string;
|
||||||
|
roomSecret: string;
|
||||||
|
roomClosed: boolean;
|
||||||
|
hasRecordings: boolean;
|
||||||
|
showRecordingCard: boolean;
|
||||||
|
showBackButton: boolean;
|
||||||
|
backButtonText: string;
|
||||||
|
participantForm: FormGroup;
|
||||||
|
participantToken: string;
|
||||||
|
}
|
||||||
@ -13,6 +13,10 @@ export interface DialogOptions {
|
|||||||
forceCheckboxText?: string;
|
forceCheckboxText?: string;
|
||||||
forceCheckboxDescription?: string;
|
forceCheckboxDescription?: string;
|
||||||
forceConfirmCallback?: () => void;
|
forceConfirmCallback?: () => void;
|
||||||
|
// Action buttons visibility
|
||||||
|
showConfirmButton?: boolean;
|
||||||
|
showCancelButton?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteRoomDialogOptions {
|
export interface DeleteRoomDialogOptions {
|
||||||
|
|||||||
@ -1,4 +1,19 @@
|
|||||||
@if (showMeeting) {
|
@if (showPrejoin) {
|
||||||
|
<!-- Prejoin screen (Lobby) -->
|
||||||
|
@if (prejoinReady && plugins.lobby) {
|
||||||
|
<ng-container [ngComponentOutlet]="plugins.lobby" [ngComponentOutletInputs]="lobbyInputs()"> </ng-container>
|
||||||
|
} @else if (!prejoinReady) {
|
||||||
|
<div class="prejoin-loading-container">
|
||||||
|
<mat-spinner diameter="30"></mat-spinner>
|
||||||
|
<p class="prejoin-loading-text">Preparing your meeting...</p>
|
||||||
|
</div>
|
||||||
|
} @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>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
<ov-videoconference
|
<ov-videoconference
|
||||||
[token]="participantToken"
|
[token]="participantToken"
|
||||||
[prejoin]="true"
|
[prejoin]="true"
|
||||||
@ -33,305 +48,62 @@
|
|||||||
[showThemeSelector]="features().showThemeSelector"
|
[showThemeSelector]="features().showThemeSelector"
|
||||||
[showDisconnectionDialog]="false"
|
[showDisconnectionDialog]="false"
|
||||||
(onRoomCreated)="onRoomCreated($event)"
|
(onRoomCreated)="onRoomCreated($event)"
|
||||||
(onParticipantConnected)="onParticipantConnected($event)"
|
(onParticipantConnected)="eventHandler.onParticipantConnected($event)"
|
||||||
(onParticipantLeft)="onParticipantLeft($event)"
|
(onParticipantLeft)="eventHandler.onParticipantLeft($event)"
|
||||||
(onRecordingStartRequested)="onRecordingStartRequested($event)"
|
(onRecordingStartRequested)="eventHandler.onRecordingStartRequested($event)"
|
||||||
(onRecordingStopRequested)="onRecordingStopRequested($event)"
|
(onRecordingStopRequested)="eventHandler.onRecordingStopRequested($event)"
|
||||||
(onViewRecordingsClicked)="onViewRecordingsClicked()"
|
(onViewRecordingsClicked)="onViewRecordingsClicked()"
|
||||||
>
|
>
|
||||||
<ng-container *ovToolbarAdditionalButtons>
|
<!-- Toolbar Additional Buttons Plugin -->
|
||||||
<!-- Copy Link Button -->
|
@if (plugins.toolbar?.additionalButtons) {
|
||||||
@if (features().canModerateRoom) {
|
<ng-container *ovToolbarAdditionalButtons>
|
||||||
@if (isMobile) {
|
<ng-container
|
||||||
<button id="copy-speaker-link" mat-menu-item (click)="copySpeakerLink()" [disableRipple]="true">
|
[ngComponentOutlet]="plugins.toolbar!.additionalButtons!"
|
||||||
<mat-icon>link</mat-icon>
|
[ngComponentOutletInputs]="toolbarAdditionalButtonsInputs()"
|
||||||
<span class="button-text">Copy meeting link</span>
|
></ng-container>
|
||||||
</button>
|
</ng-container>
|
||||||
} @else {
|
}
|
||||||
<button
|
|
||||||
id="copy-speaker-link"
|
<!-- Toolbar Leave Button Plugin -->
|
||||||
mat-icon-button
|
@if (plugins.toolbar?.leaveButton) {
|
||||||
(click)="copySpeakerLink()"
|
<ng-container *ovToolbarLeaveButton>
|
||||||
[disableRipple]="true"
|
<ng-container
|
||||||
matTooltip="Copy the meeting link"
|
[ngComponentOutlet]="plugins.toolbar!.leaveButton!"
|
||||||
>
|
[ngComponentOutletInputs]="toolbarLeaveButtonInputs()"
|
||||||
<mat-icon>link</mat-icon>
|
></ng-container>
|
||||||
</button>
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Participant Panel After Local Participant Plugin -->
|
||||||
|
@if (plugins.participantPanel?.afterLocalParticipant) {
|
||||||
|
<ng-container *ovParticipantPanelAfterLocalParticipant>
|
||||||
|
<ng-container
|
||||||
|
[ngComponentOutlet]="plugins.participantPanel!.afterLocalParticipant!"
|
||||||
|
[ngComponentOutletInputs]="participantPanelAfterLocalInputs()"
|
||||||
|
></ng-container>
|
||||||
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Layout Additional Elements Plugin -->
|
||||||
|
@if (plugins.layout?.additionalElements) {
|
||||||
|
<ng-container *ovLayoutAdditionalElements>
|
||||||
|
@if (onlyModeratorIsPresent) {
|
||||||
|
<ng-container
|
||||||
|
[ngComponentOutlet]="plugins.layout!.additionalElements!"
|
||||||
|
[ngComponentOutletInputs]="layoutAdditionalElementsInputs()"
|
||||||
|
></ng-container>
|
||||||
}
|
}
|
||||||
}
|
</ng-container>
|
||||||
</ng-container>
|
}
|
||||||
|
|
||||||
<ng-container *ovToolbarLeaveButton>
|
<!-- Participant Panel Item Plugin -->
|
||||||
@if (features().canModerateRoom) {
|
@if (plugins.participantPanel?.item) {
|
||||||
<!-- Leave Button -->
|
<ng-container *ovParticipantPanelItem="let participant">
|
||||||
<button
|
<ng-container
|
||||||
id="leave-btn"
|
[ngComponentOutlet]="plugins.participantPanel!.item!"
|
||||||
mat-icon-button
|
[ngComponentOutletInputs]="participantPanelItemInputsMap().get(participant.identity)"
|
||||||
[matMenuTriggerFor]="leaveMenu"
|
></ng-container>
|
||||||
matTooltip="Leave options"
|
</ng-container>
|
||||||
[disableRipple]="true"
|
}
|
||||||
class="custom-leave-btn"
|
|
||||||
[class.mobile-btn]="isMobile"
|
|
||||||
>
|
|
||||||
<mat-icon>call_end</mat-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #leaveMenu="matMenu">
|
|
||||||
<button mat-menu-item (click)="leaveMeeting()" id="leave-option">
|
|
||||||
<mat-icon>logout</mat-icon>
|
|
||||||
<span>Leave meeting</span>
|
|
||||||
</button>
|
|
||||||
<mat-divider></mat-divider>
|
|
||||||
<button mat-menu-item (click)="endMeeting()" id="end-meeting-option">
|
|
||||||
<mat-icon>no_meeting_room</mat-icon>
|
|
||||||
<span>End meeting for all</span>
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ovParticipantPanelAfterLocalParticipant>
|
|
||||||
@if (features().canModerateRoom) {
|
|
||||||
<div class="share-meeting-link-container">
|
|
||||||
<ov-share-meeting-link
|
|
||||||
[meetingUrl]="hostname + '/room/' + roomId"
|
|
||||||
(copyClicked)="copySpeakerLink()"
|
|
||||||
></ov-share-meeting-link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ovLayoutAdditionalElements>
|
|
||||||
@if (features().canModerateRoom && remoteParticipants.length === 0) {
|
|
||||||
<div class="main-share-meeting-link-container fade-in-delayed-more OV_big">
|
|
||||||
<ov-share-meeting-link
|
|
||||||
class="main-share-meeting-link"
|
|
||||||
[title]="'Start collaborating'"
|
|
||||||
[subtitle]="'Share this link to bring others into the meeting'"
|
|
||||||
[titleSize]="'xl'"
|
|
||||||
[titleWeight]="'bold'"
|
|
||||||
[meetingUrl]="hostname + '/room/' + roomId"
|
|
||||||
(copyClicked)="copySpeakerLink()"
|
|
||||||
></ov-share-meeting-link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ovParticipantPanelItem="let participant">
|
|
||||||
<!-- If Meet participant is moderator -->
|
|
||||||
@if (features().canModerateRoom) {
|
|
||||||
<div class="participant-item-container">
|
|
||||||
<!-- Local participant -->
|
|
||||||
@if (participant.isLocal) {
|
|
||||||
<ov-participant-panel-item [participant]="participant">
|
|
||||||
<ng-container *ovParticipantPanelParticipantBadge>
|
|
||||||
<span class="moderator-badge">
|
|
||||||
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
|
|
||||||
shield_person
|
|
||||||
</mat-icon>
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
|
||||||
</ov-participant-panel-item>
|
|
||||||
} @else {
|
|
||||||
<!-- Remote participant -->
|
|
||||||
<ov-participant-panel-item [participant]="participant">
|
|
||||||
@if (participant.isModerator()) {
|
|
||||||
<ng-container *ovParticipantPanelParticipantBadge>
|
|
||||||
<span class="moderator-badge">
|
|
||||||
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
|
|
||||||
shield_person
|
|
||||||
</mat-icon>
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
|
||||||
}
|
|
||||||
<div *ovParticipantPanelItemElements>
|
|
||||||
<!-- Button to make moderator if not -->
|
|
||||||
@if (localParticipant!.isOriginalModerator()) {
|
|
||||||
@if (participant.isModerator() && !participant.isOriginalModerator()) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
(click)="unmakeModerator(participant)"
|
|
||||||
matTooltip="Unmake participant moderator"
|
|
||||||
class="remove-moderator-btn"
|
|
||||||
>
|
|
||||||
<mat-icon class="material-symbols-outlined">remove_moderator</mat-icon>
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
@if (!participant.isModerator()) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
(click)="makeModerator(participant)"
|
|
||||||
matTooltip="Make participant moderator"
|
|
||||||
class="make-moderator-btn"
|
|
||||||
>
|
|
||||||
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
@if (!participant.isModerator()) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
(click)="makeModerator(participant)"
|
|
||||||
matTooltip="Make participant moderator"
|
|
||||||
class="make-moderator-btn"
|
|
||||||
>
|
|
||||||
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Button to kick participant -->
|
|
||||||
@if (!participant.isOriginalModerator()) {
|
|
||||||
<button
|
|
||||||
mat-icon-button
|
|
||||||
(click)="kickParticipant(participant)"
|
|
||||||
matTooltip="Kick participant"
|
|
||||||
class="force-disconnect-btn"
|
|
||||||
>
|
|
||||||
<mat-icon>call_end</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ov-participant-panel-item>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<!-- If I can't moderate the room -->
|
|
||||||
<div class="participant-item-container">
|
|
||||||
<ov-participant-panel-item [participant]="participant">
|
|
||||||
@if (participant.isModerator()) {
|
|
||||||
<ng-container *ovParticipantPanelParticipantBadge>
|
|
||||||
<span class="moderator-badge">
|
|
||||||
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
|
|
||||||
shield_person
|
|
||||||
</mat-icon>
|
|
||||||
</span>
|
|
||||||
</ng-container>
|
|
||||||
}
|
|
||||||
</ov-participant-panel-item>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
|
||||||
</ov-videoconference>
|
</ov-videoconference>
|
||||||
} @else {
|
|
||||||
<!-- Move this logic to lobby meeting page -->
|
|
||||||
<div class="ov-page-container">
|
|
||||||
<div class="room-access-container fade-in">
|
|
||||||
<!-- Header Section -->
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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-header class="card-header">
|
|
||||||
<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-subtitle>{{
|
|
||||||
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)="submitAccessMeeting()" class="join-form">
|
|
||||||
<mat-form-field appearance="outline" class="name-field">
|
|
||||||
<mat-label>Your display name</mat-label>
|
|
||||||
<input
|
|
||||||
id="participant-name-input"
|
|
||||||
matInput
|
|
||||||
placeholder="Enter your name"
|
|
||||||
formControlName="name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<mat-icon matSuffix class="ov-action-icon">person</mat-icon>
|
|
||||||
@if (participantForm.get('name')?.hasError('required')) {
|
|
||||||
<mat-error> The name is <strong>required</strong> </mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<button
|
|
||||||
mat-raised-button
|
|
||||||
color="primary"
|
|
||||||
id="participant-name-submit"
|
|
||||||
type="submit"
|
|
||||||
class="join-button"
|
|
||||||
[disabled]="participantForm.invalid"
|
|
||||||
>
|
|
||||||
<span>Join Meeting</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
} @else {
|
|
||||||
<div class="room-closed-message">
|
|
||||||
<mat-icon class="warning-icon">warning</mat-icon>
|
|
||||||
<p>
|
|
||||||
Sorry, this room is closed. You cannot join at this time. Please contact the meeting
|
|
||||||
organizer for more information.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
|
|
||||||
<!-- View Recordings Card -->
|
|
||||||
@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>
|
|
||||||
<div class="card-title-group">
|
|
||||||
<mat-card-title>View Recordings</mat-card-title>
|
|
||||||
<mat-card-subtitle>Browse and manage past recordings</mat-card-subtitle>
|
|
||||||
</div>
|
|
||||||
</mat-card-header>
|
|
||||||
|
|
||||||
<mat-card-content class="card-content">
|
|
||||||
<div class="recordings-info">
|
|
||||||
<p class="recordings-description">
|
|
||||||
Access previously recorded meetings from this room. You can watch, download, or
|
|
||||||
manage existing recordings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="view-recordings-btn"
|
|
||||||
mat-stroked-button
|
|
||||||
color="accent"
|
|
||||||
(click)="goToRecordings()"
|
|
||||||
class="recordings-button"
|
|
||||||
>
|
|
||||||
<span>Browse Recordings</span>
|
|
||||||
</button>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Room URL Badge -->
|
|
||||||
@if (!roomClosed && features().canModerateRoom) {
|
|
||||||
<ov-share-meeting-link
|
|
||||||
[meetingUrl]="hostname + '/room/' + roomId"
|
|
||||||
(copyClicked)="copySpeakerLink()"
|
|
||||||
></ov-share-meeting-link>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
|
||||||
@if (showBackButton) {
|
|
||||||
<div class="quick-actions fade-in-delayed-more">
|
|
||||||
<button mat-button class="quick-action-button" (click)="goBack()">
|
|
||||||
<mat-icon>arrow_back</mat-icon>
|
|
||||||
<span>{{ backButtonText }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,327 +1,9 @@
|
|||||||
@use '../../../../../../src/assets/styles/design-tokens';
|
@use '../../../../../../src/assets/styles/design-tokens';
|
||||||
|
|
||||||
// Room Access Container - Main layout using design tokens
|
.prejoin-loading-container,
|
||||||
.room-access-container {
|
.prejoin-error-container {
|
||||||
@include design-tokens.ov-container;
|
display: flex;
|
||||||
@include design-tokens.ov-page-content;
|
|
||||||
padding-top: var(--ov-meet-spacing-xxl);
|
|
||||||
background: var(--ov-meet-background-color);
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room Header - Clean title section
|
|
||||||
.room-header {
|
|
||||||
@include design-tokens.ov-flex-center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--ov-meet-spacing-md);
|
|
||||||
margin-bottom: var(--ov-meet-spacing-xxl);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.room-icon {
|
|
||||||
@include design-tokens.ov-icon(xl);
|
|
||||||
color: var(--ov-meet-icon-rooms);
|
|
||||||
margin-bottom: var(--ov-meet-spacing-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-info {
|
|
||||||
.room-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--ov-meet-font-size-hero);
|
|
||||||
font-weight: var(--ov-meet-font-weight-light);
|
|
||||||
color: var(--ov-meet-text-primary);
|
|
||||||
line-height: var(--ov-meet-line-height-tight);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action Cards Grid - Responsive layout
|
|
||||||
.action-cards-grid {
|
|
||||||
@include design-tokens.ov-grid-responsive(320px);
|
|
||||||
gap: var(--ov-meet-spacing-xl);
|
|
||||||
margin-bottom: var(--ov-meet-spacing-xxl);
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
// When there's only one card, limit its width to maintain visual consistency
|
|
||||||
&:has(.action-card:only-child) {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
.action-card {
|
|
||||||
max-width: 400px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@include design-tokens.ov-tablet-down {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--ov-meet-spacing-lg);
|
|
||||||
|
|
||||||
// On tablets and mobile, single cards should use full width
|
|
||||||
&:has(.action-card:only-child) {
|
|
||||||
.action-card {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action Card Base - Consistent card styling
|
|
||||||
.action-card {
|
|
||||||
@include design-tokens.ov-card;
|
|
||||||
@include design-tokens.ov-hover-lift(-4px);
|
|
||||||
@include design-tokens.ov-theme-transition;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 300px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
// Card Header
|
|
||||||
.card-header {
|
|
||||||
padding: var(--ov-meet-spacing-lg);
|
|
||||||
border-bottom: 1px solid var(--ov-meet-border-color-light);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--ov-meet-spacing-md);
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
@include design-tokens.ov-icon(lg);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title-group {
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.mat-mdc-card-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--ov-meet-font-size-xl);
|
|
||||||
font-weight: var(--ov-meet-font-weight-semibold);
|
|
||||||
color: var(--ov-meet-text-primary);
|
|
||||||
line-height: var(--ov-meet-line-height-tight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-mdc-card-subtitle {
|
|
||||||
margin: var(--ov-meet-spacing-xs) 0 0 0;
|
|
||||||
font-size: var(--ov-meet-font-size-sm);
|
|
||||||
color: var(--ov-meet-text-secondary);
|
|
||||||
line-height: var(--ov-meet-line-height-normal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Card Content
|
|
||||||
.card-content {
|
|
||||||
padding: var(--ov-meet-spacing-lg);
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Primary Card - Join meeting styling
|
|
||||||
.primary-card {
|
|
||||||
.card-header {
|
|
||||||
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-primary-light) 180%);
|
|
||||||
color: var(--ov-meet-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.room-closed-card {
|
|
||||||
.card-header {
|
|
||||||
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-warning) 180%);
|
|
||||||
|
|
||||||
.mat-icon {
|
|
||||||
color: var(--ov-meet-color-warning) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-closed-message {
|
|
||||||
@include design-tokens.ov-flex-center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--ov-meet-spacing-md);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.warning-icon {
|
|
||||||
@include design-tokens.ov-icon(xl);
|
|
||||||
color: var(--ov-meet-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--ov-meet-font-size-md);
|
|
||||||
color: var(--ov-meet-text-secondary);
|
|
||||||
line-height: var(--ov-meet-line-height-relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secondary Card - Recordings styling
|
|
||||||
.secondary-card {
|
|
||||||
.card-header {
|
|
||||||
background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-accent) 180%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join Form - Form styling
|
|
||||||
.join-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--ov-meet-spacing-lg);
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
.name-field {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.mat-mdc-form-field-icon-suffix {
|
|
||||||
color: var(--ov-meet-text-hint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.join-button {
|
|
||||||
@include design-tokens.ov-button-base;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--ov-meet-spacing-sm);
|
|
||||||
margin-top: auto;
|
|
||||||
background-color: var(--ov-meet-color-secondary);
|
|
||||||
color: var(--ov-meet-text-on-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recordings Info - Content for recordings card
|
|
||||||
.recordings-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--ov-meet-spacing-lg);
|
|
||||||
|
|
||||||
.recordings-description {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--ov-meet-font-size-md);
|
|
||||||
color: var(--ov-meet-text-secondary);
|
|
||||||
line-height: var(--ov-meet-line-height-relaxed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.recordings-button {
|
|
||||||
@include design-tokens.ov-button-base;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
height: 100%;
|
||||||
gap: var(--ov-meet-spacing-sm);
|
|
||||||
margin-top: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick Actions - Footer actions
|
|
||||||
.quick-actions {
|
|
||||||
@include design-tokens.ov-flex-center;
|
|
||||||
margin-top: var(--ov-meet-spacing-xl);
|
|
||||||
|
|
||||||
.quick-action-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--ov-meet-spacing-sm);
|
|
||||||
color: var(--ov-meet-text-secondary);
|
|
||||||
@include design-tokens.ov-theme-transition;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--ov-meet-text-primary);
|
|
||||||
background-color: var(--ov-meet-surface-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-meeting-link-container {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
|
|
||||||
.main-share-meeting-link {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive adjustments
|
|
||||||
@include design-tokens.ov-mobile-down {
|
|
||||||
.room-access-container {
|
|
||||||
padding: 0;
|
|
||||||
padding-top: var(--ov-meet-spacing-sm);
|
|
||||||
margin-bottom: var(--ov-meet-spacing-xxl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.room-header {
|
|
||||||
margin-bottom: var(--ov-meet-spacing-xl);
|
|
||||||
|
|
||||||
.room-info .room-title {
|
|
||||||
font-size: var(--ov-meet-font-size-xxl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-card {
|
|
||||||
min-height: auto;
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
padding: var(--ov-meet-spacing-md);
|
|
||||||
|
|
||||||
.card-title-group {
|
|
||||||
.mat-mdc-card-title {
|
|
||||||
font-size: var(--ov-meet-font-size-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
padding: var(--ov-meet-spacing-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom leave button styling (existing functionality)
|
|
||||||
::ng-deep {
|
|
||||||
#media-buttons-container .custom-leave-btn {
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--ov-meet-color-error) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
border-radius: var(--ov-meet-radius-md) !important;
|
|
||||||
width: 65px;
|
|
||||||
margin: 6px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.force-disconnect-btn,
|
|
||||||
.remove-moderator-btn {
|
|
||||||
color: var(--ov-meet-color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-moderator-btn {
|
|
||||||
color: var(--ov-meet-color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.participant-item-container {
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
::ng-deep .participant-container {
|
|
||||||
padding: 2px 10px !important;
|
|
||||||
}
|
|
||||||
.moderator-badge {
|
|
||||||
color: var(--ov-meet-color-warning);
|
|
||||||
mat-icon {
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +1,36 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard';
|
import { Clipboard } from '@angular/cdk/clipboard';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, NgComponentOutlet } from '@angular/common';
|
||||||
import { Component, effect, OnInit, Signal } from '@angular/core';
|
import { Component, computed, effect, inject, OnInit, Signal, signal } from '@angular/core';
|
||||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule, MatIconButton } from '@angular/material/button';
|
import { CustomParticipantModel } from '../../models';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MeetingComponentsPlugins, MEETING_COMPONENTS_TOKEN, MEETING_ACTION_HANDLER_TOKEN } from '../../customization';
|
||||||
import { MatRippleModule } from '@angular/material/core';
|
|
||||||
import { MatDividerModule } from '@angular/material/divider';
|
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
|
||||||
import { MatInputModule } from '@angular/material/input';
|
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
|
||||||
import { ShareMeetingLinkComponent } from '../../components';
|
|
||||||
import { CustomParticipantModel, ErrorReason } from '../../models';
|
|
||||||
import {
|
import {
|
||||||
AppDataService,
|
|
||||||
ApplicationFeatures,
|
ApplicationFeatures,
|
||||||
AuthService,
|
|
||||||
FeatureConfigurationService,
|
FeatureConfigurationService,
|
||||||
GlobalConfigService,
|
GlobalConfigService,
|
||||||
MeetingService,
|
MeetingService,
|
||||||
NavigationService,
|
|
||||||
NotificationService,
|
NotificationService,
|
||||||
ParticipantService,
|
ParticipantService,
|
||||||
RecordingService,
|
WebComponentManagerService,
|
||||||
RoomService,
|
MeetingEventHandlerService
|
||||||
SessionStorageService,
|
|
||||||
TokenStorageService,
|
|
||||||
WebComponentManagerService
|
|
||||||
} from '../../services';
|
} from '../../services';
|
||||||
import {
|
import { MeetRoom, ParticipantRole } from '@openvidu-meet/typings';
|
||||||
LeftEventReason,
|
|
||||||
MeetRoom,
|
|
||||||
MeetRoomStatus,
|
|
||||||
ParticipantRole,
|
|
||||||
WebComponentEvent,
|
|
||||||
WebComponentOutboundEventMessage,
|
|
||||||
MeetParticipantRoleUpdatedPayload,
|
|
||||||
MeetRoomConfigUpdatedPayload,
|
|
||||||
MeetSignalType
|
|
||||||
} from '@openvidu-meet/typings';
|
|
||||||
import {
|
import {
|
||||||
ParticipantService as ComponentParticipantService,
|
ParticipantService as ComponentParticipantService,
|
||||||
DataPacket_Kind,
|
|
||||||
OpenViduComponentsUiModule,
|
OpenViduComponentsUiModule,
|
||||||
OpenViduService,
|
OpenViduService,
|
||||||
OpenViduThemeMode,
|
OpenViduThemeMode,
|
||||||
OpenViduThemeService,
|
OpenViduThemeService,
|
||||||
ParticipantLeftEvent,
|
|
||||||
ParticipantLeftReason,
|
|
||||||
ParticipantModel,
|
|
||||||
RecordingStartRequestedEvent,
|
|
||||||
RecordingStopRequestedEvent,
|
|
||||||
RemoteParticipant,
|
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
|
||||||
Track,
|
Track,
|
||||||
ViewportService
|
ViewportService
|
||||||
} from 'openvidu-components-angular';
|
} from 'openvidu-components-angular';
|
||||||
import { combineLatest, Subject, takeUntil } from 'rxjs';
|
import { combineLatest, Subject, takeUntil } from 'rxjs';
|
||||||
|
import { MeetingLobbyService } from '../../services/meeting/meeting-lobby.service';
|
||||||
|
import { MeetingPluginManagerService } from '../../services/meeting/meeting-plugin-manager.service';
|
||||||
|
import { LobbyState } from '../../models/lobby.model';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ov-meeting',
|
selector: 'ov-meeting',
|
||||||
@ -67,71 +38,56 @@ import { combineLatest, Subject, takeUntil } from 'rxjs';
|
|||||||
styleUrls: ['./meeting.component.scss'],
|
styleUrls: ['./meeting.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
OpenViduComponentsUiModule,
|
OpenViduComponentsUiModule,
|
||||||
// ApiDirectiveModule,
|
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MatFormFieldModule,
|
|
||||||
MatInputModule,
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
MatCardModule,
|
NgComponentOutlet,
|
||||||
MatButtonModule,
|
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatIconButton,
|
MatProgressSpinnerModule
|
||||||
MatMenuModule,
|
],
|
||||||
MatDividerModule,
|
providers: [MeetingLobbyService, MeetingPluginManagerService, MeetingEventHandlerService]
|
||||||
MatTooltipModule,
|
|
||||||
MatRippleModule,
|
|
||||||
ShareMeetingLinkComponent
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
export class MeetingComponent implements OnInit {
|
export class MeetingComponent implements OnInit {
|
||||||
participantForm = new FormGroup({
|
lobbyState?: LobbyState;
|
||||||
name: new FormControl('', [Validators.required])
|
protected localParticipant = signal<CustomParticipantModel | undefined>(undefined);
|
||||||
});
|
|
||||||
|
|
||||||
hasRecordings = false;
|
// Reactive signal for remote participants to trigger computed updates
|
||||||
showRecordingCard = false;
|
protected remoteParticipants = signal<CustomParticipantModel[]>([]);
|
||||||
roomClosed = false;
|
|
||||||
|
|
||||||
showBackButton = true;
|
// Signal to track participant updates (role changes, etc.) that don't change array references
|
||||||
backButtonText = 'Back';
|
protected participantsVersion = signal<number>(0);
|
||||||
|
|
||||||
room?: MeetRoom;
|
showPrejoin = true;
|
||||||
roomId = '';
|
prejoinReady = false;
|
||||||
roomSecret = '';
|
|
||||||
participantName = '';
|
|
||||||
participantToken = '';
|
|
||||||
localParticipant?: CustomParticipantModel;
|
|
||||||
remoteParticipants: CustomParticipantModel[] = [];
|
|
||||||
|
|
||||||
showMeeting = false;
|
|
||||||
features: Signal<ApplicationFeatures>;
|
features: Signal<ApplicationFeatures>;
|
||||||
meetingEndedByMe = false;
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
// Injected plugins
|
||||||
|
plugins: MeetingComponentsPlugins;
|
||||||
|
|
||||||
constructor(
|
protected meetingService = inject(MeetingService);
|
||||||
protected route: ActivatedRoute,
|
protected participantService = inject(ParticipantService);
|
||||||
protected roomService: RoomService,
|
protected featureConfService = inject(FeatureConfigurationService);
|
||||||
protected meetingService: MeetingService,
|
protected wcManagerService = inject(WebComponentManagerService);
|
||||||
protected participantService: ParticipantService,
|
protected openviduService = inject(OpenViduService);
|
||||||
protected recordingService: RecordingService,
|
protected ovComponentsParticipantService = inject(ComponentParticipantService);
|
||||||
protected featureConfService: FeatureConfigurationService,
|
protected viewportService = inject(ViewportService);
|
||||||
protected authService: AuthService,
|
protected ovThemeService = inject(OpenViduThemeService);
|
||||||
protected appDataService: AppDataService,
|
protected configService = inject(GlobalConfigService);
|
||||||
protected sessionStorageService: SessionStorageService,
|
protected clipboard = inject(Clipboard);
|
||||||
protected wcManagerService: WebComponentManagerService,
|
protected notificationService = inject(NotificationService);
|
||||||
protected openviduService: OpenViduService,
|
protected lobbyService = inject(MeetingLobbyService);
|
||||||
protected ovComponentsParticipantService: ComponentParticipantService,
|
protected pluginManager = inject(MeetingPluginManagerService);
|
||||||
protected navigationService: NavigationService,
|
|
||||||
protected notificationService: NotificationService,
|
// Public for direct template binding (uses arrow functions to preserve 'this' context)
|
||||||
protected clipboard: Clipboard,
|
public eventHandler = inject(MeetingEventHandlerService);
|
||||||
protected viewportService: ViewportService,
|
|
||||||
protected ovThemeService: OpenViduThemeService,
|
// Injected action handler (optional - falls back to default implementation)
|
||||||
protected configService: GlobalConfigService,
|
protected actionHandler = inject(MEETING_ACTION_HANDLER_TOKEN, { optional: true });
|
||||||
protected tokenStorageService: TokenStorageService
|
protected destroy$ = new Subject<void>();
|
||||||
) {
|
|
||||||
|
constructor() {
|
||||||
this.features = this.featureConfService.features;
|
this.features = this.featureConfService.features;
|
||||||
|
this.plugins = inject(MEETING_COMPONENTS_TOKEN, { optional: true }) || {};
|
||||||
|
|
||||||
// Change theme variables when custom theme is enabled
|
// Change theme variables when custom theme is enabled
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@ -151,8 +107,125 @@ export class MeetingComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
protected layoutAdditionalElementsInputs = computed(() => {
|
||||||
|
const showOverlay = this.onlyModeratorIsPresent;
|
||||||
|
return this.pluginManager.getLayoutAdditionalElementsInputs(
|
||||||
|
showOverlay,
|
||||||
|
`${this.hostname}/room/${this.roomId}`,
|
||||||
|
() => this.handleCopySpeakerLink()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
protected lobbyInputs = computed(() => {
|
||||||
|
if (!this.lobbyState) return {};
|
||||||
|
return this.pluginManager.getLobbyInputs(
|
||||||
|
this.roomName,
|
||||||
|
`${this.hostname}/room/${this.roomId}`,
|
||||||
|
this.lobbyState.roomClosed,
|
||||||
|
this.lobbyState.showRecordingCard,
|
||||||
|
!this.lobbyState.roomClosed && this.features().canModerateRoom,
|
||||||
|
this.lobbyState.showBackButton,
|
||||||
|
this.lobbyState.backButtonText,
|
||||||
|
this.lobbyState.participantForm,
|
||||||
|
() => 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 participantToken(): string {
|
||||||
|
return this.lobbyState!.participantToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
get room(): MeetRoom | undefined {
|
||||||
|
return this.lobbyState?.room;
|
||||||
|
}
|
||||||
|
|
||||||
get roomName(): string {
|
get roomName(): string {
|
||||||
return this.room?.roomName || 'Room';
|
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 {
|
get hostname(): string {
|
||||||
@ -164,14 +237,18 @@ export class MeetingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.roomId = this.roomService.getRoomId();
|
try {
|
||||||
this.roomSecret = this.roomService.getRoomSecret();
|
this.lobbyState = await this.lobbyService.initialize();
|
||||||
this.room = await this.roomService.getRoom(this.roomId);
|
this.prejoinReady = true;
|
||||||
this.roomClosed = this.room.status === MeetRoomStatus.CLOSED;
|
} catch (error) {
|
||||||
|
console.error('Error initializing lobby state:', error);
|
||||||
await this.setBackButtonText();
|
this.notificationService.showDialog({
|
||||||
await this.checkForRecordings();
|
title: 'Error',
|
||||||
await this.initializeParticipantName();
|
message: 'An error occurred while initializing the meeting lobby. Please try again later.',
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmText: 'OK'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@ -179,129 +256,14 @@ export class MeetingComponent implements OnInit {
|
|||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the back button text based on the application mode and user role
|
|
||||||
*/
|
|
||||||
private async setBackButtonText() {
|
|
||||||
const isStandaloneMode = this.appDataService.isStandaloneMode();
|
|
||||||
const redirection = this.navigationService.getLeaveRedirectURL();
|
|
||||||
const isAdmin = await this.authService.isAdmin();
|
|
||||||
|
|
||||||
if (isStandaloneMode && !redirection && !isAdmin) {
|
|
||||||
// If in standalone mode, no redirection URL and not an admin, hide the back button
|
|
||||||
this.showBackButton = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showBackButton = true;
|
|
||||||
this.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if there are recordings in the room and updates the visibility of the recordings card.
|
|
||||||
*
|
|
||||||
* It is necessary to previously generate a recording token in order to list the recordings.
|
|
||||||
* If token generation fails or the user does not have sufficient permissions to list recordings,
|
|
||||||
* the error will be caught and the recordings card will be hidden (`showRecordingCard` will be set to `false`).
|
|
||||||
*
|
|
||||||
* If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`.
|
|
||||||
*/
|
|
||||||
private async checkForRecordings() {
|
|
||||||
try {
|
|
||||||
const { canRetrieveRecordings } = await this.recordingService.generateRecordingToken(
|
|
||||||
this.roomId,
|
|
||||||
this.roomSecret
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canRetrieveRecordings) {
|
|
||||||
this.showRecordingCard = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { recordings } = await this.recordingService.listRecordings({
|
|
||||||
maxItems: 1,
|
|
||||||
roomId: this.roomId,
|
|
||||||
fields: 'recordingId'
|
|
||||||
});
|
|
||||||
this.hasRecordings = recordings.length > 0;
|
|
||||||
this.showRecordingCard = this.hasRecordings;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for recordings:', error);
|
|
||||||
this.showRecordingCard = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the participant name in the form control.
|
|
||||||
*
|
|
||||||
* Retrieves the participant name from the ParticipantTokenService first, and if not available,
|
|
||||||
* falls back to the authenticated username. Sets the retrieved name value in the
|
|
||||||
* participant form's 'name' control if a valid name is found.
|
|
||||||
*
|
|
||||||
* @returns A promise that resolves when the participant name has been initialized
|
|
||||||
*/
|
|
||||||
private async initializeParticipantName() {
|
|
||||||
// Apply participant name from ParticipantTokenService if set, otherwise use authenticated username
|
|
||||||
const currentParticipantName = this.participantService.getParticipantName();
|
|
||||||
const username = await this.authService.getUsername();
|
|
||||||
const participantName = currentParticipantName || username;
|
|
||||||
|
|
||||||
if (participantName) {
|
|
||||||
this.participantForm.get('name')?.setValue(participantName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToRecordings() {
|
|
||||||
try {
|
|
||||||
await this.navigationService.navigateTo(`room/${this.roomId}/recordings`, { secret: this.roomSecret });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error navigating to recordings:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the back button click event and navigates accordingly
|
|
||||||
* If in embedded mode, it closes the WebComponentManagerService
|
|
||||||
* If the redirect URL is set, it navigates to that URL
|
|
||||||
* If in standalone mode without a redirect URL, it navigates to the rooms page
|
|
||||||
*/
|
|
||||||
async goBack() {
|
|
||||||
if (this.appDataService.isEmbeddedMode()) {
|
|
||||||
this.wcManagerService.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirectTo = this.navigationService.getLeaveRedirectURL();
|
|
||||||
if (redirectTo) {
|
|
||||||
// Navigate to the specified redirect URL
|
|
||||||
await this.navigationService.redirectToLeaveUrl();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.appDataService.isStandaloneMode()) {
|
|
||||||
// Navigate to rooms page
|
|
||||||
await this.navigationService.navigateTo('/rooms');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitAccessMeeting() {
|
async submitAccessMeeting() {
|
||||||
const { valid, value } = this.participantForm;
|
|
||||||
if (!valid || !value.name?.trim()) {
|
|
||||||
// If the form is invalid, do not proceed
|
|
||||||
console.warn('Participant form is invalid. Cannot access meeting.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.participantName = value.name.trim();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.generateParticipantToken();
|
await this.lobbyService.submitAccess();
|
||||||
await this.addParticipantNameToUrl();
|
|
||||||
await this.roomService.loadRoomConfig(this.roomId);
|
|
||||||
|
|
||||||
// The meeting view must be shown before loading the appearance config,
|
// The meeting view must be shown before loading the appearance config,
|
||||||
// as it contains theme information that might be applied immediately
|
// as it contains theme information that might be applied immediately
|
||||||
// when the meeting view is rendered
|
// when the meeting view is rendered
|
||||||
this.showMeeting = true;
|
this.showPrejoin = false;
|
||||||
await this.configService.loadRoomsAppearanceConfig();
|
await this.configService.loadRoomsAppearanceConfig();
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
@ -310,8 +272,15 @@ export class MeetingComponent implements OnInit {
|
|||||||
])
|
])
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(([participants, local]) => {
|
.subscribe(([participants, local]) => {
|
||||||
this.remoteParticipants = participants as CustomParticipantModel[];
|
this.remoteParticipants.set(participants as CustomParticipantModel[]);
|
||||||
this.localParticipant = local as CustomParticipantModel;
|
this.localParticipant.set(local as CustomParticipantModel);
|
||||||
|
|
||||||
|
// 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();
|
this.updateVideoPinState();
|
||||||
});
|
});
|
||||||
@ -320,205 +289,24 @@ export class MeetingComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Centralized logic for managing video pinning based on
|
|
||||||
* remote participants and local screen sharing state.
|
|
||||||
*/
|
|
||||||
private updateVideoPinState(): void {
|
|
||||||
if (!this.localParticipant) return;
|
|
||||||
|
|
||||||
const hasRemote = this.remoteParticipants.length > 0;
|
|
||||||
const isSharing = this.localParticipant.isScreenShareEnabled;
|
|
||||||
|
|
||||||
if (hasRemote && isSharing) {
|
|
||||||
// Pin the local screen share to appear bigger
|
|
||||||
this.localParticipant.setVideoPinnedBySource(Track.Source.ScreenShare, true);
|
|
||||||
} else {
|
|
||||||
// Unpin everything if no remote participants or not sharing
|
|
||||||
this.localParticipant.setAllVideoPinned(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a participant token for joining a meeting.
|
|
||||||
*
|
|
||||||
* @throws When participant already exists in the room (status 409)
|
|
||||||
* @returns Promise that resolves when token is generated
|
|
||||||
*/
|
|
||||||
private async generateParticipantToken() {
|
|
||||||
try {
|
|
||||||
this.participantToken = await this.participantService.generateToken({
|
|
||||||
roomId: this.roomId,
|
|
||||||
secret: this.roomSecret,
|
|
||||||
participantName: this.participantName
|
|
||||||
});
|
|
||||||
this.participantName = this.participantService.getParticipantName()!;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error generating participant token:', error);
|
|
||||||
switch (error.status) {
|
|
||||||
case 400:
|
|
||||||
// Invalid secret
|
|
||||||
await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET, true);
|
|
||||||
break;
|
|
||||||
case 404:
|
|
||||||
// Room not found
|
|
||||||
await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM, true);
|
|
||||||
break;
|
|
||||||
case 409:
|
|
||||||
// Room is closed
|
|
||||||
await this.navigationService.redirectToErrorPage(ErrorReason.CLOSED_ROOM, true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Error generating participant token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add participant name as a query parameter to the URL
|
|
||||||
*/
|
|
||||||
private async addParticipantNameToUrl() {
|
|
||||||
await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, {
|
|
||||||
'participant-name': this.participantName
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onRoomCreated(room: Room) {
|
onRoomCreated(room: Room) {
|
||||||
room.on(
|
this.eventHandler.setupRoomListeners(room, {
|
||||||
RoomEvent.DataReceived,
|
roomId: this.roomId,
|
||||||
async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => {
|
roomSecret: this.roomSecret,
|
||||||
const event = JSON.parse(new TextDecoder().decode(payload));
|
participantName: this.participantName,
|
||||||
|
localParticipant: () => this.localParticipant(),
|
||||||
switch (topic) {
|
remoteParticipants: () => this.remoteParticipants(),
|
||||||
case 'recordingStopped': {
|
onHasRecordingsChanged: (hasRecordings) => {
|
||||||
// If a 'recordingStopped' event is received and there was no previous recordings,
|
this.hasRecordings = hasRecordings;
|
||||||
// update the hasRecordings flag and refresh the recording token
|
},
|
||||||
if (this.hasRecordings) return;
|
onRoomSecretChanged: (secret) => {
|
||||||
|
this.roomSecret = secret;
|
||||||
this.hasRecordings = true;
|
},
|
||||||
|
onParticipantRoleUpdated: () => {
|
||||||
try {
|
// Increment version to trigger reactivity in participant panel items
|
||||||
await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret);
|
this.participantsVersion.update((v) => v + 1);
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing recording token:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case MeetSignalType.MEET_ROOM_CONFIG_UPDATED: {
|
|
||||||
// Update room config
|
|
||||||
const { config } = event as MeetRoomConfigUpdatedPayload;
|
|
||||||
this.featureConfService.setRoomConfig(config);
|
|
||||||
|
|
||||||
// Refresh recording token if recording is enabled
|
|
||||||
if (config.recording.enabled) {
|
|
||||||
try {
|
|
||||||
await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing recording token:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: {
|
|
||||||
// Update participant role
|
|
||||||
const { participantIdentity, newRole, secret } = event as MeetParticipantRoleUpdatedPayload;
|
|
||||||
|
|
||||||
if (participantIdentity === this.localParticipant!.identity) {
|
|
||||||
if (!secret) return;
|
|
||||||
|
|
||||||
this.roomSecret = secret;
|
|
||||||
this.roomService.setRoomSecret(secret, false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.participantService.refreshParticipantToken({
|
|
||||||
roomId: this.roomId,
|
|
||||||
secret,
|
|
||||||
participantName: this.participantName,
|
|
||||||
participantIdentity
|
|
||||||
});
|
|
||||||
|
|
||||||
this.localParticipant!.meetRole = newRole;
|
|
||||||
this.notificationService.showSnackbar(`You have been assigned the role of ${newRole}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing participant token to update role:', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const participant = this.remoteParticipants.find((p) => p.identity === participantIdentity);
|
|
||||||
if (participant) {
|
|
||||||
participant.meetRole = newRole;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
onParticipantConnected(event: ParticipantModel) {
|
|
||||||
const message: WebComponentOutboundEventMessage<WebComponentEvent.JOINED> = {
|
|
||||||
event: WebComponentEvent.JOINED,
|
|
||||||
payload: {
|
|
||||||
roomId: event.getProperties().room?.name || '',
|
|
||||||
participantIdentity: event.identity
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.wcManagerService.sendMessageToParent(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onParticipantLeft(event: ParticipantLeftEvent) {
|
|
||||||
let leftReason = this.getReasonParamFromEvent(event.reason);
|
|
||||||
if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) {
|
|
||||||
leftReason = LeftEventReason.MEETING_ENDED_BY_SELF;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send LEFT event to the parent component
|
|
||||||
const message: WebComponentOutboundEventMessage<WebComponentEvent.LEFT> = {
|
|
||||||
event: WebComponentEvent.LEFT,
|
|
||||||
payload: {
|
|
||||||
roomId: event.roomName,
|
|
||||||
participantIdentity: event.participantName,
|
|
||||||
reason: leftReason
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.wcManagerService.sendMessageToParent(message);
|
|
||||||
|
|
||||||
// Remove the moderator secret (and stored tokens) from session storage
|
|
||||||
// if the participant left for a reason other than browser unload
|
|
||||||
if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) {
|
|
||||||
this.sessionStorageService.removeRoomSecret();
|
|
||||||
this.tokenStorageService.clearParticipantToken();
|
|
||||||
this.tokenStorageService.clearRecordingToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to the disconnected page with the reason
|
|
||||||
await this.navigationService.navigateTo('disconnected', { reason: leftReason }, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps ParticipantLeftReason to LeftEventReason.
|
|
||||||
* This method translates the technical reasons for a participant leaving the room
|
|
||||||
* into user-friendly reasons that can be used in the UI or for logging purposes.
|
|
||||||
* @param reason The technical reason for the participant leaving the room.
|
|
||||||
* @returns The corresponding LeftEventReason.
|
|
||||||
*/
|
|
||||||
private getReasonParamFromEvent(reason: ParticipantLeftReason): LeftEventReason {
|
|
||||||
const reasonMap: Record<ParticipantLeftReason, LeftEventReason> = {
|
|
||||||
[ParticipantLeftReason.LEAVE]: LeftEventReason.VOLUNTARY_LEAVE,
|
|
||||||
[ParticipantLeftReason.BROWSER_UNLOAD]: LeftEventReason.VOLUNTARY_LEAVE,
|
|
||||||
[ParticipantLeftReason.NETWORK_DISCONNECT]: LeftEventReason.NETWORK_DISCONNECT,
|
|
||||||
[ParticipantLeftReason.SIGNAL_CLOSE]: LeftEventReason.NETWORK_DISCONNECT,
|
|
||||||
[ParticipantLeftReason.SERVER_SHUTDOWN]: LeftEventReason.SERVER_SHUTDOWN,
|
|
||||||
[ParticipantLeftReason.PARTICIPANT_REMOVED]: LeftEventReason.PARTICIPANT_KICKED,
|
|
||||||
[ParticipantLeftReason.ROOM_DELETED]: LeftEventReason.MEETING_ENDED,
|
|
||||||
[ParticipantLeftReason.DUPLICATE_IDENTITY]: LeftEventReason.UNKNOWN,
|
|
||||||
[ParticipantLeftReason.OTHER]: LeftEventReason.UNKNOWN
|
|
||||||
};
|
|
||||||
return reasonMap[reason] ?? LeftEventReason.UNKNOWN;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async leaveMeeting() {
|
async leaveMeeting() {
|
||||||
@ -528,96 +316,125 @@ export class MeetingComponent implements OnInit {
|
|||||||
async endMeeting() {
|
async endMeeting() {
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
if (!this.participantService.isModeratorParticipant()) return;
|
||||||
|
|
||||||
this.meetingEndedByMe = true;
|
this.eventHandler.setMeetingEndedByMe(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.meetingService.endMeeting(this.roomId);
|
await this.meetingService.endMeeting(this.roomId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error ending meeting:', error);
|
console.error('Error ending meeting:', error);
|
||||||
this.notificationService.showSnackbar('Failed to end meeting');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async kickParticipant(participant: CustomParticipantModel) {
|
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.meetingService.kickParticipant(this.roomId, participant.identity);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error kicking participant:', error);
|
|
||||||
this.notificationService.showSnackbar('Failed to kick participant');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes a participant as moderator.
|
|
||||||
* @param participant The participant to make as moderator.
|
|
||||||
*/
|
|
||||||
async makeModerator(participant: CustomParticipantModel) {
|
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.meetingService.changeParticipantRole(
|
|
||||||
this.roomId,
|
|
||||||
participant.identity,
|
|
||||||
ParticipantRole.MODERATOR
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error making participant moderator:', error);
|
|
||||||
this.notificationService.showSnackbar('Failed to make participant moderator');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unmakes a participant as moderator.
|
|
||||||
* @param participant The participant to unmake as moderator.
|
|
||||||
*/
|
|
||||||
async unmakeModerator(participant: CustomParticipantModel) {
|
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.meetingService.changeParticipantRole(this.roomId, participant.identity, ParticipantRole.SPEAKER);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error unmaking participant moderator:', error);
|
|
||||||
this.notificationService.showSnackbar('Failed to unmake participant moderator');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyModeratorLink() {
|
|
||||||
this.clipboard.copy(this.room!.moderatorUrl);
|
|
||||||
this.notificationService.showSnackbar('Moderator link copied to clipboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
async copySpeakerLink() {
|
|
||||||
this.clipboard.copy(this.room!.speakerUrl);
|
|
||||||
this.notificationService.showSnackbar('Speaker link copied to clipboard');
|
|
||||||
}
|
|
||||||
|
|
||||||
async onRecordingStartRequested(event: RecordingStartRequestedEvent) {
|
|
||||||
try {
|
|
||||||
await this.recordingService.startRecording(event.roomName);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if ((error as any).status === 503) {
|
|
||||||
console.error(
|
|
||||||
`No egress service was able to register a request.
|
|
||||||
Check your CPU usage or if there's any Media Node with enough CPU.
|
|
||||||
Remember that by default, a recording uses 4 CPUs for each room.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onRecordingStopRequested(event: RecordingStopRequestedEvent) {
|
|
||||||
try {
|
|
||||||
await this.recordingService.stopRecording(event.recordingId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onViewRecordingsClicked() {
|
async onViewRecordingsClicked() {
|
||||||
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
|
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized logic for managing video pinning based on
|
||||||
|
* remote participants and local screen sharing state.
|
||||||
|
*/
|
||||||
|
protected updateVideoPinState(): void {
|
||||||
|
if (!this.localParticipant) return;
|
||||||
|
|
||||||
|
const isSharing = this.localParticipant()?.isScreenShareEnabled;
|
||||||
|
|
||||||
|
if (this.hasRemoteParticipants && isSharing) {
|
||||||
|
// Pin the local screen share to appear bigger
|
||||||
|
this.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.isModeratorParticipant()) 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.isModeratorParticipant()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.meetingService.changeParticipantRole(
|
||||||
|
this.roomId,
|
||||||
|
participant.identity,
|
||||||
|
ParticipantRole.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.isModeratorParticipant()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.meetingService.changeParticipantRole(
|
||||||
|
this.roomId,
|
||||||
|
participant.identity,
|
||||||
|
ParticipantRole.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,10 @@ export * from './auth.service';
|
|||||||
export * from './global-config.service';
|
export * from './global-config.service';
|
||||||
export * from './room.service';
|
export * from './room.service';
|
||||||
export * from './participant.service';
|
export * from './participant.service';
|
||||||
export * from './meeting.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 './feature-configuration.service';
|
export * from './feature-configuration.service';
|
||||||
export * from './recording.service';
|
export * from './recording.service';
|
||||||
export * from './webcomponent-manager.service';
|
export * from './webcomponent-manager.service';
|
||||||
|
|||||||
@ -0,0 +1,359 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import {
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
DataPacket_Kind,
|
||||||
|
RemoteParticipant,
|
||||||
|
ParticipantLeftEvent,
|
||||||
|
ParticipantLeftReason,
|
||||||
|
RecordingStartRequestedEvent,
|
||||||
|
RecordingStopRequestedEvent,
|
||||||
|
ParticipantModel
|
||||||
|
} from 'openvidu-components-angular';
|
||||||
|
import {
|
||||||
|
FeatureConfigurationService,
|
||||||
|
RecordingService,
|
||||||
|
ParticipantService,
|
||||||
|
RoomService,
|
||||||
|
SessionStorageService,
|
||||||
|
TokenStorageService,
|
||||||
|
WebComponentManagerService,
|
||||||
|
NavigationService
|
||||||
|
} from '../../services';
|
||||||
|
import {
|
||||||
|
LeftEventReason,
|
||||||
|
MeetSignalType,
|
||||||
|
MeetParticipantRoleUpdatedPayload,
|
||||||
|
MeetRoomConfigUpdatedPayload,
|
||||||
|
WebComponentEvent,
|
||||||
|
WebComponentOutboundEventMessage
|
||||||
|
} from '@openvidu-meet/typings';
|
||||||
|
import { CustomParticipantModel } from '../../models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MeetingEventHandlerService {
|
||||||
|
// Injected services
|
||||||
|
protected featureConfService = inject(FeatureConfigurationService);
|
||||||
|
protected recordingService = inject(RecordingService);
|
||||||
|
protected participantService = inject(ParticipantService);
|
||||||
|
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
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up all room event listeners when room is created.
|
||||||
|
* 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 {
|
||||||
|
room.on(
|
||||||
|
RoomEvent.DataReceived,
|
||||||
|
async (
|
||||||
|
payload: Uint8Array,
|
||||||
|
_participant?: RemoteParticipant,
|
||||||
|
_kind?: DataPacket_Kind,
|
||||||
|
topic?: string
|
||||||
|
) => {
|
||||||
|
const event = JSON.parse(new TextDecoder().decode(payload));
|
||||||
|
|
||||||
|
switch (topic) {
|
||||||
|
case 'recordingStopped':
|
||||||
|
await this.handleRecordingStopped(
|
||||||
|
context.roomId,
|
||||||
|
context.roomSecret,
|
||||||
|
context.onHasRecordingsChanged
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MeetSignalType.MEET_ROOM_CONFIG_UPDATED:
|
||||||
|
await this.handleRoomConfigUpdated(event, context.roomId, context.roomSecret);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED:
|
||||||
|
await this.handleParticipantRoleUpdated(
|
||||||
|
event,
|
||||||
|
context.roomId,
|
||||||
|
context.participantName,
|
||||||
|
context.localParticipant,
|
||||||
|
context.remoteParticipants,
|
||||||
|
context.onRoomSecretChanged,
|
||||||
|
context.onParticipantRoleUpdated
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles participant connected event.
|
||||||
|
* Sends JOINED event to parent window (for web component integration).
|
||||||
|
*
|
||||||
|
* Arrow function ensures correct 'this' binding when called from template.
|
||||||
|
*
|
||||||
|
* @param event Participant model from OpenVidu
|
||||||
|
*/
|
||||||
|
onParticipantConnected = (event: ParticipantModel): void => {
|
||||||
|
const message: WebComponentOutboundEventMessage<WebComponentEvent.JOINED> = {
|
||||||
|
event: WebComponentEvent.JOINED,
|
||||||
|
payload: {
|
||||||
|
roomId: event.getProperties().room?.name || '',
|
||||||
|
participantIdentity: event.identity
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.wcManagerService.sendMessageToParent(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles participant left event.
|
||||||
|
* - Maps technical reason to user-friendly reason
|
||||||
|
* - Sends LEFT event to parent window
|
||||||
|
* - Cleans up session storage (secrets, tokens)
|
||||||
|
* - Navigates to disconnected page
|
||||||
|
*
|
||||||
|
* Arrow function ensures correct 'this' binding when called from template.
|
||||||
|
*
|
||||||
|
* @param event Participant left event from OpenVidu
|
||||||
|
*/
|
||||||
|
onParticipantLeft = async (event: ParticipantLeftEvent): Promise<void> => {
|
||||||
|
let leftReason = this.mapLeftReason(event.reason);
|
||||||
|
|
||||||
|
// If meeting was ended by this user, update reason
|
||||||
|
if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) {
|
||||||
|
leftReason = LeftEventReason.MEETING_ENDED_BY_SELF;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send LEFT event to parent window
|
||||||
|
const message: WebComponentOutboundEventMessage<WebComponentEvent.LEFT> = {
|
||||||
|
event: WebComponentEvent.LEFT,
|
||||||
|
payload: {
|
||||||
|
roomId: event.roomName,
|
||||||
|
participantIdentity: event.participantName,
|
||||||
|
reason: leftReason
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.wcManagerService.sendMessageToParent(message);
|
||||||
|
|
||||||
|
// Clean up storage (except on browser unload)
|
||||||
|
if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) {
|
||||||
|
this.sessionStorageService.removeRoomSecret();
|
||||||
|
this.tokenStorageService.clearParticipantToken();
|
||||||
|
this.tokenStorageService.clearRecordingToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to disconnected page
|
||||||
|
await this.navigationService.navigateTo('disconnected', { reason: leftReason }, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles recording start request event.
|
||||||
|
*
|
||||||
|
* Arrow function ensures correct 'this' binding when called from template.
|
||||||
|
*
|
||||||
|
* @param event Recording start requested event from OpenVidu
|
||||||
|
*/
|
||||||
|
onRecordingStartRequested = async (event: RecordingStartRequestedEvent): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await this.recordingService.startRecording(event.roomName);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 503) {
|
||||||
|
console.error(
|
||||||
|
'No egress service available. Check CPU usage or Media Node capacity. ' +
|
||||||
|
'By default, a recording uses 2 CPUs per room.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Error starting recording:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles recording stop request event.
|
||||||
|
*
|
||||||
|
* Arrow function ensures correct 'this' binding when called from template.
|
||||||
|
*
|
||||||
|
* @param event Recording stop requested event from OpenVidu
|
||||||
|
*/
|
||||||
|
onRecordingStopRequested = async (event: RecordingStopRequestedEvent): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await this.recordingService.stopRecording(event.recordingId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error stopping recording:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles recording stopped event.
|
||||||
|
* Updates hasRecordings flag and refreshes recording token.
|
||||||
|
*/
|
||||||
|
private async handleRecordingStopped(
|
||||||
|
roomId: string,
|
||||||
|
roomSecret: string,
|
||||||
|
onHasRecordingsChanged: (hasRecordings: boolean) => void
|
||||||
|
): Promise<void> {
|
||||||
|
// Notify that recordings are now available
|
||||||
|
onHasRecordingsChanged(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refresh recording token to view recordings
|
||||||
|
await this.recordingService.generateRecordingToken(roomId, roomSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing recording token:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles room config updated event.
|
||||||
|
* Updates feature config and refreshes recording token if needed.
|
||||||
|
*/
|
||||||
|
private async handleRoomConfigUpdated(
|
||||||
|
event: MeetRoomConfigUpdatedPayload,
|
||||||
|
roomId: string,
|
||||||
|
roomSecret: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { config } = event;
|
||||||
|
|
||||||
|
// Update feature configuration
|
||||||
|
this.featureConfService.setRoomConfig(config);
|
||||||
|
|
||||||
|
// Refresh recording token if recording is enabled
|
||||||
|
if (config.recording.enabled) {
|
||||||
|
try {
|
||||||
|
await this.recordingService.generateRecordingToken(roomId, roomSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing recording token:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles participant role updated event.
|
||||||
|
* Updates local or remote participant role and refreshes token if needed.
|
||||||
|
*/
|
||||||
|
private async handleParticipantRoleUpdated(
|
||||||
|
event: MeetParticipantRoleUpdatedPayload,
|
||||||
|
roomId: string,
|
||||||
|
participantName: string,
|
||||||
|
localParticipant: () => CustomParticipantModel | undefined,
|
||||||
|
remoteParticipants: () => CustomParticipantModel[],
|
||||||
|
onRoomSecretChanged: (secret: string) => void,
|
||||||
|
onParticipantRoleUpdated?: () => void
|
||||||
|
): Promise<void> {
|
||||||
|
const { participantIdentity, newRole, secret } = event;
|
||||||
|
const local = localParticipant();
|
||||||
|
|
||||||
|
// Check if the role update is for the local participant
|
||||||
|
if (local && participantIdentity === local.identity) {
|
||||||
|
if (!secret) return;
|
||||||
|
|
||||||
|
// Update room secret
|
||||||
|
onRoomSecretChanged(secret);
|
||||||
|
this.roomService.setRoomSecret(secret, false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Refresh participant token with new role
|
||||||
|
await this.participantService.refreshParticipantToken({
|
||||||
|
roomId,
|
||||||
|
secret,
|
||||||
|
participantName,
|
||||||
|
participantIdentity
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local participant role
|
||||||
|
local.meetRole = newRole;
|
||||||
|
console.log(`You have been assigned the role of ${newRole}`);
|
||||||
|
|
||||||
|
// Notify component that participant role was updated
|
||||||
|
onParticipantRoleUpdated?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing participant token:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update remote participant role
|
||||||
|
const participant = remoteParticipants().find((p) => p.identity === participantIdentity);
|
||||||
|
if (participant) {
|
||||||
|
participant.meetRole = newRole;
|
||||||
|
|
||||||
|
// Notify component that participant role was updated
|
||||||
|
onParticipantRoleUpdated?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps technical ParticipantLeftReason to user-friendly LeftEventReason.
|
||||||
|
* This provides better messaging to users about why they left the room.
|
||||||
|
*/
|
||||||
|
private mapLeftReason(reason: ParticipantLeftReason): LeftEventReason {
|
||||||
|
const reasonMap: Record<ParticipantLeftReason, LeftEventReason> = {
|
||||||
|
[ParticipantLeftReason.LEAVE]: LeftEventReason.VOLUNTARY_LEAVE,
|
||||||
|
[ParticipantLeftReason.BROWSER_UNLOAD]: LeftEventReason.VOLUNTARY_LEAVE,
|
||||||
|
[ParticipantLeftReason.NETWORK_DISCONNECT]: LeftEventReason.NETWORK_DISCONNECT,
|
||||||
|
[ParticipantLeftReason.SIGNAL_CLOSE]: LeftEventReason.NETWORK_DISCONNECT,
|
||||||
|
[ParticipantLeftReason.SERVER_SHUTDOWN]: LeftEventReason.SERVER_SHUTDOWN,
|
||||||
|
[ParticipantLeftReason.PARTICIPANT_REMOVED]: LeftEventReason.PARTICIPANT_KICKED,
|
||||||
|
[ParticipantLeftReason.ROOM_DELETED]: LeftEventReason.MEETING_ENDED,
|
||||||
|
[ParticipantLeftReason.DUPLICATE_IDENTITY]: LeftEventReason.UNKNOWN,
|
||||||
|
[ParticipantLeftReason.OTHER]: LeftEventReason.UNKNOWN
|
||||||
|
};
|
||||||
|
return reasonMap[reason] ?? LeftEventReason.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,261 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
AuthService,
|
||||||
|
RecordingService,
|
||||||
|
RoomService,
|
||||||
|
ParticipantService,
|
||||||
|
NavigationService,
|
||||||
|
AppDataService,
|
||||||
|
WebComponentManagerService
|
||||||
|
} from '..';
|
||||||
|
import { MeetRoomStatus } from '@openvidu-meet/typings';
|
||||||
|
import { LobbyState } from '../../models/lobby.model';
|
||||||
|
import { ErrorReason } from '../../models';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service that manages the meeting lobby state and operations.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Initialize and maintain lobby state
|
||||||
|
* - Validate participant information
|
||||||
|
* - Check for recordings availability
|
||||||
|
* - Handle navigation (back button, recordings)
|
||||||
|
*
|
||||||
|
* This service coordinates multiple domain services to provide
|
||||||
|
* a simplified interface for the MeetingComponent.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class MeetingLobbyService {
|
||||||
|
private state: LobbyState = {
|
||||||
|
roomId: '',
|
||||||
|
roomSecret: '',
|
||||||
|
roomClosed: false,
|
||||||
|
hasRecordings: false,
|
||||||
|
showRecordingCard: false,
|
||||||
|
showBackButton: true,
|
||||||
|
backButtonText: 'Back',
|
||||||
|
participantForm: new FormGroup({
|
||||||
|
name: new FormControl('', [Validators.required])
|
||||||
|
}),
|
||||||
|
participantToken: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
protected roomService: RoomService = inject(RoomService);
|
||||||
|
protected recordingService: RecordingService = inject(RecordingService);
|
||||||
|
protected authService: AuthService = inject(AuthService);
|
||||||
|
protected participantService: ParticipantService = inject(ParticipantService);
|
||||||
|
protected navigationService: NavigationService = inject(NavigationService);
|
||||||
|
protected appDataService: AppDataService = inject(AppDataService);
|
||||||
|
protected wcManagerService: WebComponentManagerService = inject(WebComponentManagerService);
|
||||||
|
protected route: ActivatedRoute = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current lobby state
|
||||||
|
*/
|
||||||
|
get lobbyState(): LobbyState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
set participantName(name: string) {
|
||||||
|
this.state.participantForm.get('name')?.setValue(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
get participantName(): string {
|
||||||
|
const { valid, value } = this.state.participantForm;
|
||||||
|
if (!valid || !value.name?.trim()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return value.name.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
await this.setBackButtonText();
|
||||||
|
await this.checkForRecordings();
|
||||||
|
await this.initializeParticipantName();
|
||||||
|
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the back button click event and navigates accordingly
|
||||||
|
* If in embedded mode, it closes the WebComponentManagerService
|
||||||
|
* If the redirect URL is set, it navigates to that URL
|
||||||
|
* If in standalone mode without a redirect URL, it navigates to the rooms page
|
||||||
|
*/
|
||||||
|
async goBack() {
|
||||||
|
try {
|
||||||
|
if (this.appDataService.isEmbeddedMode()) {
|
||||||
|
this.wcManagerService.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectTo = this.navigationService.getLeaveRedirectURL();
|
||||||
|
if (redirectTo) {
|
||||||
|
// Navigate to the specified redirect URL
|
||||||
|
await this.navigationService.redirectToLeaveUrl();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.appDataService.isStandaloneMode()) {
|
||||||
|
// Navigate to rooms page
|
||||||
|
await this.navigationService.navigateTo('/rooms');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling back navigation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates to recordings page
|
||||||
|
*/
|
||||||
|
async goToRecordings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.navigationService.navigateTo(`room/${this.state.roomId}/recordings`, {
|
||||||
|
secret: this.state.roomSecret
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error navigating to recordings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAccess(): Promise<void> {
|
||||||
|
if (!this.participantName) {
|
||||||
|
console.error('Participant form is invalid. Cannot access meeting.');
|
||||||
|
throw new Error('Participant form is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.generateParticipantToken();
|
||||||
|
await this.addParticipantNameToUrl();
|
||||||
|
await this.roomService.loadRoomConfig(this.state.roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected helper methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the back button text based on the application mode and user role
|
||||||
|
*/
|
||||||
|
protected async setBackButtonText(): Promise<void> {
|
||||||
|
const isStandaloneMode = this.appDataService.isStandaloneMode();
|
||||||
|
const redirection = this.navigationService.getLeaveRedirectURL();
|
||||||
|
const isAdmin = await this.authService.isAdmin();
|
||||||
|
|
||||||
|
if (isStandaloneMode && !redirection && !isAdmin) {
|
||||||
|
this.state.showBackButton = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.showBackButton = true;
|
||||||
|
this.state.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if there are recordings in the room and updates the visibility of the recordings card.
|
||||||
|
*
|
||||||
|
* It is necessary to previously generate a recording token in order to list the recordings.
|
||||||
|
* If token generation fails or the user does not have sufficient permissions to list recordings,
|
||||||
|
* the error will be caught and the recordings card will be hidden (`showRecordingCard` will be set to `false`).
|
||||||
|
*
|
||||||
|
* If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`.
|
||||||
|
*/
|
||||||
|
protected async checkForRecordings(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { canRetrieveRecordings } = await this.recordingService.generateRecordingToken(
|
||||||
|
this.state.roomId,
|
||||||
|
this.state.roomSecret
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canRetrieveRecordings) {
|
||||||
|
this.state.showRecordingCard = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { recordings } = await this.recordingService.listRecordings({
|
||||||
|
maxItems: 1,
|
||||||
|
roomId: this.state.roomId,
|
||||||
|
fields: 'recordingId'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.state.hasRecordings = recordings.length > 0;
|
||||||
|
this.state.showRecordingCard = this.state.hasRecordings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for recordings:', error);
|
||||||
|
this.state.showRecordingCard = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the participant name in the form control.
|
||||||
|
*
|
||||||
|
* Retrieves the participant name from the ParticipantTokenService first, and if not available,
|
||||||
|
* falls back to the authenticated username. Sets the retrieved name value in the
|
||||||
|
* participant form's 'name' control if a valid name is found.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when the participant name has been initialized
|
||||||
|
*/
|
||||||
|
protected async initializeParticipantName(): Promise<void> {
|
||||||
|
// Apply participant name from ParticipantTokenService if set, otherwise use authenticated username
|
||||||
|
const currentParticipantName = this.participantService.getParticipantName();
|
||||||
|
const username = await this.authService.getUsername();
|
||||||
|
const participantName = currentParticipantName || username;
|
||||||
|
|
||||||
|
if (participantName) {
|
||||||
|
this.participantName = participantName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a participant token for joining a meeting.
|
||||||
|
*
|
||||||
|
* @throws When participant already exists in the room (status 409)
|
||||||
|
* @returns Promise that resolves when token is generated
|
||||||
|
*/
|
||||||
|
protected async generateParticipantToken() {
|
||||||
|
try {
|
||||||
|
this.state.participantToken = await this.participantService.generateToken({
|
||||||
|
roomId: this.state.roomId,
|
||||||
|
secret: this.state.roomSecret,
|
||||||
|
participantName: this.participantName
|
||||||
|
});
|
||||||
|
this.participantName = this.participantService.getParticipantName()!;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error generating participant token:', error);
|
||||||
|
switch (error.status) {
|
||||||
|
case 400:
|
||||||
|
// Invalid secret
|
||||||
|
await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET, true);
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
// Room not found
|
||||||
|
await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM, true);
|
||||||
|
break;
|
||||||
|
case 409:
|
||||||
|
// Room is closed
|
||||||
|
await this.navigationService.redirectToErrorPage(ErrorReason.CLOSED_ROOM, true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Error generating participant token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add participant name as a query parameter to the URL
|
||||||
|
*/
|
||||||
|
protected async addParticipantNameToUrl() {
|
||||||
|
await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, {
|
||||||
|
'participant-name': this.participantName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,194 @@
|
|||||||
|
import { Injectable, Optional, Inject } from '@angular/core';
|
||||||
|
import { FormGroup } from '@angular/forms';
|
||||||
|
import { CustomParticipantModel } from '../../models';
|
||||||
|
import { MeetingActionHandler, MEETING_ACTION_HANDLER_TOKEN, ParticipantControls } from '../../customization';
|
||||||
|
import { ParticipantService } from '../participant.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 participantService: ParticipantService,
|
||||||
|
@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 additional elements plugin
|
||||||
|
*/
|
||||||
|
getLayoutAdditionalElementsInputs(
|
||||||
|
showOverlay: boolean,
|
||||||
|
meetingUrl: string,
|
||||||
|
onCopyLink: () => void
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
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(
|
||||||
|
roomName: string,
|
||||||
|
meetingUrl: string,
|
||||||
|
roomClosed: boolean,
|
||||||
|
showRecordingCard: boolean,
|
||||||
|
showShareLink: boolean,
|
||||||
|
showBackButton: boolean,
|
||||||
|
backButtonText: string,
|
||||||
|
participantForm: FormGroup,
|
||||||
|
onFormSubmit: () => void,
|
||||||
|
onViewRecordings: () => void,
|
||||||
|
onBack: () => void,
|
||||||
|
onCopyLink: () => void
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
roomName,
|
||||||
|
meetingUrl,
|
||||||
|
roomClosed,
|
||||||
|
showRecordingsCard: showRecordingCard,
|
||||||
|
showShareLink,
|
||||||
|
showBackButton,
|
||||||
|
backButtonText,
|
||||||
|
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.participantService.isModeratorParticipant();
|
||||||
|
const participantIsModerator = participant.isModerator();
|
||||||
|
const participantIsOriginalModerator = participant.isOriginalModerator();
|
||||||
|
|
||||||
|
// Calculate if current moderator can revoke the moderator role from the target participant
|
||||||
|
// Only allow if target is not an original moderator
|
||||||
|
const canRevokeModeratorRole = currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator;
|
||||||
|
|
||||||
|
// Calculate if current moderator can kick the target participant
|
||||||
|
// Only allow if target is not an original moderator
|
||||||
|
const canKickParticipant = currentUserIsModerator && !isCurrentUser && !participantIsOriginalModerator;
|
||||||
|
|
||||||
|
return {
|
||||||
|
showModeratorBadge: participantIsModerator,
|
||||||
|
showModerationControls: currentUserIsModerator && !isCurrentUser,
|
||||||
|
showMakeModerator: currentUserIsModerator && !isCurrentUser && !participantIsModerator,
|
||||||
|
showUnmakeModerator: canRevokeModeratorRole,
|
||||||
|
showKickButton: canKickParticipant
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpService, ParticipantService } from '../services';
|
import { HttpService, ParticipantService } from '..';
|
||||||
import { LoggerService } from 'openvidu-components-angular';
|
import { LoggerService } from 'openvidu-components-angular';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@ -9,3 +9,4 @@ export * from './lib/interceptors/index';
|
|||||||
export * from './lib/guards/index';
|
export * from './lib/guards/index';
|
||||||
export * from './lib/routes/base-routes';
|
export * from './lib/routes/base-routes';
|
||||||
export * from './lib/utils/index';
|
export * from './lib/utils/index';
|
||||||
|
export * from './lib/customization/index';
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { routes } from '@app/app.routes';
|
import { ceRoutes } from '@app/app.routes';
|
||||||
import { environment } from '@environment/environment';
|
import { environment } from '@environment/environment';
|
||||||
import { CustomParticipantModel, httpInterceptor, ThemeService } from '@openvidu-meet/shared-components';
|
import { CustomParticipantModel, httpInterceptor, ThemeService } from '@openvidu-meet/shared-components';
|
||||||
import { OpenViduComponentsConfig, OpenViduComponentsModule, ParticipantProperties } from 'openvidu-components-angular';
|
import { OpenViduComponentsConfig, OpenViduComponentsModule, ParticipantProperties } from 'openvidu-components-angular';
|
||||||
@ -30,7 +30,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
}),
|
}),
|
||||||
importProvidersFrom(OpenViduComponentsModule.forRoot(ovComponentsconfig)),
|
importProvidersFrom(OpenViduComponentsModule.forRoot(ovComponentsconfig)),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes),
|
provideRouter(ceRoutes),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
provideHttpClient(withInterceptors([httpInterceptor])),
|
provideHttpClient(withInterceptors([httpInterceptor])),
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,4 +1,14 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { baseRoutes } from '@openvidu-meet/shared-components';
|
import { baseRoutes, MeetingComponent } from '@openvidu-meet/shared-components';
|
||||||
|
import { MEETING_CE_PROVIDERS } from './customization';
|
||||||
|
|
||||||
export const routes: Routes = baseRoutes;
|
/**
|
||||||
|
* CE routes configure the plugin system using library components.
|
||||||
|
* The library's MeetingComponent uses NgComponentOutlet to render plugins dynamically.
|
||||||
|
*/
|
||||||
|
const routes = baseRoutes;
|
||||||
|
const meetingRoute = routes.find((route) => route.path === 'room/:room-id')!;
|
||||||
|
meetingRoute.component = MeetingComponent;
|
||||||
|
meetingRoute.providers = MEETING_CE_PROVIDERS;
|
||||||
|
|
||||||
|
export const ceRoutes: Routes = routes;
|
||||||
|
|||||||
1
meet-ce/frontend/src/app/customization/index.ts
Normal file
1
meet-ce/frontend/src/app/customization/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './meeting-ce.providers';
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Provider } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MEETING_COMPONENTS_TOKEN,
|
||||||
|
MeetingToolbarButtonsComponent,
|
||||||
|
MeetingParticipantPanelComponent,
|
||||||
|
MeetingShareLinkPanelComponent,
|
||||||
|
MeetingShareLinkOverlayComponent,
|
||||||
|
MeetingLobbyComponent
|
||||||
|
} 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
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
additionalElements: MeetingShareLinkOverlayComponent
|
||||||
|
},
|
||||||
|
lobby: MeetingLobbyComponent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// provide: MEETING_ACTION_HANDLER,
|
||||||
|
// useValue: {
|
||||||
|
// copySpeakerLink: () => {
|
||||||
|
// console.log('Copy speaker link clicked');
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
];
|
||||||
@ -1,49 +0,0 @@
|
|||||||
{
|
|
||||||
"cookies": [
|
|
||||||
{
|
|
||||||
"name": "OvMeetParticipantToken",
|
|
||||||
"value": "eyJhbGciOiJIUzI1NiJ9.eyJtZXRhZGF0YSI6IntcImxpdmVraXRVcmxcIjpcIndzOi8vbG9jYWxob3N0Ojc4ODBcIixcInJvbGVzXCI6W3tcInJvbGVcIjpcInNwZWFrZXJcIixcInBlcm1pc3Npb25zXCI6e1wiY2FuUmVjb3JkXCI6ZmFsc2UsXCJjYW5DaGF0XCI6dHJ1ZSxcImNhbkNoYW5nZVZpcnR1YWxCYWNrZ3JvdW5kXCI6dHJ1ZX19XSxcInNlbGVjdGVkUm9sZVwiOlwic3BlYWtlclwifSIsIm5hbWUiOiJQLTFhYWdtYWkiLCJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6InRlc3Qtcm9vbS11cXJ3ajZsZjE3Nnk1anEiLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlLCJjYW5VcGRhdGVPd25NZXRhZGF0YSI6dHJ1ZX0sImlzcyI6ImRldmtleSIsImV4cCI6MTc1ODczMTYyNiwibmJmIjowLCJzdWIiOiJQLTFhYWdtYWkifQ.pC8NFcp7U44kWosjPNlaV67Vgr_f8BlFd3Ni4x6_tR0",
|
|
||||||
"domain": "localhost",
|
|
||||||
"path": "/",
|
|
||||||
"expires": -1,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": false,
|
|
||||||
"sameSite": "Strict"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"origins": [
|
|
||||||
{
|
|
||||||
"origin": "http://localhost:6080",
|
|
||||||
"localStorage": [
|
|
||||||
{
|
|
||||||
"name": "ovMeet-theme",
|
|
||||||
"value": "light"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovComponents-tab_1758724425107_wtx80a2div_333_cameraEnabled",
|
|
||||||
"value": "{\"item\":true}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovComponents-theme",
|
|
||||||
"value": "{\"item\":\"light\"}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovComponents-virtualBg",
|
|
||||||
"value": "{\"item\":\"2\"}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovComponents-tab_1758724425107_wtx80a2div_333_microphoneEnabled",
|
|
||||||
"value": "{\"item\":true}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovComponents-activeTabs",
|
|
||||||
"value": "{\"item\":{\"tab_1758724425107_wtx80a2div_333\":1758724425108}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ovMeet-participantName",
|
|
||||||
"value": "P-1aagmai"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
leaveRoom,
|
leaveRoom,
|
||||||
prepareForJoiningRoom
|
prepareForJoiningRoom
|
||||||
} from '../../helpers/function-helpers';
|
} from '../../helpers/function-helpers';
|
||||||
|
import { LeftEventReason } from '@openvidu-meet/typings';
|
||||||
|
|
||||||
let subscribedToAppErrors = false;
|
let subscribedToAppErrors = false;
|
||||||
|
|
||||||
@ -49,49 +50,274 @@ test.describe('Web Component E2E Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Event Handling', () => {
|
test.describe('Event Handling', () => {
|
||||||
test('should successfully join as moderator and receive joined event', async ({ page }) => {
|
test.describe('JOINED Event', () => {
|
||||||
await joinRoomAs('moderator', participantName, page);
|
test('should receive joined event when joining as moderator', async ({ page }) => {
|
||||||
await page.waitForSelector('.event-joined');
|
await joinRoomAs('moderator', participantName, page);
|
||||||
const joinElements = await page.locator('.event-joined').all();
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
expect(joinElements.length).toBe(1);
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
|
expect(joinElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload contains required data
|
||||||
|
const eventText = await joinElements[0].textContent();
|
||||||
|
expect(eventText).toContain('roomId');
|
||||||
|
expect(eventText).toContain('participantIdentity');
|
||||||
|
expect(eventText).toContain(roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive joined event when joining as speaker', async ({ page }) => {
|
||||||
|
await joinRoomAs('speaker', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
|
expect(joinElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload contains required data
|
||||||
|
const eventText = await joinElements[0].textContent();
|
||||||
|
expect(eventText).toContain('roomId');
|
||||||
|
expect(eventText).toContain('participantIdentity');
|
||||||
|
expect(eventText).toContain(roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive only one joined event per join action', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait a bit to ensure no duplicate events
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
|
expect(joinElements.length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully join as speaker and receive joined event', async ({ page }) => {
|
test.describe('LEFT Event', () => {
|
||||||
await joinRoomAs('speaker', participantName, page);
|
test('should receive left event with voluntary_leave reason when using leave command', async ({
|
||||||
await page.waitForSelector('.event-joined');
|
page
|
||||||
const joinElements = await page.locator('.event-joined').all();
|
}) => {
|
||||||
expect(joinElements.length).toBe(1);
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload contains required data including reason
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
expect(eventText).toContain('roomId');
|
||||||
|
expect(eventText).toContain('participantIdentity');
|
||||||
|
expect(eventText).toContain('reason');
|
||||||
|
expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive left event with voluntary_leave reason when using disconnect button', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
expect(eventText).toContain('reason');
|
||||||
|
expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive left event with meeting_ended reason when moderator ends meeting', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('#end-meeting-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload contains meeting_ended_by_self reason
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
expect(eventText).toContain('reason');
|
||||||
|
expect(eventText).toContain(LeftEventReason.MEETING_ENDED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive left event when speaker leaves room', async ({ page }) => {
|
||||||
|
await joinRoomAs('speaker', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await leaveRoom(page, 'speaker');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify event payload
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
expect(eventText).toContain('roomId');
|
||||||
|
expect(eventText).toContain('participantIdentity');
|
||||||
|
expect(eventText).toContain('reason');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully join to room and receive left event when using leave command', async ({ page }) => {
|
test.describe('CLOSED Event', () => {
|
||||||
await joinRoomAs('moderator', participantName, page);
|
test('should receive closed event after leaving as moderator', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
await page.click('#leave-room-btn');
|
await page.click('#leave-room-btn');
|
||||||
await page.waitForSelector('.event-left');
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
const leftElements = await page.locator('.event-left').all();
|
|
||||||
expect(leftElements.length).toBe(1);
|
// The closed event should be emitted after the left event
|
||||||
|
// Wait for a reasonable amount of time for the closed event
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('.event-closed', { timeout: 5000 });
|
||||||
|
const closedElements = await page.locator('.event-closed').all();
|
||||||
|
expect(closedElements.length).toBeGreaterThanOrEqual(1);
|
||||||
|
} catch (e) {
|
||||||
|
// Closed event might not always be emitted depending on the flow
|
||||||
|
console.log('Closed event not received - this might be expected behavior');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should receive closed event after ending meeting', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('#end-meeting-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait for closed event after ending meeting
|
||||||
|
try {
|
||||||
|
await page.waitForSelector('.event-closed', { timeout: 5000 });
|
||||||
|
const closedElements = await page.locator('.event-closed').all();
|
||||||
|
expect(closedElements.length).toBeGreaterThanOrEqual(1);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Closed event not received - this might be expected behavior');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully join to room and receive left event when using disconnect button', async ({
|
test.describe('Event Sequences', () => {
|
||||||
page
|
test('should receive events in correct order: joined -> left', async ({ page }) => {
|
||||||
}) => {
|
await joinRoomAs('moderator', participantName, page);
|
||||||
await joinRoomAs('moderator', participantName, page);
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
await leaveRoom(page, 'moderator');
|
// Verify joined event is received first
|
||||||
await page.waitForSelector('.event-left');
|
let joinElements = await page.locator('.event-joined').all();
|
||||||
const leftElements = await page.locator('.event-left').all();
|
expect(joinElements.length).toBe(1);
|
||||||
expect(leftElements.length).toBe(1);
|
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify both events are present
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
|
||||||
|
// Verify joined event is still present
|
||||||
|
joinElements = await page.locator('.event-joined').all();
|
||||||
|
expect(joinElements.length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully join to room and receive left event when using end meeting command', async ({
|
test.describe('Event Payload Validation', () => {
|
||||||
page
|
test('should include correct roomId in joined event payload', async ({ page }) => {
|
||||||
}) => {
|
await joinRoomAs('moderator', participantName, page);
|
||||||
await joinRoomAs('moderator', participantName, page);
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
await page.click('#end-meeting-btn');
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
await page.waitForSelector('.event-left');
|
const eventText = await joinElements[0].textContent();
|
||||||
const meetingEndedElements = await page.locator('.event-left').all();
|
|
||||||
expect(meetingEndedElements.length).toBe(1);
|
// Parse the event text to extract the payload
|
||||||
|
expect(eventText).toContain(roomId);
|
||||||
|
expect(eventText).toContain('"roomId"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include participantIdentity in joined event payload', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
|
const eventText = await joinElements[0].textContent();
|
||||||
|
|
||||||
|
expect(eventText).toContain('"participantIdentity"');
|
||||||
|
// The participantIdentity should be present (actual value may vary)
|
||||||
|
expect(eventText).toMatch(/participantIdentity.*:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include all required fields in left event payload', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
|
||||||
|
// Verify all required fields are present
|
||||||
|
expect(eventText).toContain('"roomId"');
|
||||||
|
expect(eventText).toContain('"participantIdentity"');
|
||||||
|
expect(eventText).toContain('"reason"');
|
||||||
|
expect(eventText).toContain(roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have valid reason in left event payload', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
const eventText = await leftElements[0].textContent();
|
||||||
|
|
||||||
|
// Check for valid reason values from LeftEventReason enum
|
||||||
|
const validReasons = Object.values(LeftEventReason);
|
||||||
|
|
||||||
|
const hasValidReason = validReasons.some((reason) => eventText.includes(reason));
|
||||||
|
expect(hasValidReason).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Event Error Handling', () => {
|
||||||
|
test('should handle joining and immediately leaving', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
|
||||||
|
// Leave immediately after join (without waiting for full connection)
|
||||||
|
await page.waitForTimeout(500); // Minimal wait
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
|
||||||
|
// Should still receive left event
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not emit duplicate events on rapid actions', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Rapid clicking on leave button
|
||||||
|
await page.click('#leave-room-btn');
|
||||||
|
await page.click('#leave-room-btn').catch(() => {
|
||||||
|
/* Button might not be available */
|
||||||
|
});
|
||||||
|
await page.click('#leave-room-btn').catch(() => {
|
||||||
|
/* Button might not be available */
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
await page.waitForTimeout(1000); // Wait for any potential duplicate events
|
||||||
|
|
||||||
|
// Should only have one left event
|
||||||
|
const leftElements = await page.locator('.event-left').all();
|
||||||
|
expect(leftElements.length).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
387
meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts
Normal file
387
meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
import { expect, test, Page, BrowserContext } from '@playwright/test';
|
||||||
|
import { MEET_TESTAPP_URL } from '../../config.js';
|
||||||
|
import {
|
||||||
|
createTestRoom,
|
||||||
|
deleteAllRecordings,
|
||||||
|
deleteAllRooms,
|
||||||
|
getIframeInShadowDom,
|
||||||
|
getLocalParticipantId,
|
||||||
|
getParticipantIdByName,
|
||||||
|
interactWithElementInIframe,
|
||||||
|
isShareLinkOverlayyHidden,
|
||||||
|
joinRoomAs,
|
||||||
|
leaveRoom,
|
||||||
|
makeParticipantModerator,
|
||||||
|
openParticipantsPanel,
|
||||||
|
prepareForJoiningRoom,
|
||||||
|
removeParticipantModerator,
|
||||||
|
waitForElementInIframe
|
||||||
|
} from '../../helpers/function-helpers.js';
|
||||||
|
|
||||||
|
let subscribedToAppErrors = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test suite for moderation features in OpenVidu Meet
|
||||||
|
* Tests moderator-specific functionality including share link overlay,
|
||||||
|
* moderator badges, and moderation controls (make/unmake moderator, kick participant)
|
||||||
|
*/
|
||||||
|
test.describe('Moderation Functionality Tests', () => {
|
||||||
|
let roomId: string;
|
||||||
|
let moderatorName: string;
|
||||||
|
let speakerName: string;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SETUP & TEARDOWN
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
// Create a test room before all tests
|
||||||
|
roomId = await createTestRoom('moderation-test-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
if (!subscribedToAppErrors) {
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
const type = msg.type();
|
||||||
|
const tag = type === 'error' ? 'ERROR' : type === 'warning' ? 'WARNING' : 'LOG';
|
||||||
|
console.log('[' + tag + ']', msg.text());
|
||||||
|
});
|
||||||
|
subscribedToAppErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
moderatorName = `Moderator-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
speakerName = `Speaker-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ context }) => {
|
||||||
|
// Save storage state after each test
|
||||||
|
await context.storageState({ path: 'test_localstorage_state.json' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
const tempContext = await browser.newContext();
|
||||||
|
const tempPage = await tempContext.newPage();
|
||||||
|
await deleteAllRooms(tempPage);
|
||||||
|
await deleteAllRecordings(tempPage);
|
||||||
|
|
||||||
|
await tempContext.close();
|
||||||
|
await tempPage.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// SHARE LINK OVERLAY TESTS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
test.describe('Share Link Overlay', () => {
|
||||||
|
test('should show share link overlay when moderator is alone in the room', async ({ page }) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
|
||||||
|
// Wait for session to be established
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Check that share link overlay is visible
|
||||||
|
const shareLinkOverlay = await waitForElementInIframe(page, '#share-link-overlay', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
await expect(shareLinkOverlay).toBeVisible();
|
||||||
|
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide share link overlay when other participants join the room', async ({ page, browser }) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
|
||||||
|
// Wait for session and check overlay is visible
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
const shareLinkOverlay = await waitForElementInIframe(page, '#share-link-overlay', {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
await expect(shareLinkOverlay).toBeVisible();
|
||||||
|
|
||||||
|
// Second participant (speaker) joins
|
||||||
|
const speakerContext = await browser.newContext();
|
||||||
|
const speakerPage = await speakerContext.newPage();
|
||||||
|
await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('speaker', speakerName, speakerPage);
|
||||||
|
|
||||||
|
// Wait for remote participant to be visible in moderator's view
|
||||||
|
await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
|
// Wait a moment for the overlay to hide (give it more time)
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Check that share link overlay is no longer visible for moderator
|
||||||
|
const isHidden = await isShareLinkOverlayyHidden(page, '#share-link-overlay');
|
||||||
|
expect(isHidden).toBeTruthy();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await leaveRoom(speakerPage);
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await speakerContext.close();
|
||||||
|
});
|
||||||
|
test('should not show share link overlay when user is not a moderator', async ({ page }) => {
|
||||||
|
// Speaker joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('speaker', speakerName, page);
|
||||||
|
|
||||||
|
// Wait for session to be established
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Check that share link overlay is not visible
|
||||||
|
const isHidden = await isShareLinkOverlayyHidden(page, '#share-link-overlay');
|
||||||
|
expect(isHidden).toBeTruthy();
|
||||||
|
|
||||||
|
await leaveRoom(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODERATOR BADGE AND CONTROLS TESTS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
test.describe('Moderator Badge and Controls', () => {
|
||||||
|
test('should show moderator badge and controls when making participant a moderator', async ({
|
||||||
|
page,
|
||||||
|
browser
|
||||||
|
}) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Speaker joins the room
|
||||||
|
const speakerContext = await browser.newContext();
|
||||||
|
const speakerPage = await speakerContext.newPage();
|
||||||
|
await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('speaker', speakerName, speakerPage);
|
||||||
|
await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for remote participant to appear in both views
|
||||||
|
await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Moderator opens participants panel
|
||||||
|
await openParticipantsPanel(page);
|
||||||
|
|
||||||
|
// Get speaker's participant ID
|
||||||
|
const speakerParticipantId = await getParticipantIdByName(page, speakerName);
|
||||||
|
|
||||||
|
if (!speakerParticipantId) {
|
||||||
|
throw new Error(`Could not find speaker participant ID for: ${speakerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make speaker a moderator
|
||||||
|
await makeParticipantModerator(page, speakerParticipantId);
|
||||||
|
|
||||||
|
// Speaker opens their participants panel
|
||||||
|
await openParticipantsPanel(speakerPage);
|
||||||
|
|
||||||
|
// Get speaker's own participant ID from their page
|
||||||
|
const speakerOwnParticipantId = await getLocalParticipantId(speakerPage);
|
||||||
|
|
||||||
|
if (!speakerOwnParticipantId) {
|
||||||
|
throw new Error('Could not find speaker own participant ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderatorBadge = await waitForElementInIframe(
|
||||||
|
speakerPage,
|
||||||
|
`#moderator-badge-${speakerOwnParticipantId}`,
|
||||||
|
{
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(moderatorBadge).toBeVisible();
|
||||||
|
|
||||||
|
// Speaker (now moderator) should be able to see moderation controls
|
||||||
|
// We verify by checking that at least one .moderation-controls div exists in the DOM
|
||||||
|
const frameLocator = await getIframeInShadowDom(speakerPage);
|
||||||
|
const moderationControlsCount = await frameLocator.locator('.moderation-controls').count();
|
||||||
|
|
||||||
|
// Should have at least 1 moderation-controls div (for the original moderator)
|
||||||
|
expect(moderationControlsCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await leaveRoom(speakerPage, 'moderator');
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await speakerContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove moderator badge and controls when revoking moderator role', async ({ page, browser }) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Speaker joins the room
|
||||||
|
const speakerContext = await browser.newContext();
|
||||||
|
const speakerPage = await speakerContext.newPage();
|
||||||
|
await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('speaker', speakerName, speakerPage);
|
||||||
|
await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for remote participant to appear
|
||||||
|
await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Moderator opens participants panel
|
||||||
|
await openParticipantsPanel(page);
|
||||||
|
|
||||||
|
// Get speaker's participant ID
|
||||||
|
const speakerParticipantId = await getParticipantIdByName(page, speakerName);
|
||||||
|
|
||||||
|
if (!speakerParticipantId) {
|
||||||
|
throw new Error(`Could not find speaker participant ID for: ${speakerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make speaker a moderator
|
||||||
|
await makeParticipantModerator(page, speakerParticipantId);
|
||||||
|
|
||||||
|
// Verify speaker has moderator badge
|
||||||
|
await openParticipantsPanel(speakerPage);
|
||||||
|
|
||||||
|
const speakerOwnParticipantId = await getLocalParticipantId(speakerPage);
|
||||||
|
|
||||||
|
if (!speakerOwnParticipantId) {
|
||||||
|
throw new Error('Could not find speaker own participant ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const moderatorBadge = await waitForElementInIframe(
|
||||||
|
speakerPage,
|
||||||
|
`#moderator-badge-${speakerOwnParticipantId}`,
|
||||||
|
{
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await expect(moderatorBadge).toBeVisible();
|
||||||
|
|
||||||
|
// Now revoke moderator role
|
||||||
|
await removeParticipantModerator(page, speakerParticipantId);
|
||||||
|
|
||||||
|
// Speaker should no longer see moderator badge
|
||||||
|
await waitForElementInIframe(speakerPage, `#moderator-badge-${speakerOwnParticipantId}`, {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speaker should not see moderation controls (verify they can't see controls for the moderator)
|
||||||
|
const moderatorParticipantId = await getParticipantIdByName(speakerPage, moderatorName);
|
||||||
|
if (moderatorParticipantId) {
|
||||||
|
// If speaker is no longer moderator, moderation-controls div should be hidden
|
||||||
|
await waitForElementInIframe(speakerPage, `#moderation-controls-${moderatorParticipantId}`, {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await leaveRoom(speakerPage);
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await speakerContext.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ORIGINAL MODERATOR PROTECTION TESTS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
test.describe('Original Moderator Protection', () => {
|
||||||
|
test('should not allow removing moderator role from original moderator', async ({ page, browser }) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Speaker joins as second moderator
|
||||||
|
const speakerContext = await browser.newContext();
|
||||||
|
const speakerPage = await speakerContext.newPage();
|
||||||
|
await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', speakerName, speakerPage);
|
||||||
|
await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for both participants to be in the session
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Second moderator opens participants panel
|
||||||
|
await openParticipantsPanel(speakerPage);
|
||||||
|
|
||||||
|
// Get original moderator's participant ID
|
||||||
|
const originalModParticipantId = await getParticipantIdByName(speakerPage, moderatorName);
|
||||||
|
|
||||||
|
if (!originalModParticipantId) {
|
||||||
|
throw new Error(`Could not find original moderator participant ID for: ${moderatorName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that "remove moderator" button is NOT present for original moderator
|
||||||
|
// The button should be in hidden state (not rendered)
|
||||||
|
try {
|
||||||
|
await waitForElementInIframe(speakerPage, `#remove-moderator-btn-${originalModParticipantId}`, {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 2000
|
||||||
|
});
|
||||||
|
// If we get here, the button is correctly hidden
|
||||||
|
} catch (error) {
|
||||||
|
// If the element doesn't exist at all, that's also correct
|
||||||
|
console.log('✅ Remove moderator button not found for original moderator (as expected)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await leaveRoom(speakerPage, 'moderator');
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await speakerContext.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not allow kicking original moderator from the room', async ({ page, browser }) => {
|
||||||
|
// Moderator joins the room
|
||||||
|
await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', moderatorName, page);
|
||||||
|
await waitForElementInIframe(page, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Speaker joins as second moderator
|
||||||
|
const speakerContext = await browser.newContext();
|
||||||
|
const speakerPage = await speakerContext.newPage();
|
||||||
|
await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId);
|
||||||
|
await joinRoomAs('moderator', speakerName, speakerPage);
|
||||||
|
await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' });
|
||||||
|
|
||||||
|
// Wait for both participants to be in the session
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Second moderator opens participants panel
|
||||||
|
await openParticipantsPanel(speakerPage);
|
||||||
|
|
||||||
|
// Get original moderator's participant ID
|
||||||
|
const originalModParticipantId = await getParticipantIdByName(speakerPage, moderatorName);
|
||||||
|
|
||||||
|
if (!originalModParticipantId) {
|
||||||
|
throw new Error(`Could not find original moderator participant ID for: ${moderatorName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that "kick participant" button is NOT present for original moderator
|
||||||
|
// The button should be in hidden state (not rendered)
|
||||||
|
try {
|
||||||
|
await waitForElementInIframe(speakerPage, `#kick-participant-btn-${originalModParticipantId}`, {
|
||||||
|
state: 'hidden',
|
||||||
|
timeout: 2000
|
||||||
|
});
|
||||||
|
// If we get here, the button is correctly hidden
|
||||||
|
} catch (error) {
|
||||||
|
// If the element doesn't exist at all, that's also correct
|
||||||
|
console.log('✅ Kick participant button not found for original moderator (as expected)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await leaveRoom(speakerPage, 'moderator');
|
||||||
|
await leaveRoom(page, 'moderator');
|
||||||
|
await speakerContext.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -401,3 +401,148 @@ export const closeMoreOptionsMenu = async (page: Page) => {
|
|||||||
await interactWithElementInIframe(page, 'body', { action: 'click' });
|
await interactWithElementInIframe(page, 'body', { action: 'click' });
|
||||||
await page.waitForTimeout(500); // Wait for menu to close
|
await page.waitForTimeout(500); // Wait for menu to close
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// MODERATION HELPER FUNCTIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the participant ID (sid) of a participant by name from a specific page view
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param participantName - Name of the participant to find
|
||||||
|
* @returns Promise resolving to the participant ID (sid) or empty string if not found
|
||||||
|
*/
|
||||||
|
export const getParticipantIdByName = async (page: Page, participantName: string): Promise<string> => {
|
||||||
|
// Get iframe using the proper Playwright method
|
||||||
|
const frameLocator = await getIframeInShadowDom(page);
|
||||||
|
|
||||||
|
// Find all participant containers
|
||||||
|
const participantContainers = frameLocator.locator('[data-participant-id]');
|
||||||
|
const count = await participantContainers.count();
|
||||||
|
console.log(`🔍 Found ${count} participant containers`);
|
||||||
|
|
||||||
|
// Iterate through participants to find the matching name
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const container = participantContainers.nth(i);
|
||||||
|
const nameElement = container.locator('.participant-name-text');
|
||||||
|
const pName = await nameElement.textContent();
|
||||||
|
const pId = await container.getAttribute('data-participant-id');
|
||||||
|
|
||||||
|
console.log(`👤 Participant: "${pName?.trim()}" with ID: ${pId}`);
|
||||||
|
|
||||||
|
if (pName?.trim() === participantName) {
|
||||||
|
console.log(`✅ Found matching participant: ${participantName} with ID: ${pId}`);
|
||||||
|
return pId || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`❌ Could not find participant with name: ${participantName}`);
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current user's own participant ID (sid)
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @returns Promise resolving to the local participant's ID (sid) or empty string if not found
|
||||||
|
*/
|
||||||
|
export const getLocalParticipantId = async (page: Page): Promise<string> => {
|
||||||
|
// Get iframe using the proper Playwright method
|
||||||
|
const frameLocator = await getIframeInShadowDom(page);
|
||||||
|
|
||||||
|
// Find all participant containers
|
||||||
|
const participantContainers = frameLocator.locator('[data-participant-id]');
|
||||||
|
const count = await participantContainers.count();
|
||||||
|
console.log(`🔍 Found ${count} participant containers`);
|
||||||
|
|
||||||
|
// Iterate through participants to find the local one (has .local-indicator)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const container = participantContainers.nth(i);
|
||||||
|
const youLabel = container.locator('.local-indicator');
|
||||||
|
const hasYouLabel = (await youLabel.count()) > 0;
|
||||||
|
|
||||||
|
if (hasYouLabel) {
|
||||||
|
const nameElement = container.locator('.participant-name-text');
|
||||||
|
const participantName = await nameElement.textContent();
|
||||||
|
const pId = await container.getAttribute('data-participant-id');
|
||||||
|
|
||||||
|
console.log(`✅ Found local participant: "${participantName?.trim()}" with ID: ${pId}`);
|
||||||
|
return pId || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('❌ Could not find local participant');
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the participants panel and waits for it to be visible
|
||||||
|
* @param page - Playwright page object
|
||||||
|
*/
|
||||||
|
export const openParticipantsPanel = async (page: Page): Promise<void> => {
|
||||||
|
await waitForElementInIframe(page, '#participants-panel-btn');
|
||||||
|
await interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' });
|
||||||
|
await waitForElementInIframe(page, 'ov-participants-panel', { state: 'visible' });
|
||||||
|
await page.waitForTimeout(1000); // Wait for panel to fully load
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a participant a moderator by clicking the make-moderator button
|
||||||
|
* @param page - Playwright page object (moderator's page)
|
||||||
|
* @param participantId - The participant ID (sid) to promote
|
||||||
|
*/
|
||||||
|
export const makeParticipantModerator = async (page: Page, participantId: string): Promise<void> => {
|
||||||
|
const makeModeratorbtn = await waitForElementInIframe(page, `#make-moderator-btn-${participantId}`, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
await makeModeratorbtn.click();
|
||||||
|
await page.waitForTimeout(2000); // Wait for role change to propagate
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes moderator role from a participant by clicking the remove-moderator button
|
||||||
|
* @param page - Playwright page object (moderator's page)
|
||||||
|
* @param participantId - The participant ID (sid) to demote
|
||||||
|
*/
|
||||||
|
export const removeParticipantModerator = async (page: Page, participantId: string): Promise<void> => {
|
||||||
|
const removeModeratorbtn = await waitForElementInIframe(page, `#remove-moderator-btn-${participantId}`, {
|
||||||
|
state: 'visible',
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
await removeModeratorbtn.click();
|
||||||
|
await page.waitForTimeout(2000); // Wait for role change to propagate
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an overlay element is hidden in the iframe
|
||||||
|
* An element is considered hidden if:
|
||||||
|
* - It doesn't exist in the DOM (removed)
|
||||||
|
* - Has display: none
|
||||||
|
* - Has visibility: hidden
|
||||||
|
* - Has opacity: 0
|
||||||
|
* @param page - Playwright page object
|
||||||
|
* @param overlaySelector - CSS selector for the overlay element
|
||||||
|
* @returns Promise resolving to true if the overlay is hidden, false otherwise
|
||||||
|
*/
|
||||||
|
export const isShareLinkOverlayyHidden = async (page: Page, overlaySelector: string): Promise<boolean> => {
|
||||||
|
const frameLocator = await getIframeInShadowDom(page);
|
||||||
|
const overlay = frameLocator.locator(overlaySelector);
|
||||||
|
const count = await overlay.count();
|
||||||
|
|
||||||
|
// Element doesn't exist in the DOM
|
||||||
|
if (count === 0) {
|
||||||
|
console.log('✅ Overlay element not found in DOM (removed)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element is hidden via CSS
|
||||||
|
const isVisible = await overlay.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
console.log('✅ Overlay is hidden (display: none, visibility: hidden, or opacity: 0)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('❌ Overlay is still visible');
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|||||||
7
meet.sh
7
meet.sh
@ -393,6 +393,12 @@ add_common_dev_commands() {
|
|||||||
CMD_NAMES+=("shared-meet-components")
|
CMD_NAMES+=("shared-meet-components")
|
||||||
CMD_COLORS+=("bgYellow.dark")
|
CMD_COLORS+=("bgYellow.dark")
|
||||||
CMD_COMMANDS+=("wait-on ${components_path} && pnpm --filter @openvidu-meet/frontend run lib:serve")
|
CMD_COMMANDS+=("wait-on ${components_path} && pnpm --filter @openvidu-meet/frontend run lib:serve")
|
||||||
|
|
||||||
|
# Testapp
|
||||||
|
CMD_NAMES+=("testapp")
|
||||||
|
CMD_COLORS+=("blue")
|
||||||
|
CMD_COMMANDS+=("node ./scripts/dev/watch-with-typings-guard.mjs 'pnpm run dev:testapp'")
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Helper: Add CE-specific commands (backend, frontend)
|
# Helper: Add CE-specific commands (backend, frontend)
|
||||||
@ -473,6 +479,7 @@ add_browsersync_commands() {
|
|||||||
const local = urls?.get('local') ?? 'undefined';
|
const local = urls?.get('local') ?? 'undefined';
|
||||||
const external = urls?.get('external') ?? 'undefined';
|
const external = urls?.get('external') ?? 'undefined';
|
||||||
console.log(chalk.cyanBright(' OpenVidu Meet: http://localhost:6080'));
|
console.log(chalk.cyanBright(' OpenVidu Meet: http://localhost:6080'));
|
||||||
|
console.log(chalk.cyanBright(' OpenVidu Meet Testapp: http://localhost:5080'));
|
||||||
console.log(chalk.cyanBright(' Live reload Local: ' + local));
|
console.log(chalk.cyanBright(' Live reload Local: ' + local));
|
||||||
console.log(chalk.cyanBright(' Live reload LAN: ' + external));
|
console.log(chalk.cyanBright(' Live reload LAN: ' + external));
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user