Merge branch 'meeting_refactor'
This commit is contained in:
commit
075ae28dd7
1
.gitignore
vendored
1
.gitignore
vendored
@ -48,3 +48,4 @@ pnpm-debug.log*
|
|||||||
**/**/docs/webcomponent-events.md
|
**/**/docs/webcomponent-events.md
|
||||||
|
|
||||||
**/**/meet-pro
|
**/**/meet-pro
|
||||||
|
**/**/test_localstorage_state.json
|
||||||
|
|||||||
@ -2,3 +2,5 @@ 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,10 +21,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
|
@if (shouldShowActions()) {
|
||||||
<mat-dialog-actions class="dialog-action">
|
<mat-dialog-actions class="dialog-action">
|
||||||
|
@if (shouldShowCancelButton()) {
|
||||||
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
|
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
|
||||||
{{ data.cancelText ?? 'Cancel' }}
|
{{ data.cancelText ?? 'Cancel' }}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
@if (shouldShowConfirmButton()) {
|
||||||
<button
|
<button
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
mat-dialog-close
|
mat-dialog-close
|
||||||
@ -35,5 +39,7 @@
|
|||||||
>
|
>
|
||||||
{{ data.confirmText ?? 'Confirm' }}
|
{{ data.confirmText ?? 'Confirm' }}
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</mat-dialog-actions>
|
</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()"
|
||||||
>
|
>
|
||||||
|
<!-- Toolbar Additional Buttons Plugin -->
|
||||||
|
@if (plugins.toolbar?.additionalButtons) {
|
||||||
<ng-container *ovToolbarAdditionalButtons>
|
<ng-container *ovToolbarAdditionalButtons>
|
||||||
<!-- Copy Link Button -->
|
<ng-container
|
||||||
@if (features().canModerateRoom) {
|
[ngComponentOutlet]="plugins.toolbar!.additionalButtons!"
|
||||||
@if (isMobile) {
|
[ngComponentOutletInputs]="toolbarAdditionalButtonsInputs()"
|
||||||
<button id="copy-speaker-link" mat-menu-item (click)="copySpeakerLink()" [disableRipple]="true">
|
></ng-container>
|
||||||
<mat-icon>link</mat-icon>
|
|
||||||
<span class="button-text">Copy meeting link</span>
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
id="copy-speaker-link"
|
|
||||||
mat-icon-button
|
|
||||||
(click)="copySpeakerLink()"
|
|
||||||
[disableRipple]="true"
|
|
||||||
matTooltip="Copy the meeting link"
|
|
||||||
>
|
|
||||||
<mat-icon>link</mat-icon>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Toolbar Leave Button Plugin -->
|
||||||
|
@if (plugins.toolbar?.leaveButton) {
|
||||||
<ng-container *ovToolbarLeaveButton>
|
<ng-container *ovToolbarLeaveButton>
|
||||||
@if (features().canModerateRoom) {
|
<ng-container
|
||||||
<!-- Leave Button -->
|
[ngComponentOutlet]="plugins.toolbar!.leaveButton!"
|
||||||
<button
|
[ngComponentOutletInputs]="toolbarLeaveButtonInputs()"
|
||||||
id="leave-btn"
|
></ng-container>
|
||||||
mat-icon-button
|
|
||||||
[matMenuTriggerFor]="leaveMenu"
|
|
||||||
matTooltip="Leave options"
|
|
||||||
[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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Participant Panel After Local Participant Plugin -->
|
||||||
|
@if (plugins.participantPanel?.afterLocalParticipant) {
|
||||||
<ng-container *ovParticipantPanelAfterLocalParticipant>
|
<ng-container *ovParticipantPanelAfterLocalParticipant>
|
||||||
@if (features().canModerateRoom) {
|
<ng-container
|
||||||
<div class="share-meeting-link-container">
|
[ngComponentOutlet]="plugins.participantPanel!.afterLocalParticipant!"
|
||||||
<ov-share-meeting-link
|
[ngComponentOutletInputs]="participantPanelAfterLocalInputs()"
|
||||||
[meetingUrl]="hostname + '/room/' + roomId"
|
></ng-container>
|
||||||
(copyClicked)="copySpeakerLink()"
|
|
||||||
></ov-share-meeting-link>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Layout Additional Elements Plugin -->
|
||||||
|
@if (plugins.layout?.additionalElements) {
|
||||||
<ng-container *ovLayoutAdditionalElements>
|
<ng-container *ovLayoutAdditionalElements>
|
||||||
@if (features().canModerateRoom && remoteParticipants.length === 0) {
|
@if (onlyModeratorIsPresent) {
|
||||||
<div class="main-share-meeting-link-container fade-in-delayed-more OV_big">
|
<ng-container
|
||||||
<ov-share-meeting-link
|
[ngComponentOutlet]="plugins.layout!.additionalElements!"
|
||||||
class="main-share-meeting-link"
|
[ngComponentOutletInputs]="layoutAdditionalElementsInputs()"
|
||||||
[title]="'Start collaborating'"
|
></ng-container>
|
||||||
[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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Participant Panel Item Plugin -->
|
||||||
|
@if (plugins.participantPanel?.item) {
|
||||||
<ng-container *ovParticipantPanelItem="let participant">
|
<ng-container *ovParticipantPanelItem="let participant">
|
||||||
<!-- If Meet participant is moderator -->
|
<ng-container
|
||||||
@if (features().canModerateRoom) {
|
[ngComponentOutlet]="plugins.participantPanel!.item!"
|
||||||
<div class="participant-item-container">
|
[ngComponentOutletInputs]="participantPanelItemInputsMap().get(participant.identity)"
|
||||||
<!-- Local participant -->
|
></ng-container>
|
||||||
@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>
|
</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;
|
|
||||||
@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;
|
display: flex;
|
||||||
justify-content: center;
|
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;
|
align-items: center;
|
||||||
gap: var(--ov-meet-spacing-md);
|
height: 100%;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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,
|
|
||||||
async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => {
|
|
||||||
const event = JSON.parse(new TextDecoder().decode(payload));
|
|
||||||
|
|
||||||
switch (topic) {
|
|
||||||
case 'recordingStopped': {
|
|
||||||
// If a 'recordingStopped' event is received and there was no previous recordings,
|
|
||||||
// update the hasRecordings flag and refresh the recording token
|
|
||||||
if (this.hasRecordings) return;
|
|
||||||
|
|
||||||
this.hasRecordings = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing recording token:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case MeetSignalType.MEET_ROOM_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,
|
roomId: this.roomId,
|
||||||
secret,
|
roomSecret: this.roomSecret,
|
||||||
participantName: this.participantName,
|
participantName: this.participantName,
|
||||||
participantIdentity
|
localParticipant: () => this.localParticipant(),
|
||||||
|
remoteParticipants: () => this.remoteParticipants(),
|
||||||
|
onHasRecordingsChanged: (hasRecordings) => {
|
||||||
|
this.hasRecordings = hasRecordings;
|
||||||
|
},
|
||||||
|
onRoomSecretChanged: (secret) => {
|
||||||
|
this.roomSecret = secret;
|
||||||
|
},
|
||||||
|
onParticipantRoleUpdated: () => {
|
||||||
|
// Increment version to trigger reactivity in participant panel items
|
||||||
|
this.participantsVersion.update((v) => v + 1);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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,32 +316,61 @@ 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) {
|
async onViewRecordingsClicked() {
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
/**
|
||||||
await this.meetingService.kickParticipant(this.roomId, participant.identity);
|
* Centralized logic for managing video pinning based on
|
||||||
} catch (error) {
|
* remote participants and local screen sharing state.
|
||||||
console.error('Error kicking participant:', error);
|
*/
|
||||||
this.notificationService.showSnackbar('Failed to kick participant');
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes a participant as moderator.
|
* Event handler wrappers - delegates to actionHandler if provided, otherwise uses default implementation
|
||||||
* @param participant The participant to make as moderator.
|
|
||||||
*/
|
*/
|
||||||
async makeModerator(participant: CustomParticipantModel) {
|
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;
|
if (!this.participantService.isModeratorParticipant()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -562,62 +379,62 @@ export class MeetingComponent implements OnInit {
|
|||||||
participant.identity,
|
participant.identity,
|
||||||
ParticipantRole.MODERATOR
|
ParticipantRole.MODERATOR
|
||||||
);
|
);
|
||||||
|
console.log('Moderator assigned successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error making participant moderator:', error);
|
console.error('Error assigning moderator:', error);
|
||||||
this.notificationService.showSnackbar('Failed to make participant moderator');
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected async handleUnmakeModerator(participant: CustomParticipantModel) {
|
||||||
* Unmakes a participant as moderator.
|
if (this.actionHandler) {
|
||||||
* @param participant The participant to unmake as moderator.
|
await this.actionHandler.unmakeModerator(participant);
|
||||||
*/
|
} else {
|
||||||
async unmakeModerator(participant: CustomParticipantModel) {
|
// Default implementation
|
||||||
if (!this.participantService.isModeratorParticipant()) return;
|
if (!this.participantService.isModeratorParticipant()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.meetingService.changeParticipantRole(this.roomId, participant.identity, ParticipantRole.SPEAKER);
|
await this.meetingService.changeParticipantRole(
|
||||||
} catch (error) {
|
this.roomId,
|
||||||
console.error('Error unmaking participant moderator:', error);
|
participant.identity,
|
||||||
this.notificationService.showSnackbar('Failed to unmake participant moderator');
|
ParticipantRole.SPEAKER
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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.log('Moderator unassigned successfully');
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onRecordingStopRequested(event: RecordingStopRequestedEvent) {
|
|
||||||
try {
|
|
||||||
await this.recordingService.stopRecording(event.recordingId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error('Error unassigning moderator:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onViewRecordingsClicked() {
|
// private async handleCopyModeratorLink() {
|
||||||
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
|
// 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', () => {
|
||||||
|
test('should receive joined event when joining as moderator', async ({ page }) => {
|
||||||
await joinRoomAs('moderator', participantName, page);
|
await joinRoomAs('moderator', participantName, page);
|
||||||
await page.waitForSelector('.event-joined');
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
const joinElements = await page.locator('.event-joined').all();
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
expect(joinElements.length).toBe(1);
|
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 successfully join as speaker and receive joined event', async ({ page }) => {
|
test('should receive joined event when joining as speaker', async ({ page }) => {
|
||||||
await joinRoomAs('speaker', participantName, page);
|
await joinRoomAs('speaker', participantName, page);
|
||||||
await page.waitForSelector('.event-joined');
|
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();
|
const joinElements = await page.locator('.event-joined').all();
|
||||||
expect(joinElements.length).toBe(1);
|
expect(joinElements.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should successfully join to room and receive left event when using leave command', async ({ page }) => {
|
test.describe('LEFT Event', () => {
|
||||||
|
test('should receive left event with voluntary_leave reason when using leave command', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
await joinRoomAs('moderator', participantName, 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();
|
const leftElements = await page.locator('.event-left').all();
|
||||||
expect(leftElements.length).toBe(1);
|
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 successfully join to room and receive left event when using disconnect button', async ({
|
test('should receive left event with voluntary_leave reason when using disconnect button', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}) => {
|
||||||
await joinRoomAs('moderator', participantName, page);
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
await leaveRoom(page, 'moderator');
|
await leaveRoom(page, 'moderator');
|
||||||
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);
|
||||||
|
|
||||||
|
// 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.describe('CLOSED Event', () => {
|
||||||
|
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.waitForSelector('.event-left', { timeout: 10000 });
|
||||||
|
|
||||||
|
// 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.describe('Event Sequences', () => {
|
||||||
|
test('should receive events in correct order: joined -> left', async ({ page }) => {
|
||||||
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify joined event is received first
|
||||||
|
let joinElements = await page.locator('.event-joined').all();
|
||||||
|
expect(joinElements.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.describe('Event Payload Validation', () => {
|
||||||
|
test('should include correct roomId 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();
|
||||||
|
|
||||||
|
// 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();
|
const leftElements = await page.locator('.event-left').all();
|
||||||
expect(leftElements.length).toBe(1);
|
expect(leftElements.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should successfully join to room and receive left event when using end meeting command', async ({
|
test('should not emit duplicate events on rapid actions', async ({ page }) => {
|
||||||
page
|
|
||||||
}) => {
|
|
||||||
await joinRoomAs('moderator', participantName, page);
|
await joinRoomAs('moderator', participantName, page);
|
||||||
|
await page.waitForSelector('.event-joined', { timeout: 10000 });
|
||||||
|
|
||||||
await page.click('#end-meeting-btn');
|
// Rapid clicking on leave button
|
||||||
await page.waitForSelector('.event-left');
|
await page.click('#leave-room-btn');
|
||||||
const meetingEndedElements = await page.locator('.event-left').all();
|
await page.click('#leave-room-btn').catch(() => {
|
||||||
expect(meetingEndedElements.length).toBe(1);
|
/* 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