Compare commits

...

10 Commits

Author SHA1 Message Date
64924f9999 Applied new local config
Some checks failed
WebComponent E2E Tests / WebComponent E2E Tests (push) Has been cancelled
WebComponent Unit Tests / WebComponent Unit Tests (push) Has been cancelled
Backend Unit Tests / Backend Unit Tests (push) Has been cancelled
2025-11-01 00:49:33 -07:00
Carlos Santos
075ae28dd7 Merge branch 'meeting_refactor' 2025-10-31 13:17:07 +01:00
Carlos Santos
ad4ae2a69d frontend: Refactor meeting component for allowing customization
- Implemented MeetingParticipantPanelComponent for displaying participant details with moderation controls.
- Created MeetingShareLinkOverlayComponent for sharing meeting links when no participants are present.
- Developed MeetingShareLinkPanelComponent for sharing links within the participant panel.
- Introduced MeetingToolbarButtonsComponent for additional toolbar actions like copying links and leaving meetings.
- Refactored MeetingComponent to utilize new components for participant management and sharing links.
- Updated styles for new components and removed redundant styles from MeetingComponent.

frontend: implement CE-specific meeting component with routing and UI elements

frontend: Enhance meeting components with plugin system

- Added alternative function inputs for event handling in MeetingLobbyComponent, MeetingParticipantPanelComponent, MeetingShareLinkOverlayComponent, MeetingShareLinkPanelComponent, and MeetingToolbarButtonsComponent.
- Introduced MeetingComponentsPlugins interface and MEETING_COMPONENTS_TOKEN for dynamic component injection.
- Updated MeetingComponent to utilize NgComponentOutlet for rendering plugins and prepare inputs for plugin components.
- Removed CE-specific MeetingCEComponent and its associated files, integrating its functionality directly into MeetingComponent.
- Created MEETING_CE_PROVIDERS to configure the plugin system using library components directly.
- Updated routing to use the new MeetingComponent with plugin support.

frontend: Update meeting component to display prejoin screen with lobby plugin

Moves meeting service to a subdirectory

Moves the meeting service to its own subdirectory for better organization.

Updates imports to reflect the new location.

frontend: Refactor dialog component to conditionally render action buttons

frontend: Implement lobby state management and enhance prejoin screen functionality

frontend: Refactor MeetingComponent to streamline service injections and constructor

frontend: Remove unused participantToken variable and add getter for lobbyState participantToken

frontend: Rename lobby.service to meeting-lobby.service

frontend: Refactor MeetingComponent to use MeetingPluginManagerService for plugin inputs and remove deprecated methods

meet.sh: launch testapp with dev command

backend: Added webhook config in .env.test

Adds web component events e2e tests

Introduces end-to-end tests for web component events, covering scenarios such as joining, leaving, and handling meeting closure.

The tests verify correct event emission and payload structure, including reason codes for leave events.

Also, add `test_localstorage_state.json` to git ignore, removing the file.

frontend: Added meeting event handler service

frontend: Enhances meeting component reactivity

Refactors the meeting component to use signals for reactive updates.

This improves performance by reducing unnecessary re-renders and simplifies state management.

- Moves event handling to a dedicated service.
- Introduces signals for participant lists and updates.
- Implements caching for participant panel inputs.
- Improves moderator control visibility logic.

webcomponent: Added moderation e2e tests

refactor(meeting): optimize participant panel item inputs handling

frontend: fix moderator badge rendering in participant panel

refactor(meeting): remove unused services and streamline constructor logic

refactor(meeting): update leave and end meeting handlers to return promises
2025-10-31 13:16:43 +01:00
Carlos Santos
f4376934d7 tests: reorganize Jest configuration and VSCode settings for improved testing setup 2025-10-27 14:08:48 +01:00
Carlos Santos
4b9c5dbdd1 add copilot instructions documentation for OpenVidu Meet project 2025-10-27 13:44:48 +01:00
Carlos Santos
ed4e336473 update Node.js image version in Dockerfile to 22.21.0 2025-10-27 12:40:15 +01:00
Carlos Santos
e876ef94d1 Updated dockerfile 2025-10-23 20:32:03 +02:00
Carlos Santos
18b54b22c2 add testapp to Docker/CI workspace configuration 2025-10-23 20:13:14 +02:00
Carlos Santos
593558508f update shared-meet-components package version constraint in prepare-ci-build.sh 2025-10-23 20:06:03 +02:00
Carlos Santos
face9218ae update pnpm install command in prepare-ci-build.sh to use --no-frozen-lockfile option 2025-10-23 19:59:05 +02:00
103 changed files with 7438 additions and 1905 deletions

49
.env.production Normal file
View File

@ -0,0 +1,49 @@
# CONFIGURACIÓN PARA EASYPANEL + LIVEKIT SELF-HOSTED
# ===========================================
# CONFIGURACIÓN BÁSICA
# ===========================================
NODE_ENV=production
MEET_LOG_LEVEL=info
MEET_BLOB_STORAGE_MODE=memory
# ===========================================
# USUARIO ADMINISTRADOR
# ===========================================
ADMIN_USER=admin
ADMIN_PASSWORD=admin123
# ===========================================
# LIVEKIT SELF-HOSTED (RED LOCAL + PÚBLICO)
# ===========================================
# ✅ LiveKit corriendo en servidor dedicado
# ✅ Puertos UDP expuestos en router
# ✅ SSL/WSS configurado
# URL de tu LiveKit self-hosted (cambiar por tu dominio/IP)
LIVEKIT_URL=wss://mi-livekit.duckdns.org
# O con IP fija: wss://TU_IP_PUBLICA:443
LIVEKIT_API_KEY=production-key
LIVEKIT_API_SECRET=tu-super-secret-de-32-caracteres-o-mas
# ===========================================
# REDIS (OPCIONAL para EasyPanel)
# ===========================================
# Si quieres Redis también en EasyPanel
# REDIS_HOST=redis
# REDIS_PORT=6379
# REDIS_PASSWORD=admin-redis
# ===========================================
# ARQUITECTURA FINAL:
# ===========================================
# Internet → Router (Port Forward) → LiveKit Server (192.168.1.19)
# ↘ EasyPanel → OpenVidu Meet Backend
#
# Ventajas:
# ✅ Control total sobre LiveKit
# ✅ Sin costos mensuales externos
# ✅ Rendimiento en red local
# ✅ Acceso desde internet
# ✅ Escalable según hardware

View File

@ -0,0 +1,397 @@
# OpenVidu Meet - Copilot Instructions
## Project Overview
**OpenVidu Meet** is a production-ready videoconferencing application built on top of OpenVidu and LiveKit. It provides a complete, customizable solution for video conferencing with both Community Edition (CE) and Pro versions.
### Key Technologies
- **Frontend**: Angular 20.x, TypeScript, Angular Material
- **Backend**: Node.js, Express framework
- **Package Manager**: pnpm (v10.18.3+) with workspaces
- **Build System**: Nx-like monorepo structure
- **Testing**: Playwright (E2E), Jest (Unit)
- **Containerization**: Docker multi-stage builds
---
## Project Structure
```
openvidu-meet/
├── meet-ce/ # Community Edition
│ ├── frontend/ # Angular application
│ │ ├── src/ # Main app source
│ │ ├── webcomponent/ # Web Component build
│ │ └── projects/
│ │ └── shared-meet-components/ # Shared Angular library
│ ├── backend/ # Express backend
│ └── docker/ # Docker configurations
├── meet-pro/ # Pro Edition (extends CE)
├── testapp/ # Testing application for E2E tests
├── meet-demo/ # Demo application for showcasing features
├── scripts/ # Build and automation scripts
└── meet.sh # Main CLI tool
```
---
## Architecture Principles
### 1. **Monorepo with External Dependencies**
- Uses pnpm workspaces for internal packages
- **External dependency**: `openvidu-components-angular` (located outside this repo)
- **Development**: Uses `workspace:*` protocol for local linking
- **CI/Docker**: Uses npm registry or tarball installations
### 2. **Dual Workspace Configuration**
```yaml
# Development (pnpm-workspace.yaml)
packages:
- 'meet-ce/**'
- '../openvidu/openvidu-components-angular/projects/openvidu-components-angular'
# CI/Docker (pnpm-workspace.docker.yaml)
packages:
- 'meet-ce/**' # No external packages
```
### 3. **Library Structure**
- `@openvidu-meet/frontend`: Main Angular application
- `@openvidu-meet/shared-components`: Reusable Angular library
- Has `openvidu-components-angular` as **peerDependency**
- Built as Angular library (ng-packagr)
- Does NOT bundle dependencies
---
## Coding Standards
### TypeScript/Angular
```typescript
// ✅ Good: Use interfaces for data models
export interface Conference {
id: string;
name: string;
participants: Participant[];
}
// ✅ Good: Use Angular dependency injection
@Injectable({ providedIn: 'root' })
export class ConferenceService {
constructor(private http: HttpClient) {}
}
// ✅ Good: Use RxJS operators properly
this.participants$.pipe(
map(participants => participants.filter(p => p.isActive)),
takeUntil(this.destroy$)
).subscribe();
// ❌ Bad: Don't use 'any' type
// ❌ Bad: Don't forget to unsubscribe from observables
```
### Express Backend with TypeScript and InversifyJS
```typescript
// ✅ Good: Use decorators and dependency injection
@injectable()
export class LoggerService {
log(message: string): void {
console.log(message);
}
}
```
---
## Build & Development
### Main CLI Tool: `meet.sh`
```bash
# Development
./meet.sh dev # Start development servers
./meet.sh dev --skip-install # Skip dependency installation
# Building
./meet.sh build # Build all packages
./meet.sh build --skip-install # Build without installing deps
./meet.sh build --base-href=/custom/ # Custom base href
# Docker
./meet.sh build-docker ov-meet # Build Docker image (CE)
./meet.sh build-docker ov-meet --components-angular-version 3.5.0-beta1
# CI Preparation (Important!)
./meet.sh prepare-ci-build --components-angular-version 3.5.0-beta1
./meet.sh prepare-ci-build --components-angular-tarball ./components.tgz
./meet.sh restore-dev-config # Restore development config
```
### CI/Docker Dependency Strategy
**Problem**: External package `openvidu-components-angular` is not in this repo.
**Solution**: Dual workspace configuration
1. **Development**: Uses `workspace:*` (local linking)
2. **CI/Docker**: Replaces `workspace:*` with registry/tarball versions
**Scripts**:
- `scripts/prepare-ci-build.sh`: Prepares workspace for CI/Docker builds
- `scripts/restore-dev-config.sh`: Restores development configuration
**Workflow**:
```bash
# Before CI/Docker build
prepare-ci-build.sh --components-angular-version 3.5.0-beta1
# Changes: pnpm-workspace.yaml → pnpm-workspace.docker.yaml
# .npmrc → .npmrc.docker
# package.json: workspace:* → ^3.5.0-beta1
# After build
restore-dev-config.sh
# Restores all original files
```
---
## Important Conventions
### 1. **Never Manually Edit package.json in CI**
```bash
# ❌ Bad: Manual sed in CI
sed -i 's/workspace:\*/3.5.0/g' package.json
# ✅ Good: Use prepare-ci-build script
./scripts/prepare-ci-build.sh --components-angular-version 3.5.0-beta1
```
### 2. **peerDependencies Guidelines**
- `shared-meet-components` declares `openvidu-components-angular` as **peerDependency**
- ✅ Use semver ranges: `"^3.0.0"`
- ✅ Use workspace protocol in dev: `"workspace:*"`
- ❌ NEVER use `file:` in peerDependencies (invalid)
### 3. **Angular Version Compatibility**
- Current: Angular 20.x
- `openvidu-components-angular` must support same Angular version
- Check peer dependency warnings carefully
### 4. **Docker Best Practices**
```dockerfile
# ✅ Good: Multi-stage build
FROM node:22.19.0 AS builder
# ... build stage ...
FROM node:22.19.0-alpine AS runner
# ... runtime stage ...
# ✅ Good: Use build args
ARG COMPONENTS_VERSION=3.5.0-beta1
ARG BASE_HREF=/
# ✅ Good: Use .dockerignore
# Avoid copying unnecessary files
```
---
## Common Tasks
### Adding a New Feature
1. Create feature in `meet-ce/frontend/src/app/features/`
2. If reusable, consider moving to `shared-meet-components`
3. Add tests in `*.spec.ts` files
4. Update documentation
### Updating openvidu-components-angular
```bash
# Development (local changes)
cd ../openvidu/openvidu-components-angular
npm run lib:build
cd -
pnpm install
# CI/Docker (version update)
./meet.sh prepare-ci-build --components-angular-version 3.5.0-beta1
./meet.sh build
./meet.sh restore-dev-config
```
### Working with Shared Components
```bash
# Build shared library
cd meet-ce/frontend/projects/shared-meet-components
ng build
# Use in main app
import { SomeComponent } from '@openvidu-meet/shared-components';
```
---
## Testing
### E2E Tests (Playwright)
```bash
npm run test:e2e # Run all E2E tests
npm run test:e2e:ui # Run with UI
npm run test:e2e:debug # Debug mode
```
### Unit Tests (Jest)
```bash
npm run test # Run all unit tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage
```
---
## Troubleshooting
### "workspace package not found" Error
**Cause**: Using `workspace:*` in CI/Docker mode
**Solution**:
```bash
./meet.sh prepare-ci-build --components-angular-version 3.5.0-beta1
```
### "Invalid peer dependency" Error
**Cause**: Using `file:` in peerDependencies
**Solution**: Use semver range (`^3.0.0`) in peerDependencies, not `file:` paths
### Angular Version Mismatch Warnings
**Cause**: `openvidu-components-angular` version doesn't support your Angular version
**Solution**: Update to compatible version
```bash
./meet.sh prepare-ci-build --components-angular-version 3.5.0-beta1 # Supports Angular 20
```
### pnpm-lock.yaml Conflicts
**Solution**: Restore development config first
```bash
./meet.sh restore-dev-config
pnpm install
```
---
## File Naming Conventions
```
✅ Good:
- conference.service.ts (Services)
- conference.component.ts (Components)
- conference.component.spec.ts (Tests)
- conference.model.ts (Models/Interfaces)
- conference.module.ts (Modules)
❌ Bad:
- ConferenceService.ts (PascalCase in filename)
- conference_service.ts (snake_case)
- conferenceService.ts (camelCase)
```
---
## Environment Variables
### Development
```bash
# Frontend
OPENVIDU_SERVER_URL=ws://localhost:4443
OPENVIDU_SECRET=MY_SECRET
# Backend
PORT=3000
OPENVIDU_URL=https://localhost:4443
OPENVIDU_SECRET=MY_SECRET
```
### Docker
Set via `docker-compose.yml` or Dockerfile ARG/ENV
---
## Security Considerations
1. **Never commit secrets** to version control
2. Use environment variables for sensitive data
3. Validate all user inputs (DTOs in backend)
4. Sanitize HTML content in frontend
5. Use Angular's built-in XSS protection
---
## Performance Guidelines
1. **Lazy load modules** when possible
2. **Use OnPush change detection** for performance-critical components
3. **Unsubscribe from observables** (use `takeUntil` or async pipe)
4. **Optimize Docker images** (multi-stage builds, alpine images)
5. **Use pnpm** for faster installs and efficient disk usage
---
## Documentation References
- Full CI/Docker strategy: `docs/ci-docker-dependencies-strategy.md`
- Dependency diagrams: `docs/ci-docker-dependencies-diagrams.md`
- Implementation summary: `docs/IMPLEMENTATION_SUMMARY.md`
- Validation checklist: `docs/VALIDATION_CHECKLIST.md`
---
## When Suggesting Code Changes
1. **Understand the context**: Is this for development or CI/Docker?
2. **Check package.json**: Are dependencies using `workspace:*` or versions?
3. **Consider peerDependencies**: Never use `file:` paths in peerDependencies
4. **Follow conventions**: Use existing patterns in the codebase
5. **Test implications**: Consider impact on both local dev and CI/Docker builds
6. **Use prepare-ci-build**: For any CI/Docker related changes
---
## Quick Reference Commands
```bash
# Development
./meet.sh dev # Start dev servers
./meet.sh build # Build all
# CI/Docker
./meet.sh prepare-ci-build --components-angular-version 3.5.0-beta1
./meet.sh build-docker ov-meet
./meet.sh restore-dev-config
# Testing
npm run test # Unit tests
npm run test:e2e # E2E tests
# Package management
pnpm install # Install deps
pnpm add <package> --filter @openvidu-meet/frontend # Add to specific package
```
---
## Remember
- **Always use scripts** for CI/Docker preparation, never manual edits
- **peerDependencies** are for library compatibility declarations, not installation
- **workspace:*** works only when package is in workspace
- **Test locally** before pushing CI/Docker changes
- **Document breaking changes** and update this file accordingly
---
**Last Updated**: 2025-10-27
**Project Version**: OpenVidu Meet CE/Pro
**Maintained By**: OpenVidu Team

1
.gitignore vendored
View File

@ -48,3 +48,4 @@ pnpm-debug.log*
**/**/docs/webcomponent-events.md
**/**/meet-pro
**/**/test_localstorage_state.json

13
.npmrc
View File

@ -1,10 +1,18 @@
# Docker/CI specific npm configuration
# This configuration is used during Docker builds and CI workflows
# to prevent linking workspace packages and use published versions instead
# Disable workspace package linking
# This forces pnpm to install packages from registry or local tarballs
link-workspace-packages=false
# Strict peer dependencies
strict-peer-dependencies=false
# Auto install peers
auto-install-peers=true
# Shamefully hoist - neccessary for some packages
# Shamefully hoist - necessary for some packages
shamefully-hoist=true
# Node linker - use hoisted for full compatibility
@ -12,6 +20,3 @@ node-linker=hoisted
# Lockfile settings
lockfile=true
# Optional: Store location (uncomment if you want to customize)
# store-dir=.pnpm-store

View File

@ -0,0 +1,65 @@
# CLOUDFLARE TUNNEL - SIN PORT FORWARDING
## ☁️ Cloudflare Tunnel para LiveKit (Avanzado)
### Ventajas:
- ✅ **Sin port forwarding** en router
- ✅ **SSL automático**
- ✅ **Protección DDoS**
- ✅ **IP oculta**
### ⚠️ Limitaciones para WebRTC:
- ❌ **UDP no soportado** directamente
- ⚠️ **Requiere TURN server** para WebRTC
- 🔧 **Solo TCP/HTTP** a través del tunnel
### Configuración (solo si tienes TURN server):
#### Paso 1: Instalar cloudflared
```bash
# Descargar cloudflared
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
```
#### Paso 2: Crear tunnel
```bash
# Login a Cloudflare
cloudflared tunnel login
# Crear tunnel
cloudflared tunnel create livekit-tunnel
# Configurar tunnel
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: livekit-tunnel
credentials-file: /home/usuario/.cloudflared/livekit-tunnel.json
ingress:
- hostname: livekit.midominio.com
service: http://localhost:7880
- service: http_status:404
EOF
# Crear DNS record
cloudflared tunnel route dns livekit-tunnel livekit.midominio.com
# Ejecutar tunnel
cloudflared tunnel run livekit-tunnel
```
#### Configuración LiveKit (necesita TURN):
```yaml
# livekit-production.yaml
rtc:
# SIN puertos UDP directos - usar TURN
use_external_ip: false
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["turn:turn.midominio.com:3478"]
username: "usuario"
credential: "password"
```
### ⚠️ **NO RECOMENDADO** para LiveKit porque WebRTC necesita UDP

65
CONFIG-ACCESO-PUBLICO.md Normal file
View File

@ -0,0 +1,65 @@
# CONFIGURACIÓN AVANZADA: ACCESO DESDE INTERNET
## 🌍 Para usuarios externos (más complejo y riesgoso)
### ⚠️ PROBLEMAS de exponer UDP públicamente:
1. **Seguridad**: 10,000 puertos UDP expuestos
2. **Complejidad**: Port forwarding masivo
3. **NAT Traversal**: Problemas con diferentes ISPs
4. **Mantenimiento**: Configuración de router compleja
### Si NECESITAS acceso externo, opciones:
#### Opción A: TURN Server (RECOMENDADO)
```yaml
# livekit-public.yaml
rtc:
use_external_ip: true
external_ip: "TU_IP_PUBLICA"
# Usar TURN para atravesar NAT
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["turn:tu-turn-server.com:3478"]
username: "usuario"
credential: "password"
# Rango reducido de puertos
port_range_start: 50000
port_range_end: 50100 # Solo 100 puertos
```
#### Opción B: LiveKit Cloud (MÁS RECOMENDADO)
```env
# .env.production
LIVEKIT_URL=wss://tu-proyecto.livekit.cloud
LIVEKIT_API_KEY=cloud-api-key
LIVEKIT_API_SECRET=cloud-secret
# ✅ SIN configuración UDP local
# ✅ Sin port forwarding
# ✅ Infraestructura optimizada
```
#### Opción C: VPN (Para usuarios conocidos)
```bash
# Configurar WireGuard/OpenVPN
# Usuarios se conectan por VPN a tu red local
# Acceso como si fueran locales
# No requiere exponer UDP públicamente
# Acceso seguro y controlado
```
### Port Forwarding (Solo si es absolutamente necesario):
```
⚠️ EN ROUTER:
UDP 50000-60000 → 192.168.1.19:50000-60000
❌ RIESGOS:
- 10,000 puertos UDP expuestos
- Posibles ataques DDoS
- Configuración compleja
- Problemas con CGN/CGNAT de ISPs
```

36
CONFIG-RED-LOCAL.md Normal file
View File

@ -0,0 +1,36 @@
# CONFIGURACIÓN RECOMENDADA: SOLO RED LOCAL
## 🔒 Para uso en red local ÚNICAMENTE
### Firewall UFW (SOLO red local):
```bash
# Permitir desde red local solamente
sudo ufw allow from 192.168.1.0/24 to any port 50000:60000 proto udp
sudo ufw allow from 192.168.1.0/24 to any port 7880 proto tcp
sudo ufw allow from 192.168.1.0/24 to any port 80 proto tcp
# BLOQUEAR acceso externo a UDP
sudo ufw deny 50000:60000/udp
# Verificar que solo red local tenga acceso
sudo ufw status numbered
```
### Router/Modem (NO configurar port forwarding):
```
❌ NO hacer port forwarding de UDP 50000-60000
✅ Solo HTTP (puerto 80) si necesitas acceso web externo
✅ Usar VPN si necesitas acceso remoto
```
### Ventajas:
- ✅ **Seguridad**: UDP no expuesto a internet
- ✅ **Simplicidad**: Sin configuración de router
- ✅ **Rendimiento**: Conexión directa en LAN
- ✅ **Sin NAT**: Sin problemas de traversal
### Casos de uso:
- Oficina local
- Casa/familia
- Reuniones internas
- Desarrollo y testing

107
DOMINIO-PROPIO-CONFIG.md Normal file
View File

@ -0,0 +1,107 @@
# CONFIGURACIÓN DOMINIO PROPIO PARA LIVEKIT
## 🏠 Dominio propio (ej: livekit.midominio.com)
### Opción A: Subdominio de tu dominio existente
#### Paso 1: Configurar DNS
```
Tipo: A
Nombre: livekit
Valor: TU_IP_PUBLICA
TTL: 300
Resultado: livekit.midominio.com → TU_IP_PUBLICA
```
#### Paso 2: Port forwarding en router
```
Puerto 80 → 192.168.1.19:80 # HTTP para Let's Encrypt
Puerto 443 → 192.168.1.19:443 # HTTPS/WSS
Puerto 7880 → 192.168.1.19:7880 # LiveKit API directo
Puerto 50000-50100 (UDP) → 192.168.1.19:50000-50100 # WebRTC
```
#### Paso 3: SSL con Let's Encrypt
```bash
# Instalar certbot
sudo apt update
sudo apt install certbot nginx
# Configurar Nginx básico
sudo tee /etc/nginx/sites-available/livekit << 'EOF'
server {
listen 80;
server_name livekit.midominio.com;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$server_name$request_uri;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/livekit /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx
# Generar certificado SSL
sudo certbot --nginx -d livekit.midominio.com
# Resultado: certificados en /etc/letsencrypt/live/livekit.midominio.com/
```
#### Paso 4: Configurar Nginx para LiveKit
```nginx
# /etc/nginx/sites-available/livekit
server {
listen 443 ssl http2;
server_name livekit.midominio.com;
ssl_certificate /etc/letsencrypt/live/livekit.midominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/livekit.midominio.com/privkey.pem;
# WebSocket proxy para LiveKit
location / {
proxy_pass http://localhost:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts para WebRTC
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
server {
listen 80;
server_name livekit.midominio.com;
return 301 https://$server_name$request_uri;
}
```
#### Paso 5: Auto-renovación SSL
```bash
# Agregar a crontab
sudo crontab -e
# Renovar certificados automáticamente
0 12 * * * /usr/bin/certbot renew --quiet && systemctl reload nginx
```
### URLs finales:
- **LiveKit WSS**: `wss://livekit.midominio.com`
- **API HTTPS**: `https://livekit.midominio.com`
### Configurar en OpenVidu Meet:
```env
LIVEKIT_URL=wss://livekit.midominio.com
```

72
DUCKDNS-CONFIG.md Normal file
View File

@ -0,0 +1,72 @@
# CONFIGURACIÓN DUCKDNS PARA LIVEKIT
## 🦆 DuckDNS - Dominio gratuito para IP dinámica
### Paso 1: Registrarse en DuckDNS
1. Ir a [duckdns.org](https://www.duckdns.org)
2. Login con Google/GitHub
3. Crear subdominio: `mi-livekit.duckdns.org`
4. Copiar el token
### Paso 2: Script de actualización automática
```bash
# Crear script de actualización
cat > /home/xesar/update-duckdns.sh << 'EOF'
#!/bin/bash
# Actualizar DuckDNS con IP actual
DOMAIN="mi-livekit" # Tu subdominio sin .duckdns.org
TOKEN="tu-token-aqui" # Token de DuckDNS
# Obtener IP pública actual
CURRENT_IP=$(curl -s https://checkip.amazonaws.com)
# Actualizar DuckDNS
RESPONSE=$(curl -s "https://www.duckdns.org/update?domains=$DOMAIN&token=$TOKEN&ip=$CURRENT_IP")
if [ "$RESPONSE" = "OK" ]; then
echo "$(date): DuckDNS actualizado - $DOMAIN.duckdns.org → $CURRENT_IP"
else
echo "$(date): ERROR actualizando DuckDNS: $RESPONSE"
fi
EOF
chmod +x /home/xesar/update-duckdns.sh
```
### Paso 3: Automatizar con cron
```bash
# Editar crontab
crontab -e
# Agregar línea para actualizar cada 5 minutos:
*/5 * * * * /home/xesar/update-duckdns.sh >> /home/xesar/duckdns.log 2>&1
```
### Paso 4: Configurar LiveKit con dominio
```yaml
# livekit-production.yaml
rtc:
external_ip: "mi-livekit.duckdns.org" # Tu dominio DuckDNS
port_range_start: 50000
port_range_end: 50100
```
### Paso 5: Port forwarding en router
```
Regla: LiveKit-API
- Puerto externo: 7880
- Puerto interno: 7880
- IP: 192.168.1.19
- Protocolo: TCP
Regla: LiveKit-WebRTC
- Puerto externo: 50000-50100
- Puerto interno: 50000-50100
- IP: 192.168.1.19
- Protocolo: UDP
```
### URLs finales:
- **LiveKit API**: `ws://mi-livekit.duckdns.org:7880`
- **Con SSL**: `wss://mi-livekit.duckdns.org:443` (después de configurar SSL)

67
Dockerfile Normal file
View File

@ -0,0 +1,67 @@
# Multi-stage build para OpenVidu Meet
FROM node:20-alpine AS builder
# Instalar pnpm
RUN npm install -g pnpm
# Copiar archivos del workspace
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY meet-ce/backend/package.json ./meet-ce/backend/
COPY meet-ce/frontend/package.json ./meet-ce/frontend/
COPY meet-ce/frontend/projects/shared-meet-components/package.json ./meet-ce/frontend/projects/shared-meet-components/
# Instalar dependencias
RUN pnpm install --frozen-lockfile
# Copiar código fuente
COPY . .
# Build backend
WORKDIR /app/meet-ce/backend
RUN pnpm run build
# Build frontend
WORKDIR /app/meet-ce/frontend
RUN pnpm run build:prod
# Imagen de producción
FROM node:20-alpine AS production
# Instalar pnpm
RUN npm install -g pnpm
# Crear usuario no-root
RUN addgroup -g 1001 -S nodejs && \
adduser -S openvidu -u 1001
WORKDIR /app
# Copiar package.json y dependencias
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY meet-ce/backend/package.json ./meet-ce/backend/
# Instalar solo dependencias de producción
RUN pnpm install --prod --frozen-lockfile
# Copiar archivos compilados
COPY --from=builder /app/meet-ce/backend/dist ./meet-ce/backend/dist
COPY --from=builder /app/meet-ce/backend/public ./meet-ce/backend/public
# Cambiar propietario
RUN chown -R openvidu:nodejs /app
USER openvidu
# Variables de entorno por defecto
ENV NODE_ENV=production
ENV PORT=6080
ENV MEET_BLOB_STORAGE_MODE=memory
ENV MEET_LOG_LEVEL=info
# Exponer puerto
EXPOSE 6080
# Comando de inicio
WORKDIR /app/meet-ce/backend
CMD ["node", "dist/src/server.js"]

52
EASYPANEL-COMPARACION.md Normal file
View File

@ -0,0 +1,52 @@
# COMPARACIÓN: OPCIONES PARA EASYPANEL
## 📊 Tabla comparativa
| Opción | Configuración UDP | Costo | Complejidad | Rendimiento | Recomendado |
|--------|------------------|-------|-------------|-------------|-------------|
| **LiveKit Cloud** | ❌ No necesario | $20-100/mes | ⭐ Muy fácil | ⭐⭐⭐ Excelente | ✅ **SÍ** |
| **VPS Híbrido** | ✅ En VPS separado | $10-30/mes | ⭐⭐ Medio | ⭐⭐⭐ Excelente | ⚠️ Si budget limitado |
| **TURN Server** | ✅ 3 puertos en VPS | $5-15/mes | ⭐⭐⭐ Complejo | ⭐⭐ Bueno | ⚠️ Para expertos |
| **EasyPanel Solo** | ❌ Imposible | $0 extra | ❌ No funciona | ❌ No WebRTC | ❌ NO |
## 🏆 RECOMENDACIÓN FINAL
### Para la mayoría de casos: **LiveKit Cloud**
```env
# .env.production para EasyPanel
LIVEKIT_URL=wss://tu-proyecto.livekit.cloud
LIVEKIT_API_KEY=api-key-de-cloud
LIVEKIT_API_SECRET=secret-de-cloud
```
### Ventajas de LiveKit Cloud:
- ✅ **Cero configuración UDP**
- ✅ **Sin VPS adicionales**
- ✅ **Infraestructura global**
- ✅ **Escalabilidad automática**
- ✅ **Soporte oficial**
- ✅ **TURN servers incluidos**
- ✅ **Monitoreo y analytics**
### Costos LiveKit Cloud:
- **Free tier**: 50GB/mes gratis
- **Starter**: $20/mes - 500GB
- **Pro**: $99/mes - 2TB + features
### Setup LiveKit Cloud:
1. **Registro**: https://cloud.livekit.io
2. **Crear proyecto**
3. **Copiar credenciales**
4. **Configurar en EasyPanel**
5. **Deploy**
## 🎯 PASOS SIGUIENTE PARA TI
¿Qué opción prefieres?
1. **LiveKit Cloud** (fácil, costo medio)
2. **VPS Híbrido** (control total, setup complejo)
3. **TURN Server** (experto, costo bajo)
Te ayudo a configurar la que elijas.

113
EASYPANEL-README.md Normal file
View File

@ -0,0 +1,113 @@
# CONFIGURACIÓN EASYPANEL - OpenVidu Meet
# ========================================
## 📋 CONFIGURACIÓN DEL PROYECTO
### 1. Crear Proyecto en EasyPanel
- Tipo: Docker Compose o Docker Build
- Repositorio: Tu repo con estos archivos
- Branch: main
### 2. Variables de Entorno Requeridas
```env
# Básicas
NODE_ENV=production
MEET_LOG_LEVEL=info
MEET_BLOB_STORAGE_MODE=memory
# Admin (CAMBIAR)
ADMIN_PASSWORD=tu-password-seguro-aqui
# LiveKit (ajustar según tu setup)
LIVEKIT_URL=wss://tu-livekit-domain.com
LIVEKIT_API_KEY=tu-api-key
LIVEKIT_API_SECRET=tu-secret-32-caracteres-minimo
# Proxy
TRUST_PROXY=true
SERVER_CORS_ORIGIN=*
USE_HTTPS=true
```
### 3. Configuración de Puertos
- **Opción A (con Nginx)**: Puerto 80
- **Opción B (directo)**: Puerto 6080
### 4. Configuración de Dominio
- Agregar tu dominio en EasyPanel
- Habilitar SSL automático
- Configurar redirects HTTP → HTTPS
## 🐳 OPCIONES DE DEPLOY
### Opción A: Con Nginx Proxy (Recomendado)
```yaml
# Usar docker-compose.yml completo
# Puerto expuesto: 80/443
# Incluye rate limiting y optimizaciones
```
### Opción B: Solo Backend
```yaml
# Solo el servicio openvidu-meet del compose
# Puerto expuesto: 6080
# EasyPanel maneja el proxy
```
## 🔧 CONFIGURACIONES ADICIONALES
### LiveKit Setup
1. Desplegar LiveKit en servidor separado
2. Configurar LIVEKIT_URL con dominio público
3. Generar API keys seguros
### Redis (Opcional)
- Usar servicio Redis de EasyPanel
- O mantener storage en memoria para simplicidad
### SSL/TLS
- EasyPanel maneja certificados automáticamente
- Configurar HTTPS en variables de entorno
## 🚨 CONSIDERACIONES DE SEGURIDAD
1. **Cambiar credenciales por defecto**
2. **Usar secrets seguros para LiveKit**
3. **Configurar CORS apropiadamente**
4. **Habilitar rate limiting**
5. **Usar HTTPS únicamente**
## 📊 MONITOREO
### Health Checks
- `/nginx-health` - Estado del proxy
- `/api/health` - Estado del backend
- Logs en EasyPanel dashboard
### Métricas
- CPU/Memory usage
- Response times
- Error rates
## 🔄 ACTUALIZACIONES
1. Push cambios al repo
2. EasyPanel rebuilds automáticamente
3. Zero-downtime deployment
## 🐛 TROUBLESHOOTING
### Backend no arranca
- Verificar variables de entorno
- Revisar logs en EasyPanel
- Verificar puertos
### Error de proxy
- Verificar nginx.conf
- Revisar headers
- Verificar upstreams
### LiveKit no conecta
- Verificar LIVEKIT_URL
- Verificar API keys
- Verificar connectivity

164
EASYPANEL-SIMPLE.md Normal file
View File

@ -0,0 +1,164 @@
# 🚀 **DEPLOYMENT EN EASYPANEL - SIMPLIFICADO**
## 📋 **RESUMEN DE CONFIGURACIÓN**
✅ **EasyPanel maneja automáticamente:**
- SSL/TLS con certificados gratuitos
- Subdominios (ej: `tu-app.easypanel.host`)
- Proxy reverso con Traefik
- HTTPS redirect
✅ **Tu aplicación expone:**
- Solo puerto 80 (HTTP)
- Nginx como proxy interno
- OpenVidu Meet backend
---
## 🔧 **PASOS DE DEPLOYMENT**
### **1. Preparar archivos**
```bash
# Los archivos ya están listos:
# ✅ Dockerfile (optimizado)
# ✅ docker-compose.yml (puerto 80 solamente)
# ✅ nginx.conf (sin SSL - solo HTTP)
# ✅ .env.production (variables de entorno)
```
### **2. Subir a repositorio Git**
```bash
git add Dockerfile docker-compose.yml nginx.conf .env.production
git commit -m "Add EasyPanel deployment config"
git push
```
### **3. Crear proyecto en EasyPanel**
#### **Opción A: Docker Compose (Recomendado)**
1. **Nuevo Proyecto** → **Deploy from Git**
2. **Conectar repositorio**
3. **Tipo:** Docker Compose
4. **Archivo:** `docker-compose.yml`
5. **Puerto expuesto:** `80`
#### **Opción B: Dockerfile simple**
1. **Nuevo Proyecto** → **Deploy from Git**
2. **Tipo:** Dockerfile
3. **Puerto:** `6080`
4. **Health Check:** `/health`
### **4. Variables de entorno en EasyPanel**
En el dashboard de EasyPanel, configurar:
```env
# ADMIN (¡CAMBIAR!)
ADMIN_PASSWORD=mi-password-super-seguro
# LIVEKIT (configurar según tu setup)
LIVEKIT_URL=wss://tu-livekit-domain.com
LIVEKIT_API_KEY=tu-api-key
LIVEKIT_API_SECRET=tu-secret-de-32-caracteres
# REDIS (opcional)
REDIS_HOST=tu-redis-host
REDIS_PASSWORD=tu-redis-password
```
### **5. Deploy**
- Hacer clic en **Deploy**
- EasyPanel construirá automáticamente
- Generará subdominio (ej: `openvidu-meet.easypanel.host`)
- Aplicará SSL automáticamente
---
## 🌐 **RESULTADO FINAL**
```
https://tu-app.easypanel.host
├── EasyPanel Traefik Proxy (SSL/HTTPS)
└── Tu Container (puerto 80)
├── Nginx (proxy interno)
└── OpenVidu Meet Backend (:6080)
```
### **URLs disponibles:**
- **Aplicación:** `https://tu-app.easypanel.host`
- **Admin Login:** `https://tu-app.easypanel.host/admin`
- **API:** `https://tu-app.easypanel.host/api/`
- **Health Check:** `https://tu-app.easypanel.host/health`
---
## 🔧 **CONFIGURACIÓN LIVEKIT**
Para que funcione completamente, necesitas **LiveKit server** separado:
### **Opción 1: LiveKit en EasyPanel (otro proyecto)**
```yaml
# livekit.yaml para EasyPanel
port: 7880
redis:
address: tu-redis:6379
password: tu-password
```
### **Opción 2: LiveKit Cloud**
- Registrarse en [LiveKit Cloud](https://cloud.livekit.io)
- Copiar `LIVEKIT_URL`, `API_KEY`, `API_SECRET`
- Configurar en variables de entorno
---
## 🔐 **SEGURIDAD**
### **Cambiar credenciales por defecto:**
```env
ADMIN_PASSWORD=un-password-muy-seguro
LIVEKIT_API_SECRET=secret-de-al-menos-32-caracteres
```
### **Headers de seguridad incluidos:**
- Rate limiting (API: 10req/s, Login: 1req/s)
- X-Frame-Options
- X-Content-Type-Options
- X-XSS-Protection
---
## 🚨 **TROUBLESHOOTING**
### **El container no inicia:**
```bash
# Ver logs en EasyPanel dashboard
# O conectar por SSH:
docker logs container-name
```
### **502 Bad Gateway:**
- Verificar que el backend responde en puerto 6080
- Health check: `curl localhost:6080/health`
### **WebSocket no funciona:**
- Verificar configuración de LiveKit
- Headers de WebSocket están configurados en nginx
### **Admin login no funciona:**
- Verificar variable `ADMIN_PASSWORD`
- Limpiar datos Redis si está configurado
---
## ✅ **CHECKLIST FINAL**
- [ ] Repository con archivos de deployment subido
- [ ] Proyecto creado en EasyPanel
- [ ] Variables de entorno configuradas
- [ ] Password admin cambiado
- [ ] LiveKit configurado (separado)
- [ ] SSL funcionando automáticamente
- [ ] Admin login funcional en `/admin`
**¡Ya tienes OpenVidu Meet funcionando en producción con EasyPanel!** 🎉

104
EASYPANEL-TURN-SERVER.md Normal file
View File

@ -0,0 +1,104 @@
# SOLUCIÓN: TURN SERVER PARA EASYPANEL
## 🔄 TURN Server como alternativa para NAT traversal
### ¿Qué es TURN?
TURN (Traversal Using Relays around NAT) permite que WebRTC funcione sin exponer miles de puertos UDP.
## 🏗️ ARQUITECTURA CON TURN
```
Cliente → Internet → TURN Server → EasyPanel
(3 puertos) (sin UDP)
Vs. directo:
Cliente → Internet → EasyPanel
(10,000 UDP) ❌ No posible
```
## 📋 IMPLEMENTACIÓN
### 1. TURN Server en VPS separado
```bash
# Instalar Coturn en VPS
apt-get update
apt-get install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
external-ip=IP_PUBLICA_VPS
realm=turn.tu-dominio.com
lt-cred-mech
user=usuario:password123
verbose
```
### 2. Firewall VPS (Solo 3 puertos)
```bash
# Solo estos 3 puertos para TURN
ufw allow 3478/tcp # TURN TCP
ufw allow 3478/udp # TURN UDP
ufw allow 5349/tcp # TURN over TLS
ufw enable
```
### 3. LiveKit en EasyPanel con TURN
```yaml
# livekit.yaml para EasyPanel
port: 7880
keys:
devkey: tu-secret-32-chars
# SIN puertos UDP locales - usar TURN
rtc:
# NO port_range - usa TURN
use_external_ip: false
# Configurar TURN servers
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["turn:turn.tu-dominio.com:3478"]
username: "usuario"
credential: "password123"
- urls: ["turns:turn.tu-dominio.com:5349"]
username: "usuario"
credential: "password123"
```
### 4. Variables EasyPanel
```env
# Solo TCP - SIN UDP
LIVEKIT_URL=wss://tu-app.easypanel.host/livekit
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=tu-secret-32-chars
```
### 5. Nginx en EasyPanel para proxy LiveKit
```nginx
# nginx.conf - agregar ruta para LiveKit
location /livekit {
proxy_pass http://openvidu-meet:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
## 💰 COSTOS
- **TURN VPS**: $5-10/mes (pequeño VPS)
- **EasyPanel**: Tu plan actual
- **Total**: +$5-10/mes vs LiveKit Cloud
## ✅ VENTAJAS
- ✅ Solo 3 puertos UDP en VPS externo
- ✅ EasyPanel sin UDP
- ✅ NAT traversal garantizado
- ✅ Menor costo que LiveKit Cloud
## ❌ DESVENTAJAS
- ❌ Configuración más compleja
- ❌ VPS adicional para TURN
- ❌ Latencia adicional (relay)
- ❌ Ancho de banda TURN server

View File

@ -0,0 +1,29 @@
# LIMITACIONES DE EASYPANEL PARA UDP
## ❌ Por qué EasyPanel NO puede exponer UDP:
### Arquitectura de EasyPanel:
```
Internet → Traefik (HTTP/HTTPS Proxy) → Tu Container
Solo maneja TCP/HTTP/HTTPS
NO puede proxy UDP
```
### Limitaciones técnicas:
1. **Traefik**: Solo HTTP/HTTPS reverse proxy
2. **Docker networking**: Limitado a puertos TCP expuestos
3. **UI de EasyPanel**: Solo configuración HTTP
4. **Load balancing**: Diseñado para web apps, no media streaming
### Puertos disponibles en EasyPanel:
- ✅ 80 (HTTP)
- ✅ 443 (HTTPS)
- ✅ Puertos TCP custom
- ❌ Puertos UDP (NO DISPONIBLE)
## ⚠️ Problemas si intentas exponer UDP:
- EasyPanel UI no tiene opción para UDP
- Traefik no puede hacer proxy de UDP
- Docker compose limitado a TCP en EasyPanel
- No hay configuración de port ranges UDP

129
EASYPANEL-VPS-HIBRIDO.md Normal file
View File

@ -0,0 +1,129 @@
# SOLUCIÓN: LIVEKIT EN VPS SEPARADO + EASYPANEL
## 🏗️ ARQUITECTURA HÍBRIDA
```
┌─ EasyPanel ────────────────┐ ┌─ VPS Separado ─────────┐
│ │ │ │
│ OpenVidu Meet Backend ──────────→ LiveKit Server │
│ (HTTP/HTTPS only) │ │ (UDP 50000-60000) │
│ │ │ │
└────────────────────────────┘ └────────────────────────┘
↑ ↑
Traefik/SSL Firewall/UDP abierto
```
## 📋 CONFIGURACIÓN PASO A PASO
### 1. EasyPanel (Solo OpenVidu Meet Backend)
```yaml
# docker-compose.yml para EasyPanel
version: '3.8'
services:
openvidu-meet:
build: .
environment:
# LiveKit en VPS externo
LIVEKIT_URL: wss://livekit.tu-vps.com:7880
LIVEKIT_API_KEY: devkey
LIVEKIT_API_SECRET: tu-secret-32-chars
ports:
- "80:6080" # Solo HTTP - EasyPanel maneja SSL
```
### 2. VPS Separado (Solo LiveKit + Redis)
```yaml
# docker-compose.yml en VPS
version: '3.8'
services:
livekit:
image: livekit/livekit-server:latest
ports:
- "7880:7880" # API/WebSocket
- "50000-60000:50000-60000/udp" # WebRTC
volumes:
- ./livekit.yaml:/livekit.yaml
command: --config /livekit.yaml
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --requirepass redispassword
```
### 3. Configuración LiveKit en VPS
```yaml
# livekit.yaml en VPS
port: 7880
bind_addresses: ["0.0.0.0"]
keys:
devkey: tu-secret-de-32-caracteres-minimo
redis:
address: "localhost:6379"
password: "redispassword"
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
external_ip: "IP_PUBLICA_DEL_VPS"
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
```
### 4. Firewall en VPS
```bash
# Configurar firewall en VPS
ufw allow 7880/tcp # LiveKit API
ufw allow 50000:60000/udp # WebRTC UDP
ufw allow 6379/tcp # Redis (si acceso externo)
ufw enable
```
### 5. SSL para LiveKit (Nginx en VPS)
```nginx
# /etc/nginx/sites-available/livekit
server {
listen 443 ssl;
server_name livekit.tu-vps.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
## 💰 COSTOS ESTIMADOS
### VPS para LiveKit:
- **Básico**: $5-10/mes (2GB RAM, 1 CPU)
- **Medio**: $15-25/mes (4GB RAM, 2 CPU)
- **Alto**: $30-50/mes (8GB RAM, 4 CPU)
### Proveedores recomendados:
- DigitalOcean
- Linode
- Hetzner
- Vultr
## ✅ VENTAJAS
- ✅ EasyPanel para web app (fácil)
- ✅ VPS dedicado para WebRTC (potencia)
- ✅ Escalabilidad independiente
- ✅ Control total sobre LiveKit
## ❌ DESVENTAJAS
- ❌ Costo adicional VPS
- ❌ Más complejidad de setup
- ❌ Mantenimiento de dos servicios

180
LIVEKIT-SELFHOST-SERVER.md Normal file
View File

@ -0,0 +1,180 @@
# SERVIDOR LIVEKIT SELF-HOSTING DEDICADO
## 🖥️ Setup en servidor dedicado (192.168.1.19)
### Docker Compose para LiveKit Server:
```yaml
# docker-compose-livekit-server.yml
version: '3.8'
services:
# LiveKit Server Principal
livekit-server:
image: livekit/livekit-server:latest
container_name: livekit-production
restart: unless-stopped
ports:
# API/WebSocket (EXPONER PÚBLICAMENTE)
- "7880:7880"
# Rango UDP para WebRTC (EXPONER PÚBLICAMENTE)
- "50000-50100:50000-50100/udp" # 100 puertos para ~10 usuarios concurrentes
volumes:
- ./livekit-production.yaml:/livekit.yaml:ro
- ./logs:/app/logs
command: --config /livekit.yaml
environment:
- LIVEKIT_CONFIG=/livekit.yaml
networks:
- livekit-network
depends_on:
- redis
# Redis para LiveKit
redis:
image: redis:7-alpine
container_name: livekit-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --requirepass ${REDIS_PASSWORD:-livekitredis123}
volumes:
- redis_data:/data
networks:
- livekit-network
# Nginx SSL Termination (para HTTPS/WSS)
nginx-livekit:
image: nginx:alpine
container_name: livekit-nginx
restart: unless-stopped
ports:
- "443:443" # HTTPS/WSS (EXPONER PÚBLICAMENTE)
- "80:80" # HTTP redirect
volumes:
- ./nginx-livekit.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro # Certificados SSL
depends_on:
- livekit-server
networks:
- livekit-network
volumes:
redis_data:
networks:
livekit-network:
driver: bridge
```
### Configuración LiveKit Production:
```yaml
# livekit-production.yaml
port: 7880
bind_addresses: ["0.0.0.0"]
# API Keys seguros
keys:
production-key: tu-super-secret-de-32-caracteres-o-mas
# Redis para scaling y persistence
redis:
address: "redis:6379"
password: "livekitredis123"
db: 0
# RTC Configuration para acceso público
rtc:
# Puertos UDP (coincidir con docker-compose)
port_range_start: 50000
port_range_end: 50100
# IP pública/externa (tu IP pública o dominio)
use_external_ip: true
external_ip: "TU_IP_PUBLICA_O_DOMINIO" # ej: "mi-casa.duckdns.org"
# STUN servers para NAT traversal
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["stun:stun1.l.google.com:19302"]
# Room settings para producción
room:
auto_create: true
max_participants: 50
empty_timeout: 600 # 10 minutos
# Security
webhook:
# Opcional: webhook para eventos
api_key: "tu-webhook-key"
# Logging
log_level: info
log_format: json
# Enable egress (grabaciones)
# Automático con Redis
```
### Nginx SSL para LiveKit:
```nginx
# nginx-livekit.conf
events {
worker_connections 1024;
}
http {
# Redirect HTTP to HTTPS
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS/WSS Server
server {
listen 443 ssl http2;
server_name _;
# SSL Configuration
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# WebSocket support para LiveKit
location / {
proxy_pass http://livekit-server:7880;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts para WebRTC
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
}
```
## 🔥 Firewall en servidor LiveKit:
```bash
# UFW rules para exposición pública segura
sudo ufw allow 80/tcp comment "HTTP redirect"
sudo ufw allow 443/tcp comment "HTTPS/WSS LiveKit"
sudo ufw allow 7880/tcp comment "LiveKit API directo"
sudo ufw allow 50000:50100/udp comment "WebRTC UDP range"
# Opcional: limitar SSH a red local solamente
sudo ufw allow from 192.168.1.0/24 to any port 22
sudo ufw enable
sudo ufw status numbered
```

40
LIVEKIT-TURN-CONFIG.md Normal file
View File

@ -0,0 +1,40 @@
# CONFIGURACIÓN TURN PARA LIVEKIT
## 🔧 Si necesitas TURN server para LiveKit detrás de firewall:
### 1. Configurar Coturn (TURN server)
```bash
# Instalar coturn
apt-get install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
external-ip=TU_IP_PUBLICA
realm=tu-dominio.com
lt-cred-mech
user=usuario:password
```
### 2. Configurar LiveKit con TURN
```yaml
# livekit.yaml
rtc:
port_range_start: 50000
port_range_end: 60000
ice_servers:
- urls:
- "stun:stun.l.google.com:19302"
- "turn:tu-turn-server.com:3478"
username: "usuario"
credential: "password"
```
### 3. Firewall para TURN
```bash
# Puertos necesarios
ufw allow 3478/tcp # TURN TCP
ufw allow 3478/udp # TURN UDP
ufw allow 5349/tcp # TURN TLS
ufw allow 50000:60000/udp # Media streams
```

103
ROUTER-CONFIG-LIVEKIT.md Normal file
View File

@ -0,0 +1,103 @@
# CONFIGURACIÓN ROUTER - PORT FORWARDING PARA LIVEKIT
## 🌐 Port Forwarding necesario en tu Router
### Puertos a exponer públicamente:
| Servicio | Puerto | Protocolo | IP Interna | Descripción |
|----------|---------|-----------|------------|-------------|
| **HTTP** | 80 | TCP | 192.168.1.19 | Redirect a HTTPS |
| **HTTPS/WSS** | 443 | TCP | 192.168.1.19 | LiveKit WebSocket Secure |
| **LiveKit API** | 7880 | TCP | 192.168.1.19 | API directa (opcional) |
| **WebRTC Media** | 50000-50100 | UDP | 192.168.1.19 | Streams de audio/video |
### Configuración típica router:
```
Regla 1: LiveKit-HTTPS
- Servicio: HTTPS/Custom
- Puerto externo: 443
- Puerto interno: 443
- IP interna: 192.168.1.19
- Protocolo: TCP
- Estado: Habilitado
Regla 2: LiveKit-HTTP
- Servicio: HTTP
- Puerto externo: 80
- Puerto interno: 80
- IP interna: 192.168.1.19
- Protocolo: TCP
- Estado: Habilitado
Regla 3: LiveKit-WebRTC
- Servicio: Custom
- Puerto externo: 50000-50100
- Puerto interno: 50000-50100
- IP interna: 192.168.1.19
- Protocolo: UDP
- Estado: Habilitado
```
## 🏠 IP Dinámica - Solución con DuckDNS
### Si tu IP pública cambia (típico en casa):
```bash
# 1. Crear cuenta en DuckDNS.org
# 2. Crear subdominio: mi-livekit.duckdns.org
# 3. Script de actualización automática
# /home/usuario/update-duckdns.sh
#!/bin/bash
echo url="https://www.duckdns.org/update?domains=mi-livekit&token=TU_TOKEN&ip=" | curl -k -o ~/duckdns.log -K -
# Crontab para actualizar cada 5 minutos
# crontab -e
*/5 * * * * /home/usuario/update-duckdns.sh >/dev/null 2>&1
```
### Configurar dominio en LiveKit:
```yaml
# livekit-production.yaml
rtc:
external_ip: "mi-livekit.duckdns.org" # En lugar de IP
```
## 🔒 Certificado SSL automático con Let's Encrypt
```bash
# Instalar certbot
sudo apt install certbot
# Generar certificado para tu dominio
sudo certbot certonly --standalone -d mi-livekit.duckdns.org
# Copiar certificados para Docker
sudo cp /etc/letsencrypt/live/mi-livekit.duckdns.org/fullchain.pem ./ssl/cert.pem
sudo cp /etc/letsencrypt/live/mi-livekit.duckdns.org/privkey.pem ./ssl/key.pem
sudo chown $USER:$USER ./ssl/*.pem
# Auto-renovación (crontab)
0 12 * * * /usr/bin/certbot renew --quiet && docker-compose restart nginx-livekit
```
## 📊 Verificación de conectividad
### Tests externos:
```bash
# Test puertos desde internet
nmap -p 80,443,7880 mi-livekit.duckdns.org
nmap -sU -p 50000-50010 mi-livekit.duckdns.org
# Test WebSocket
wscat -c wss://mi-livekit.duckdns.org
# Test HTTPS
curl -I https://mi-livekit.duckdns.org
```
### URLs finales:
- **LiveKit WSS**: `wss://mi-livekit.duckdns.org`
- **API HTTP**: `https://mi-livekit.duckdns.org`
- **Monitoreo**: `https://mi-livekit.duckdns.org/debug`

106
UDP-FIREWALL-CONFIG.md Normal file
View File

@ -0,0 +1,106 @@
# CONFIGURACIÓN MANUAL DE PUERTOS UDP PARA LIVEKIT
## 🔥 FIREWALL UBUNTU/DEBIAN (UFW)
```bash
# Puertos TCP
sudo ufw allow 80/tcp comment "HTTP"
sudo ufw allow 6080/tcp comment "OpenVidu Meet"
sudo ufw allow 6379/tcp comment "Redis"
sudo ufw allow 7880/tcp comment "LiveKit API"
# Puertos UDP para WebRTC
sudo ufw allow 50000:60000/udp comment "LiveKit WebRTC"
# Verificar
sudo ufw status numbered
```
## 🔥 FIREWALL CENTOS/RHEL (firewalld)
```bash
# Puertos TCP
sudo firewall-cmd --permanent --add-port=80/tcp
sudo firewall-cmd --permanent --add-port=6080/tcp
sudo firewall-cmd --permanent --add-port=6379/tcp
sudo firewall-cmd --permanent --add-port=7880/tcp
# Puertos UDP
sudo firewall-cmd --permanent --add-port=50000-60000/udp
# Aplicar
sudo firewall-cmd --reload
sudo firewall-cmd --list-ports
```
## 🖥️ ROUTER/MODEM (Para acceso externo)
Si quieres acceso desde internet:
### Port Forwarding necesario:
- **TCP 80** → Tu servidor (OpenVidu Meet)
- **TCP 7880** → Tu servidor (LiveKit API)
- **UDP 50000-60000** → Tu servidor (WebRTC media)
### Configuración típica router:
```
Servicio: OpenVidu-HTTP
Puerto externo: 80
Puerto interno: 80
IP interna: 192.168.1.19
Protocolo: TCP
Servicio: LiveKit-API
Puerto externo: 7880
Puerto interno: 7880
IP interna: 192.168.1.19
Protocolo: TCP
Servicio: WebRTC-Media
Puerto externo: 50000-60000
Puerto interno: 50000-60000
IP interna: 192.168.1.19
Protocolo: UDP
```
## 🔍 VERIFICACIÓN DE PUERTOS
### Verificar puertos abiertos:
```bash
# Ver todos los puertos TCP/UDP en uso
sudo ss -tulnp
# Específicos de LiveKit
sudo ss -tulnp | grep -E "(7880|50000|60000)"
# Verificar desde otro dispositivo
nmap -p 7880,50000-50010 192.168.1.19
```
### Test de conectividad:
```bash
# Test TCP (LiveKit API)
curl http://192.168.1.19:7880
# Test WebSocket
wscat -c ws://192.168.1.19:7880
# Test UDP (requiere herramientas específicas)
nc -u 192.168.1.19 50000
```
## ⚠️ CONSIDERACIONES IMPORTANTES
### Para red local:
- ✅ Solo configurar firewall del servidor
- ✅ Usar IP local (192.168.x.x)
- ✅ No necesita port forwarding
### Para acceso externo:
- ⚠️ Configurar port forwarding en router
- ⚠️ Usar IP pública o dominio
- ⚠️ Configurar HTTPS para LiveKit
- ⚠️ Considerar seguridad (VPN, etc.)
### Rango de puertos UDP:
- **Mínimo:** 100 puertos (ej: 50000-50100)
- **Recomendado:** 1000 puertos (50000-51000)
- **Máximo configurado:** 10000 puertos (50000-60000)
- **Cálculo:** ~10 puertos por participante simultáneo

190
configure-livekit-domain.sh Executable file
View File

@ -0,0 +1,190 @@
#!/bin/bash
# Script para configurar dominio automáticamente para LiveKit
set -e
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🌐 Configurador de Dominio para LiveKit${NC}"
echo ""
# Detectar IP pública
echo "🔍 Detectando IP pública..."
PUBLIC_IP=$(curl -s https://checkip.amazonaws.com || curl -s https://ipinfo.io/ip || echo "No detectada")
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo -e "${BLUE}📊 Información de red:${NC}"
echo " IP Local: $LOCAL_IP"
echo " IP Pública: $PUBLIC_IP"
echo ""
# Opciones de dominio
echo "¿Qué tipo de dominio quieres configurar?"
echo "1) DuckDNS (gratuito, IP dinámica)"
echo "2) Dominio propio + Let's Encrypt"
echo "3) Solo IP pública (sin dominio)"
echo ""
read -p "Selecciona opción (1-3): " DOMAIN_OPTION
case $DOMAIN_OPTION in
1)
echo -e "${GREEN}🦆 Configurando DuckDNS${NC}"
read -p "Subdominio DuckDNS (sin .duckdns.org): " DUCKDNS_SUBDOMAIN
read -p "Token DuckDNS: " DUCKDNS_TOKEN
DOMAIN="$DUCKDNS_SUBDOMAIN.duckdns.org"
# Crear script de actualización
cat > update-duckdns.sh << EOF
#!/bin/bash
CURRENT_IP=\$(curl -s https://checkip.amazonaws.com)
RESPONSE=\$(curl -s "https://www.duckdns.org/update?domains=$DUCKDNS_SUBDOMAIN&token=$DUCKDNS_TOKEN&ip=\$CURRENT_IP")
if [ "\$RESPONSE" = "OK" ]; then
echo "\$(date): DuckDNS actualizado - $DOMAIN → \$CURRENT_IP"
else
echo "\$(date): ERROR: \$RESPONSE"
fi
EOF
chmod +x update-duckdns.sh
# Actualizar inmediatamente
./update-duckdns.sh
# Configurar cron
(crontab -l 2>/dev/null; echo "*/5 * * * * $(pwd)/update-duckdns.sh >> $(pwd)/duckdns.log 2>&1") | crontab -
echo -e "${GREEN}✅ DuckDNS configurado: $DOMAIN${NC}"
LIVEKIT_URL="ws://$DOMAIN:7880"
;;
2)
echo -e "${GREEN}🏠 Configurando dominio propio${NC}"
read -p "Dominio completo (ej: livekit.midominio.com): " CUSTOM_DOMAIN
DOMAIN="$CUSTOM_DOMAIN"
echo -e "${YELLOW}📋 Pasos manuales necesarios:${NC}"
echo "1. Configurar DNS A record:"
echo " $DOMAIN$PUBLIC_IP"
echo ""
echo "2. Port forwarding en router:"
echo " TCP 80,443,7880 → $LOCAL_IP"
echo " UDP 50000-50100 → $LOCAL_IP"
echo ""
read -p "¿Continuar con configuración SSL automática? (y/N): " SSL_SETUP
if [[ $SSL_SETUP =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}🔧 Configurando Nginx + SSL...${NC}"
# Instalar dependencias
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
# Configurar Nginx básico
sudo tee /etc/nginx/sites-available/livekit << EOF
server {
listen 80;
server_name $DOMAIN;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://\$server_name\$request_uri;
}
}
EOF
sudo ln -sf /etc/nginx/sites-available/livekit /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl restart nginx
# Generar certificado
sudo certbot --nginx -d $DOMAIN --non-interactive --agree-tos --email admin@$DOMAIN
echo -e "${GREEN}✅ SSL configurado para $DOMAIN${NC}"
fi
LIVEKIT_URL="wss://$DOMAIN"
;;
3)
echo -e "${YELLOW}📡 Usando IP pública directa${NC}"
DOMAIN="$PUBLIC_IP"
LIVEKIT_URL="ws://$PUBLIC_IP:7880"
;;
*)
echo -e "${RED}❌ Opción inválida${NC}"
exit 1
;;
esac
# Actualizar configuración LiveKit
echo -e "${YELLOW}🔧 Actualizando configuración LiveKit...${NC}"
# Actualizar livekit.yaml existente o crear nuevo
if [ -f "livekit-production.yaml" ]; then
sed -i "s/external_ip: .*/external_ip: \"$DOMAIN\"/" livekit-production.yaml
echo -e "${GREEN}✅ livekit-production.yaml actualizado${NC}"
elif [ -f "livekit.yaml" ]; then
sed -i "s/external_ip: .*/external_ip: \"$DOMAIN\"/" livekit.yaml
echo -e "${GREEN}✅ livekit.yaml actualizado${NC}"
else
echo -e "${YELLOW}⚠️ No se encontró archivo de configuración LiveKit${NC}"
fi
# Actualizar variables para OpenVidu Meet
cat > .env.livekit-domain << EOF
# Configuración de dominio para LiveKit
DOMAIN=$DOMAIN
LIVEKIT_URL=$LIVEKIT_URL
PUBLIC_IP=$PUBLIC_IP
LOCAL_IP=$LOCAL_IP
# Variables para EasyPanel/OpenVidu Meet:
LIVEKIT_URL=$LIVEKIT_URL
LIVEKIT_API_KEY=production-key
LIVEKIT_API_SECRET=tu-secret-de-32-caracteres
EOF
echo -e "${GREEN}"
echo "============================================="
echo "🎉 DOMINIO CONFIGURADO EXITOSAMENTE"
echo "============================================="
echo "🌐 Dominio: $DOMAIN"
echo "🔗 LiveKit URL: $LIVEKIT_URL"
echo "📍 IP Pública: $PUBLIC_IP"
echo "🏠 IP Local: $LOCAL_IP"
echo ""
echo "📋 CONFIGURACIÓN PARA OPENVIDU MEET:"
echo " LIVEKIT_URL=$LIVEKIT_URL"
echo ""
echo "🔧 PUERTOS NECESARIOS EN ROUTER:"
echo " TCP 7880 → $LOCAL_IP:7880"
echo " UDP 50000-50100 → $LOCAL_IP:50000-50100"
if [[ $DOMAIN_OPTION == 2 ]]; then
echo " TCP 80,443 → $LOCAL_IP:80,443"
fi
echo ""
echo "📁 Archivos generados:"
echo " - .env.livekit-domain (variables)"
if [[ $DOMAIN_OPTION == 1 ]]; then
echo " - update-duckdns.sh (actualización automática)"
fi
echo "============================================="
echo -e "${NC}"
# Test conectividad
echo -e "${BLUE}🔍 Probando conectividad...${NC}"
if timeout 5 bash -c "echo >/dev/tcp/$DOMAIN/7880" 2>/dev/null; then
echo -e "${GREEN}✅ Puerto 7880 accesible${NC}"
else
echo -e "${YELLOW}⚠️ Puerto 7880 no accesible (verificar port forwarding)${NC}"
fi

58
configure-udp-ports.sh Executable file
View File

@ -0,0 +1,58 @@
#!/bin/bash
# Script para configurar puertos UDP para LiveKit local
echo "🔧 Configurando puertos UDP para LiveKit..."
# Verificar si ufw está disponible
if command -v ufw &> /dev/null; then
echo "Configurando con UFW..."
# Puertos TCP para LiveKit API
sudo ufw allow 7880/tcp comment "LiveKit API"
# Rango de puertos UDP para WebRTC (según livekit.yaml)
sudo ufw allow 50000:60000/udp comment "LiveKit WebRTC UDP"
# Verificar reglas
echo "Reglas UFW configuradas:"
sudo ufw status numbered
elif command -v firewall-cmd &> /dev/null; then
echo "Configurando con firewalld..."
# Puerto TCP para LiveKit
sudo firewall-cmd --permanent --add-port=7880/tcp
# Rango UDP para WebRTC
sudo firewall-cmd --permanent --add-port=50000-60000/udp
# Recargar firewall
sudo firewall-cmd --reload
echo "Reglas firewalld configuradas:"
sudo firewall-cmd --list-ports
else
echo "⚠️ No se detectó UFW ni firewalld"
echo "Configurar manualmente:"
echo "- TCP 7880 (LiveKit API)"
echo "- UDP 50000-60000 (WebRTC media)"
fi
echo "✅ Configuración de firewall completada"
# Verificar que LiveKit esté corriendo
echo "🔍 Verificando LiveKit..."
if curl -s http://localhost:7880 > /dev/null 2>&1; then
echo "✅ LiveKit responde en puerto 7880"
else
echo "❌ LiveKit no responde - verificar que esté corriendo"
fi
# Mostrar puertos abiertos
echo "📊 Puertos actualmente en uso:"
if command -v ss &> /dev/null; then
ss -tulnp | grep -E "(7880|50000|60000)"
elif command -v netstat &> /dev/null; then
netstat -tulnp | grep -E "(7880|50000|60000)"
fi

136
configure-udp-security.sh Executable file
View File

@ -0,0 +1,136 @@
#!/bin/bash
# Script para configurar UDP de forma segura según el caso de uso
set -e
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🔧 Configuración segura de puertos UDP para LiveKit${NC}"
echo ""
# Preguntar caso de uso
echo "¿Cuál es tu caso de uso?"
echo "1) Solo red local (recomendado y seguro)"
echo "2) Acceso desde internet (complejo y riesgoso)"
echo "3) Mostrar configuración actual"
echo ""
read -p "Selecciona opción (1-3): " OPTION
case $OPTION in
1)
echo -e "${GREEN}✅ Configurando para RED LOCAL únicamente${NC}"
# Configurar firewall para solo red local
if command -v ufw &> /dev/null; then
echo "Configurando UFW para red local..."
# Permitir desde red local
sudo ufw allow from 192.168.0.0/16 to any port 50000:60000 proto udp comment "LiveKit UDP (red local)"
sudo ufw allow from 192.168.0.0/16 to any port 7880 proto tcp comment "LiveKit API (red local)"
sudo ufw allow from 192.168.0.0/16 to any port 80 proto tcp comment "HTTP (red local)"
# DENEGAR acceso externo a UDP
sudo ufw deny 50000:60000/udp comment "BLOQUEAR UDP externo"
echo -e "${GREEN}✅ Firewall configurado para red local${NC}"
sudo ufw status numbered
else
echo -e "${YELLOW}⚠️ UFW no disponible. Configurar manualmente:${NC}"
echo "- Permitir UDP 50000-60000 desde 192.168.x.x"
echo "- BLOQUEAR UDP desde internet"
fi
echo ""
echo -e "${GREEN}🔒 CONFIGURACIÓN SEGURA APLICADA:${NC}"
echo "- UDP 50000-60000: Solo red local"
echo "- Acceso web: http://192.168.1.19"
echo "- Sin port forwarding necesario"
echo "- Máxima seguridad"
;;
2)
echo -e "${RED}⚠️ CONFIGURACIÓN PARA ACCESO PÚBLICO${NC}"
echo ""
echo -e "${YELLOW}RIESGOS:${NC}"
echo "- 10,000 puertos UDP expuestos"
echo "- Posibles ataques de red"
echo "- Configuración compleja"
echo "- Problemas con NAT/CGNAT"
echo ""
echo -e "${BLUE}ALTERNATIVAS RECOMENDADAS:${NC}"
echo "1. LiveKit Cloud (sin UDP local)"
echo "2. VPN para usuarios remotos"
echo "3. TURN server para NAT traversal"
echo ""
read -p "¿Continuar con configuración pública? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}Configurando acceso público...${NC}"
if command -v ufw &> /dev/null; then
# Abrir UDP para todo el mundo (PELIGROSO)
sudo ufw allow 50000:60000/udp comment "LiveKit UDP PUBLICO"
sudo ufw allow 7880/tcp comment "LiveKit API PUBLICO"
sudo ufw allow 80/tcp comment "HTTP PUBLICO"
echo -e "${RED}❌ UDP EXPUESTO PÚBLICAMENTE${NC}"
fi
echo ""
echo -e "${RED}⚠️ CONFIGURACIÓN APLICADA (RIESGOSA):${NC}"
echo "- UDP 50000-60000: PÚBLICO"
echo "- Configurar port forwarding en router"
echo "- Usar IP pública en livekit.yaml"
echo "- Considerar VPN o LiveKit Cloud"
else
echo -e "${GREEN}✅ Configuración pública cancelada${NC}"
fi
;;
3)
echo -e "${BLUE}📊 Configuración actual:${NC}"
# Verificar puertos UDP
echo ""
echo "Puertos UDP en uso:"
if command -v ss &> /dev/null; then
ss -ulnp | grep -E ":(5[0-9]{4})" | head -10
fi
# Verificar firewall
echo ""
echo "Reglas de firewall:"
if command -v ufw &> /dev/null; then
sudo ufw status numbered | grep -E "(50000|7880|80)"
fi
# Verificar IP externa
echo ""
echo "IP externa detectada:"
curl -s ifconfig.me || echo "No disponible"
echo ""
echo "IP local:"
hostname -I | awk '{print $1}'
;;
*)
echo -e "${RED}❌ Opción inválida${NC}"
exit 1
;;
esac
echo ""
echo -e "${BLUE}💡 RECOMENDACIÓN FINAL:${NC}"
echo "Para máxima seguridad y simplicidad:"
echo "- Usar solo en red local"
echo "- Para acceso remoto: VPN o LiveKit Cloud"
echo "- NO exponer 10,000 puertos UDP públicamente"

100
deploy-easypanel.sh Executable file
View File

@ -0,0 +1,100 @@
#!/bin/bash
set -e
echo "🚀 PREPARANDO DEPLOY PARA EASYPANEL"
echo "==================================="
# 1. Crear directorio ssl
echo "1. Creando estructura de directorios..."
mkdir -p ssl logs
# 2. Crear certificados dummy (EasyPanel los reemplazará)
echo "2. Creando certificados dummy..."
if [ ! -f ssl/cert.pem ]; then
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" 2>/dev/null || \
echo "⚠️ OpenSSL no disponible - EasyPanel manejará SSL"
fi
# 3. Crear .dockerignore
echo "3. Optimizando build..."
cat > .dockerignore << 'EOF'
node_modules
.git
.env*
*.log
logs/
ssl/
*.md
.vscode
.idea
dist
coverage
.nyc_output
EOF
# 4. Verificar archivos necesarios
echo "4. Verificando archivos..."
REQUIRED_FILES=(
"Dockerfile"
"docker-compose.yml"
"nginx.conf"
".env.production"
)
for file in "${REQUIRED_FILES[@]}"; do
if [ -f "$file" ]; then
echo "$file"
else
echo "$file - FALTANTE"
exit 1
fi
done
# 5. Test de build local (opcional)
echo "5. ¿Quieres probar el build localmente? (y/n)"
read -r TEST_BUILD
if [ "$TEST_BUILD" = "y" ] || [ "$TEST_BUILD" = "Y" ]; then
echo "Construyendo imagen de prueba..."
docker build -t openvidu-meet-test . || {
echo "❌ Error en el build - revisar Dockerfile"
exit 1
}
echo "✅ Build exitoso"
fi
echo ""
echo "🎉 PREPARACIÓN COMPLETADA"
echo "========================="
echo ""
echo "📋 PASOS PARA EASYPANEL:"
echo ""
echo "1. Crear nuevo proyecto en EasyPanel"
echo "2. Conectar repositorio Git"
echo "3. Configurar variables de entorno:"
echo " - Copiar contenido de .env.production"
echo " - Ajustar LIVEKIT_URL y secrets"
echo ""
echo "4. Configurar build:"
echo " - Dockerfile: ./Dockerfile"
echo " - Puerto: 80 (nginx) o 6080 (directo)"
echo ""
echo "5. Configurar dominio y SSL en EasyPanel"
echo ""
echo "📁 ARCHIVOS LISTOS:"
echo " ✅ Dockerfile (multi-stage optimizado)"
echo " ✅ docker-compose.yml (con nginx proxy)"
echo " ✅ nginx.conf (configuración completa)"
echo " ✅ .env.production (variables de ejemplo)"
echo ""
echo "🔗 URLs después del deploy:"
echo " • Admin: https://tu-dominio.com"
echo " • API: https://tu-dominio.com/api/"
echo " • Health: https://tu-dominio.com/nginx-health"
echo ""
echo "👤 Login por defecto:"
echo " • Usuario: admin"
echo " • Contraseña: [configurar en ADMIN_PASSWORD]"

221
deploy-livekit-selfhost.sh Executable file
View File

@ -0,0 +1,221 @@
#!/bin/bash
# Script para desplegar LiveKit self-hosted con exposición pública
set -e
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🏠 Configurando LiveKit Self-Hosted con exposición pública${NC}"
echo ""
# Detectar IP local
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo -e "${BLUE}🌐 IP Local detectada: $LOCAL_IP${NC}"
# Preguntar dominio/IP pública
echo "¿Cuál es tu configuración de acceso público?"
echo "1) Tengo IP pública fija"
echo "2) IP dinámica - usar DuckDNS"
echo "3) Solo testing local"
echo ""
read -p "Selecciona opción (1-3): " IP_OPTION
case $IP_OPTION in
1)
read -p "Ingresa tu IP pública: " PUBLIC_IP
EXTERNAL_HOST="$PUBLIC_IP"
;;
2)
read -p "Ingresa tu subdominio DuckDNS (ej: mi-livekit): " DUCKDNS_SUBDOMAIN
EXTERNAL_HOST="$DUCKDNS_SUBDOMAIN.duckdns.org"
echo -e "${YELLOW}📝 Recuerda configurar DuckDNS token después${NC}"
;;
3)
EXTERNAL_HOST="$LOCAL_IP"
echo -e "${YELLOW}⚠️ Solo funcionará en red local${NC}"
;;
*)
echo -e "${RED}❌ Opción inválida${NC}"
exit 1
;;
esac
echo -e "${GREEN}🌐 Host externo configurado: $EXTERNAL_HOST${NC}"
# Generar secretos seguros
API_SECRET=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 16)
echo -e "${YELLOW}🔧 Generando configuración...${NC}"
# Crear directorio SSL
mkdir -p ssl logs
# Generar livekit-production.yaml
cat > livekit-production.yaml << EOF
port: 7880
bind_addresses: ["0.0.0.0"]
# API Keys seguros (generados automáticamente)
keys:
production-key: $API_SECRET
# Redis para persistence y scaling
redis:
address: "redis:6379"
password: "$REDIS_PASSWORD"
db: 0
# RTC Configuration para acceso público
rtc:
# Rango de puertos UDP reducido pero suficiente
port_range_start: 50000
port_range_end: 50100
# Host/IP externa para acceso público
use_external_ip: true
external_ip: "$EXTERNAL_HOST"
# STUN servers para NAT traversal
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
- urls: ["stun:stun1.l.google.com:19302"]
# Room settings para producción
room:
auto_create: true
max_participants: 25
empty_timeout: 600
# Logging para producción
log_level: info
log_format: json
EOF
# Crear docker-compose-livekit-server.yml
cat > docker-compose-livekit-server.yml << EOF
version: '3.8'
services:
livekit-server:
image: livekit/livekit-server:latest
container_name: livekit-production
restart: unless-stopped
ports:
- "7880:7880"
- "50000-50100:50000-50100/udp"
volumes:
- ./livekit-production.yaml:/livekit.yaml:ro
- ./logs:/app/logs
command: --config /livekit.yaml
networks:
- livekit-network
depends_on:
- redis
redis:
image: redis:7-alpine
container_name: livekit-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --requirepass $REDIS_PASSWORD
volumes:
- redis_data:/data
networks:
- livekit-network
volumes:
redis_data:
networks:
livekit-network:
driver: bridge
EOF
# Crear variables para OpenVidu Meet
cat > .env.livekit-client << EOF
# Variables para EasyPanel/OpenVidu Meet
LIVEKIT_URL=ws://$EXTERNAL_HOST:7880
LIVEKIT_API_KEY=production-key
LIVEKIT_API_SECRET=$API_SECRET
EOF
echo -e "${GREEN}✅ Configuración generada${NC}"
# Configurar firewall
echo -e "${YELLOW}🔥 Configurando firewall...${NC}"
if command -v ufw &> /dev/null; then
sudo ufw allow 7880/tcp comment "LiveKit API"
sudo ufw allow 50000:50100/udp comment "LiveKit WebRTC"
echo -e "${GREEN}✅ Firewall configurado${NC}"
fi
# Parar servicios existentes
echo -e "${YELLOW}🛑 Parando servicios existentes...${NC}"
docker-compose -f docker-compose-livekit-server.yml down 2>/dev/null || true
# Iniciar LiveKit Server
echo -e "${YELLOW}🚀 Iniciando LiveKit Server...${NC}"
docker-compose -f docker-compose-livekit-server.yml up -d
# Esperar inicio
echo -e "${YELLOW}⏳ Esperando que LiveKit inicie...${NC}"
sleep 15
# Verificar servicios
echo -e "${BLUE}🔍 Verificando servicios...${NC}"
if curl -s http://localhost:7880 > /dev/null 2>&1; then
echo -e "${GREEN}✅ LiveKit API funcionando${NC}"
else
echo -e "${RED}❌ LiveKit no responde${NC}"
fi
if docker exec livekit-redis redis-cli -a $REDIS_PASSWORD ping > /dev/null 2>&1; then
echo -e "${GREEN}✅ Redis funcionando${NC}"
else
echo -e "${RED}❌ Redis no responde${NC}"
fi
# Mostrar configuración final
echo -e "${GREEN}"
echo "============================================="
echo "🎉 LIVEKIT SELF-HOSTED CONFIGURADO"
echo "============================================="
echo "🌐 Host externo: $EXTERNAL_HOST"
echo "🔌 Puerto API: 7880"
echo "📡 Puertos UDP: 50000-50100"
echo ""
echo "📋 CONFIGURACIÓN PARA OPENVIDU MEET:"
echo " LIVEKIT_URL=ws://$EXTERNAL_HOST:7880"
echo " LIVEKIT_API_KEY=production-key"
echo " LIVEKIT_API_SECRET=$API_SECRET"
echo ""
echo "🔧 PASOS SIGUIENTES:"
echo "1. Configurar port forwarding en router:"
echo " - TCP 7880 → $LOCAL_IP:7880"
echo " - UDP 50000-50100 → $LOCAL_IP:50000-50100"
echo ""
if [[ $IP_OPTION == 2 ]]; then
echo "2. Configurar DuckDNS:"
echo " - Token en duckdns.org"
echo " - Script de actualización automática"
echo ""
fi
echo "3. Configurar OpenVidu Meet con variables generadas"
echo "4. (Opcional) Configurar SSL/HTTPS con Let's Encrypt"
echo "============================================="
echo -e "${NC}"
# Mostrar logs
read -p "¿Ver logs de LiveKit en tiempo real? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker-compose -f docker-compose-livekit-server.yml logs -f livekit-server
fi

125
deploy-local-with-udp.sh Executable file
View File

@ -0,0 +1,125 @@
#!/bin/bash
# Script completo para desplegar OpenVidu Meet con LiveKit local y UDP
set -e
echo "🚀 Desplegando OpenVidu Meet con LiveKit local (UDP)..."
# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Verificar Docker
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker no está instalado${NC}"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo -e "${RED}❌ Docker Compose no está instalado${NC}"
exit 1
fi
# Obtener IP local
LOCAL_IP=$(hostname -I | awk '{print $1}')
echo -e "${BLUE}🌐 IP Local detectada: $LOCAL_IP${NC}"
# Crear .env para local
echo -e "${YELLOW}📝 Creando configuración local...${NC}"
cat > .env.local << EOF
# Configuración LOCAL con LiveKit y UDP
ADMIN_PASSWORD=admin123
REDIS_PASSWORD=redispassword
# LiveKit Local
LIVEKIT_URL=ws://$LOCAL_IP:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret
# Redis Local
REDIS_HOST=$LOCAL_IP
REDIS_PORT=6379
EOF
# Actualizar IP en livekit-local.yaml
echo -e "${YELLOW}🔧 Configurando LiveKit para IP $LOCAL_IP...${NC}"
sed -i "s/external_ip: \".*\"/external_ip: \"$LOCAL_IP\"/" livekit-local.yaml
# Configurar firewall
echo -e "${YELLOW}🔥 Configurando firewall...${NC}"
./configure-udp-ports.sh
# Parar servicios existentes
echo -e "${YELLOW}🛑 Parando servicios existentes...${NC}"
docker-compose -f docker-compose-with-livekit.yml down 2>/dev/null || true
# Construir imágenes
echo -e "${YELLOW}🔨 Construyendo imágenes...${NC}"
docker-compose -f docker-compose-with-livekit.yml build
# Iniciar servicios
echo -e "${YELLOW}🚀 Iniciando servicios completos...${NC}"
docker-compose -f docker-compose-with-livekit.yml --env-file .env.local up -d
# Esperar a que los servicios estén listos
echo -e "${YELLOW}⏳ Esperando servicios...${NC}"
sleep 15
# Verificar servicios
echo -e "${BLUE}🔍 Verificando servicios...${NC}"
services=(
"redis:6379"
"livekit:7880"
"openvidu-meet:6080"
"nginx:80"
)
for service in "${services[@]}"; do
name=$(echo $service | cut -d: -f1)
port=$(echo $service | cut -d: -f2)
if curl -s http://localhost:$port > /dev/null 2>&1; then
echo -e "${GREEN}$name funcionando en puerto $port${NC}"
else
echo -e "${RED}$name no responde en puerto $port${NC}"
fi
done
# Verificar puertos UDP
echo -e "${BLUE}📊 Verificando puertos UDP...${NC}"
if ss -tulnp | grep -q ":50000-60000"; then
echo -e "${GREEN}✅ Puertos UDP 50000-60000 abiertos${NC}"
else
echo -e "${YELLOW}⚠️ No se detectan puertos UDP - verificar manualmente${NC}"
fi
# Mostrar URLs finales
echo -e "${GREEN}"
echo "============================================="
echo "🎉 DESPLIEGUE COMPLETADO"
echo "============================================="
echo "📱 OpenVidu Meet: http://$LOCAL_IP"
echo "👨‍💼 Admin Panel: http://$LOCAL_IP/admin"
echo "🔧 LiveKit API: http://$LOCAL_IP:7880"
echo "📊 Redis: $LOCAL_IP:6379"
echo ""
echo "🔐 Credenciales Admin:"
echo " Usuario: admin"
echo " Password: admin123"
echo ""
echo "⚠️ PUERTOS NECESARIOS:"
echo " TCP: 80, 6080, 6379, 7880"
echo " UDP: 50000-60000 (WebRTC)"
echo "============================================="
echo -e "${NC}"
# Mostrar logs en tiempo real (opcional)
read -p "¿Ver logs en tiempo real? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker-compose -f docker-compose-with-livekit.yml --env-file .env.local logs -f
fi

View File

@ -0,0 +1,94 @@
version: '3.8'
services:
# OpenVidu Meet Backend
openvidu-meet:
build: .
container_name: openvidu-meet
restart: unless-stopped
environment:
NODE_ENV: production
MEET_LOG_LEVEL: info
MEET_BLOB_STORAGE_MODE: memory
PORT: 6080
# Admin user
MEET_INITIAL_ADMIN_USER: admin
MEET_INITIAL_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
# CORS y proxy
SERVER_CORS_ORIGIN: "*"
TRUST_PROXY: "true"
# LiveKit LOCAL con UDP
LIVEKIT_URL: ${LIVEKIT_URL:-ws://192.168.1.19:7880}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-secretsecretsecretsecretsecretsecret}
ports:
- "6080:6080"
volumes:
- ./logs:/app/logs
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- openvidu-network
# LiveKit Server LOCAL con puertos UDP
livekit:
image: livekit/livekit-server:latest
container_name: openvidu-livekit
restart: unless-stopped
ports:
# Puerto API/WebSocket
- "7880:7880"
# Rango UDP para WebRTC (IMPORTANTE!)
- "50000-60000:50000-60000/udp"
volumes:
- ./livekit.yaml:/livekit.yaml:ro
command: --config /livekit.yaml
networks:
- openvidu-network
depends_on:
- redis
# Redis para LiveKit
redis:
image: redis:7-alpine
container_name: openvidu-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --requirepass ${REDIS_PASSWORD:-redispassword}
volumes:
- redis_data:/data
networks:
- openvidu-network
# Nginx Proxy
nginx-proxy:
image: nginx:alpine
container_name: openvidu-nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- nginx-cache:/var/cache/nginx
depends_on:
- openvidu-meet
- livekit
networks:
- openvidu-network
volumes:
nginx-cache:
redis_data:
networks:
openvidu-network:
driver: bridge

70
docker-compose.yml Normal file
View File

@ -0,0 +1,70 @@
version: '3.8'
services:
# OpenVidu Meet Backend
openvidu-meet:
build: .
container_name: openvidu-meet
restart: unless-stopped
environment:
# Configuración básica
NODE_ENV: production
MEET_LOG_LEVEL: info
MEET_BLOB_STORAGE_MODE: memory
PORT: 6080
# Admin user (cambiar en producción)
MEET_INITIAL_ADMIN_USER: admin
MEET_INITIAL_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123}
# CORS para proxy
SERVER_CORS_ORIGIN: "*"
# Configuración para proxy
TRUST_PROXY: "true"
# LiveKit (ajustar según tu setup)
LIVEKIT_URL: ${LIVEKIT_URL:-ws://localhost:7880}
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:-devkey}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:-your-secret-key-32-chars-long}
# Redis (opcional - si no se proporciona, usa memoria)
MEET_REDIS_HOST: ${REDIS_HOST:-}
MEET_REDIS_PORT: ${REDIS_PORT:-6379}
MEET_REDIS_PASSWORD: ${REDIS_PASSWORD:-}
ports:
- "6080:6080"
volumes:
# Logs persistentes
- ./logs:/app/logs
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- openvidu-network
# Nginx Proxy - Solo puerto 80 para EasyPanel
nginx-proxy:
image: nginx:alpine
container_name: openvidu-nginx
restart: unless-stopped
ports:
- "80:80" # Solo HTTP - EasyPanel maneja SSL con Traefik
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- nginx-cache:/var/cache/nginx
depends_on:
- openvidu-meet
networks:
- openvidu-network
volumes:
nginx-cache:
networks:
openvidu-network:
driver: bridge

178
docs/javascript-snippets.md Normal file
View File

@ -0,0 +1,178 @@
# Javascript snippets
- `np` - nextPage
- `npssp` - nextPageServerSideProps
- `npsp` - nextPageStaticProps
- `npspth` - nextPageStaticPaths
- `nssp` - nextServerSideProps
- `nsp` - nextStaticProps
- `nspth` - nextStaticPaths
- `nip` - nextInitialProps
- `nimg` - nextImage
- `napp` - nextApp
- `ndoc` - nextDocument
- `napi` - nextApi
- `nmid` - nextMiddleware
## `np` - nextPage
```javascript
const FileName = ({}) => {
return <div></div>
}
export default FileName
```
## `npssp` - nextPageServerSideProps
```javascript
const FileName = ({}) => {
return <div></div>
}
export const getServerSideProps = async (ctx) => {
return {
props: {}
}
}
export default FileName
```
## `npsp` - nextPageStaticProps
```javascript
const FileName = ({}) => {
return <div></div>
}
export const getStaticProps = async (ctx) => {
return {
props: {},
}
}
export default FileName
```
## `npspth` - nextPageStaticPaths
```javascript
const FileName = ({}) => {
return <div></div>
}
export const getStaticPaths = async () => {
return {
paths: [],
fallback: false,
}
}
export default FileName
```
## `nssp` - nextServerSideProps
```javascript
export const getServerSideProps = async (ctx) => {
return {
props: {}
}
}
```
## `nsp` - nextStaticProps
```javascript
export const getStaticProps = async (ctx) => {
return {
props: {},
}
}
```
## `nspth` - nextStaticPaths
```javascript
export const getStaticPaths = async () => {
return {
paths: [],
fallback: false,
}
}
```
## `nip` - nextInitialProps
```javascript
FileName.getInitialProps = async (ctx) => {
return {
}
}
```
## `nimg` - nextImage
```javascript
<Image src="" alt="" />
```
## `napp` - nextApp
```javascript
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
```
## `ndoc` - nextDocument
```javascript
import Document, { Html, Head, Main, NextScript } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument
```
## `napi` - nextApi
```javascript
export default async function handler(req, res) {
}
```
## `nmid` - nextMiddleware
```javascript
import { NextResponse } from 'next/server'
export async function middleware(request) {
}
export const config = {
matcher: '/about/:path*',
}
```

202
docs/typescript-snippets.md Normal file
View File

@ -0,0 +1,202 @@
# Typescript snippets
- `np` - nextPage
- `npssp` - nextPageServerSideProps
- `npsp` - nextPageStaticProps
- `npspth` - nextPageStaticPaths
- `nssp` - nextServerSideProps
- `nsp` - nextStaticProps
- `nspth` - nextStaticPaths
- `nip` - nextInitialProps
- `nimg` - nextImage
- `napp` - nextApp
- `ndoc` - nextDocument
- `napi` - nextApi
- `nmid` - nextMiddleware
## `np` - nextPage
```typescript
import { NextPage } from 'next'
interface Props {}
const FileName: NextPage<Props> = ({}) => {
return <div></div>
}
export default FileName
```
## `npssp` - nextPageServerSideProps
```typescript
import { NextPage, GetServerSideProps } from 'next'
interface Props {}
const FileName: NextPage<Props> = ({}) => {
return <div></div>
}
export const getServerSideProps: GetServerSideProps = async (ctx) => {
return {
props: {}
}
}
export default FileName
```
## `npsp` - nextPageStaticProps
```typescript
import { NextPage, GetStaticProps } from 'next'
interface Props {}
const FileName: NextPage<Props> = ({}) => {
return <div></div>
}
export const getStaticProps: GetStaticProps = async (ctx) => {
return {
props: {},
}
}
export default FileName
```
## `npspth` - nextPageStaticPaths
```typescript
import { NextPage, GetStaticPaths } from 'next'
interface Props {}
const FileName: NextPage<Props> = ({}) => {
return <div></div>
}
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: false,
}
}
export default FileName
```
## `nssp` - nextServerSideProps
```typescript
export const getServerSideProps: GetServerSideProps = async (ctx) => {
return {
props: {}
}
}
```
## `nsp` - nextStaticProps
```typescript
export const getStaticProps: GetStaticProps = async (ctx) => {
return {
props: {},
}
}
```
## `nspth` - nextStaticPaths
```typescript
export const getStaticPaths: GetStaticPaths = async () => {
return {
paths: [],
fallback: false,
}
}
```
## `nip` - nextInitialProps
```typescript
FileName.getInitialProps = async (ctx) => {
return {
}
}
```
## `nimg` - nextImage
```typescript
<Image src="" alt="" />
```
## `napp` - nextApp
```typescript
import type { AppProps } from 'next/app'
export default function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
```
## `ndoc` - nextDocument
```typescript
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps }
}
render() {
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument
```
## `napi` - nextApi
```typescript
import type { NextApiRequest, NextApiResponse } from 'next'
interface Data {}
export default async function handler(req: NextApiRequest, res: NextApiResponse<Data>) {
}
```
## `nmid` - nextMiddleware
```typescript
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
}
export const config = {
matcher: '/about/:path*',
}
```

68
fix-admin-login.sh Executable file
View File

@ -0,0 +1,68 @@
#!/bin/bash
set -e
echo "🔧 SOLUCIONANDO PROBLEMA DE LOGIN ADMIN"
echo "======================================"
echo "1. Parando backend actual..."
pkill -f "node.*dist/src/server.js" 2>/dev/null || true
sleep 3
echo "2. Configurando backend con storage en memoria..."
cd /home/xesar/Documentos/openvidu-meet/meet-ce/backend
# Crear backup del log anterior
[ -f /tmp/ovm-logs/backend.log ] && mv /tmp/ovm-logs/backend.log /tmp/ovm-logs/backend.log.backup
echo "3. Arrancando backend con configuración correcta..."
nohup env \
NODE_ENV=development \
MEET_LOG_LEVEL=debug \
MEET_BLOB_STORAGE_MODE=memory \
MEET_INITIAL_ADMIN_USER=admin \
MEET_INITIAL_ADMIN_PASSWORD=admin \
LIVEKIT_URL=ws://192.168.1.19:7880 \
LIVEKIT_URL_PRIVATE=ws://192.168.1.19:7880 \
LIVEKIT_API_KEY=devkey \
LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret \
MEET_REDIS_HOST=192.168.1.19 \
MEET_REDIS_PORT=6379 \
MEET_REDIS_PASSWORD=redispassword \
node dist/src/server.js > /tmp/ovm-logs/backend.log 2>&1 &
BACKEND_PID=$!
echo "✅ Backend iniciado con PID: $BACKEND_PID"
echo "4. Esperando arranque (10s)..."
sleep 10
echo "5. Verificando estado:"
if ps -p $BACKEND_PID >/dev/null 2>&1; then
echo "✅ Proceso backend activo"
else
echo "❌ Proceso backend inactivo"
echo "Logs de error:"
tail -n 10 /tmp/ovm-logs/backend.log
exit 1
fi
if ss -ltn | grep -q :6080; then
echo "✅ Puerto 6080 activo"
else
echo "❌ Puerto 6080 inactivo"
fi
echo "6. Verificando logs de admin:"
grep -i "admin\|storage.*mode\|memory" /tmp/ovm-logs/backend.log | tail -5
echo ""
echo "🎉 SOLUCION COMPLETADA"
echo "======================"
echo "✅ Backend corriendo con storage en memoria"
echo "✅ Usuario admin configurado: admin/admin"
echo "🌐 Accede a: http://192.168.1.19:6080"
echo "📄 Logs en: /tmp/ovm-logs/backend.log"
echo ""
echo "👤 CREDENCIALES DE LOGIN:"
echo " Usuario: admin"
echo " Contraseña: admin"

46
livekit-local.yaml Normal file
View File

@ -0,0 +1,46 @@
# LiveKit Server Configuration for LOCAL deployment with UDP
# Para usar con Docker Compose completo incluyendo LiveKit
port: 7880
bind_addresses: [""]
# API Keys (mismo secret que en backend)
keys:
devkey: secretsecretsecretsecretsecretsecret
# Redis para coordinación
redis:
address: redis:6379
password: redispassword
db: 0
# Configuración RTC con UDP para red local
rtc:
# Rango de puertos UDP (DEBE coincidir con docker-compose)
port_range_start: 50000
port_range_end: 60000
# IP externa para acceso desde otros dispositivos
# Cambiar por tu IP local real
use_external_ip: true
external_ip: "192.168.1.19"
# Configuración ICE/STUN
ice_servers:
- urls: ["stun:stun.l.google.com:19302"]
# Configuración de rooms
room:
auto_create: true
max_participants: 0
empty_timeout: 300
# Egress para grabaciones (requiere Redis)
# Habilitado automáticamente con Redis
# Logging
log_level: info
log_format: json
# Configuración de desarrollo
development: true

41
livekit.yaml Normal file
View File

@ -0,0 +1,41 @@
# LiveKit Server Configuration for Development
# https://docs.livekit.io/deploy/configuration/
port: 7880
# Admin/API port (HTTP for rooms/egress APIs)
# The default admin port is 7880 for WebSocket and HTTP admin API
# Some builds use separate ports; adjust if needed
# API Keys for authentication (secret must be 32+ characters)
keys:
devkey: secretsecretsecretsecretsecretsecret
# Redis configuration (required for egress, ingress, and multi-node deployments)
redis:
address: 192.168.1.19:6379
password: redispassword
db: 0
# Enable egress service (recording/streaming)
# Egress requires Redis to coordinate recording jobs
# If you see "egress not connected (redis required)", ensure Redis config is correct above
# Development mode settings
log_level: debug
# RTC configuration
rtc:
# Use ephemeral ports for UDP
port_range_start: 50000
port_range_end: 60000
# Allow connection from network (not just localhost)
use_external_ip: true
# Room settings
room:
# Auto-create rooms when participants join
auto_create: true
# Max participants per room (0 = unlimited)
max_participants: 0
# Empty room timeout (in seconds, 0 = no timeout)
empty_timeout: 300

8
meet-ce/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"jest.jestCommandLine": "node --experimental-vm-modules ../../node_modules/.bin/jest",
"jest.rootPath": "backend",
"jest.nodeEnv": {
"NODE_OPTIONS": "--experimental-vm-modules"
},
"jest.runMode": "on-demand"
}

View File

@ -1,4 +1,39 @@
USE_HTTPS=false
USE_HTTPS=true
MEET_LOG_LEVEL=debug
SERVER_CORS_ORIGIN=*
MEET_INITIAL_API_KEY=meet-api-key
# Admin user configuration (initial admin user created on first startup)
MEET_INITIAL_ADMIN_USER=admin
MEET_INITIAL_ADMIN_PASSWORD=admin
# Redis configuration (used by the backend). Defaults in code point to localhost:6379.
# If you don't have a Redis server running locally, you can start one with Docker:
# docker run --name openvidu-redis -p 6379:6379 -d redis:7
# Or with podman:
# podman run --name openvidu-redis -p 6379:6379 -d docker.io/library/redis:7
# Environment variables read by the server (optional - only needed if you want to change defaults):
MEET_REDIS_HOST=192.168.1.19
MEET_REDIS_PORT=6379
MEET_REDIS_PASSWORD=redispassword
MEET_REDIS_DB=0
# If using Redis Sentinel, set the host list as comma separated host:port pairs and the sentinel password:
# MEET_REDIS_SENTINEL_HOST_LIST=sentinel1:26379,sentinel2:26379
# MEET_REDIS_SENTINEL_PASSWORD=your-sentinel-password
# LiveKit URL — use the websocket URL that corresponds to the admin HTTP port.
# The livekit-server process here is listening on 7880 (client) and 7881 (admin).
# Point LIVEKIT_URL/LIVEKIT_URL_PRIVATE to the admin-enabled port so server-side
# clients (egress/room service) use the correct HTTP admin endpoint.
LIVEKIT_URL=ws://nextream.sytes.net:7880
LIVEKIT_URL_PRIVATE=ws://nextream.sytes.net:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret
# MinIO / S3 configuration for local development (temporarily using memory)
MEET_BLOB_STORAGE_MODE=memory
# MEET_S3_SERVICE_ENDPOINT=http://192.168.1.19:9000
# MEET_S3_ACCESS_KEY=minioadmin
# MEET_S3_SECRET_KEY=minioadmin
# MEET_S3_BUCKET=openvidu-appdata
# MEET_S3_WITH_PATH_STYLE_ACCESS=true

View File

@ -2,3 +2,5 @@ USE_HTTPS=false
MEET_LOG_LEVEL=verbose
SERVER_CORS_ORIGIN=*
MEET_INITIAL_API_KEY=meet-api-key
MEET_INITIAL_WEBHOOK_ENABLED=true
MEET_INITIAL_WEBHOOK_URL=http://localhost:5080/webhook

View File

@ -1,62 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest Test",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--config",
"jest.config.mjs",
"--runInBand",
"--no-cache",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/.bin/jest.cmd"
},
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder}"
},
{
"name": "Debug Current Jest Test",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [
"--config",
"jest.config.mjs",
"--runInBand",
"--testNamePattern",
"${input:testName}",
"${relativeFile}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder}"
}
],
"inputs": [
{
"id": "testName",
"description": "Test name pattern (optional)",
"default": "",
"type": "promptString"
}
]
}

View File

@ -1,5 +0,0 @@
{
"jest.jestCommandLine": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.config.mjs",
"jest.rootPath": "./",
"jest.runMode": "on-demand"
}

View File

@ -12,7 +12,7 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^@openvidu-meet/typings$': '<rootDir>/../typings/src/index.ts',
'^(\\.{1,2}/.*)\\.js$': '$1' // Permite importar .js que resuelven a .ts
'^(\\.{1,2}/.*)\\.js$': '$1' // Allow importing js files and resolving to ts files
},
transform: {
'^.+\\.tsx?$': ['ts-jest', {

View File

@ -1,7 +1,7 @@
# ====================================================
# Stage 1: builder
# ====================================================
FROM node:22.19.0 AS builder
FROM node:22.21.0 AS builder
# Define pnpm version as build argument with default value
ARG PNPM_VERSION=10.18.3
@ -48,14 +48,6 @@ COPY --chown=node:node meet-ce/frontend/package.json ./meet-ce/frontend/
COPY --chown=node:node meet-ce/frontend/webcomponent/package.json ./meet-ce/frontend/webcomponent/
COPY --chown=node:node meet-ce/frontend/projects/shared-meet-components/package.json ./meet-ce/frontend/projects/shared-meet-components/
COPY --chown=node:node meet-ce/backend/package.json ./meet-ce/backend/
# Install dependencies
# Note: openvidu-components-angular will be installed from:
# 1. Local tarball (if exists in meet-ce/frontend/openvidu-components-angular*.tgz)
# 2. npm registry (if specified in package.json)
# The tarball should be placed in meet-ce/frontend/ by CI before docker build
RUN pnpm install --no-frozen-lockfile
# Copy the source code for all packages
COPY --chown=node:node meet-ce/typings/ ./meet-ce/typings/
COPY --chown=node:node meet-ce/frontend/ ./meet-ce/frontend/
@ -67,7 +59,7 @@ COPY --chown=node:node meet.sh .
ARG BASE_HREF=/
# Build OpenVidu Meet project
RUN pnpm install --no-frozen-lockfile && \
RUN pnpm install --frozen-lockfile && \
./meet.sh build --skip-install --base-href=${BASE_HREF}
# Clean up development dependencies and unnecessary files
@ -83,7 +75,7 @@ RUN rm -rf node_modules \
# ====================================================
# Stage 2: production
# ====================================================
FROM node:22.19.0-alpine3.21 AS production
FROM node:22.21.0-alpine3.21 AS production
# Define pnpm version as build argument with default value
ARG PNPM_VERSION=10.18.3

View File

@ -1,8 +0,0 @@
{
"prettier.jsxSingleQuote": true,
"prettier.singleQuote": true,
"javascript.preferences.quoteStyle": "single",
"typescript.preferences.quoteStyle": "single",
"editor.insertSpaces": false,
"prettier.useTabs": true
}

View File

@ -100,7 +100,9 @@
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"buildTarget": "openvidu-meet:build",
"proxyConfig": "src/proxy.conf.json"
"proxyConfig": "src/proxy.conf.json",
"host": "0.0.0.0",
"port": 4200
},
"configurations": {
"development": {

View File

@ -34,7 +34,7 @@
"core-js": "3.45.1",
"jwt-decode": "4.0.0",
"livekit-client": "2.15.11",
"openvidu-components-angular": "workspace:*",
"openvidu-components-angular": "3.4.0",
"rxjs": "7.8.2",
"tslib": "2.8.1",
"unique-names-generator": "4.7.1",

View File

@ -5,7 +5,7 @@
"module": "dist/fesm2022/openvidu-meet-shared-components.mjs",
"typings": "dist/index.d.ts",
"peerDependencies": {
"openvidu-components-angular": "workspace:*"
"openvidu-components-angular": "3.4.0"
},
"dependencies": {
"tslib": "^2.3.0"

View File

@ -21,19 +21,25 @@
</div>
}
</mat-dialog-content>
<mat-dialog-actions class="dialog-action">
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
{{ data.cancelText ?? 'Cancel' }}
</button>
<button
mat-flat-button
mat-dialog-close
cdkFocusInitial
(click)="close('confirm')"
class="confirm-button"
[class.force]="force"
>
{{ data.confirmText ?? 'Confirm' }}
</button>
</mat-dialog-actions>
@if (shouldShowActions()) {
<mat-dialog-actions class="dialog-action">
@if (shouldShowCancelButton()) {
<button mat-button mat-dialog-close (click)="close('cancel')" class="cancel-button">
{{ data.cancelText ?? 'Cancel' }}
</button>
}
@if (shouldShowConfirmButton()) {
<button
mat-flat-button
mat-dialog-close
cdkFocusInitial
(click)="close('confirm')"
class="confirm-button"
[class.force]="force"
>
{{ data.confirmText ?? 'Confirm' }}
</button>
}
</mat-dialog-actions>
}
</div>

View File

@ -46,4 +46,16 @@ export class DialogComponent {
this.data.cancelCallback();
}
}
shouldShowActions(): boolean {
return this.data.showActions !== false;
}
shouldShowConfirmButton(): boolean {
return this.data.showConfirmButton !== false;
}
shouldShowCancelButton(): boolean {
return this.data.showCancelButton !== false;
}
}

View File

@ -1,6 +1,7 @@
export * from './console-nav/console-nav.component';
export * from './dialogs/basic-dialog/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 './pro-feature-badge/pro-feature-badge.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 './share-meeting-link/share-meeting-link.component';
export * from './dialogs/basic-dialog/dialog.component';
export * from './dialogs/share-recording-dialog/share-recording-dialog.component';
export * from './dialogs/delete-room-dialog/delete-room-dialog.component';
// Meeting modular components
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
export * from './meeting-participant-panel/meeting-participant-panel.component';
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
export * from './meeting-share-link-overlay/meeting-share-link-overlay.component';
export * from './meeting-lobby/meeting-lobby.component';
// Meeting components
export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component';
export * from './meeting-participant-panel/meeting-participant-panel.component';
export * from './meeting-share-link-panel/meeting-share-link-panel.component';
export * from './meeting-share-link-overlay/meeting-share-link-overlay.component';
export * from './meeting-lobby/meeting-lobby.component';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
@if (showShareLink) {
<div class="share-meeting-link-container">
<ov-share-meeting-link [meetingUrl]="meetingUrl" (copyClicked)="onCopyClicked()"></ov-share-meeting-link>
</div>
}

View File

@ -0,0 +1,3 @@
.share-meeting-link-container {
padding: 10px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
/**
* Index file for customization exports
*/
export * from './components/meeting-components-plugins.token';
export * from './handlers/meeting-action-handler';

View File

@ -4,3 +4,4 @@ export * from './navigation.model';
export * from './notification.model';
export * from './sidenav.model';
export * from './wizard.model';
export * from './lobby.model';

View File

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

View File

@ -13,6 +13,10 @@ export interface DialogOptions {
forceCheckboxText?: string;
forceCheckboxDescription?: string;
forceConfirmCallback?: () => void;
// Action buttons visibility
showConfirmButton?: boolean;
showCancelButton?: boolean;
showActions?: boolean;
}
export interface DeleteRoomDialogOptions {

View File

@ -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
[token]="participantToken"
[prejoin]="true"
@ -33,305 +48,62 @@
[showThemeSelector]="features().showThemeSelector"
[showDisconnectionDialog]="false"
(onRoomCreated)="onRoomCreated($event)"
(onParticipantConnected)="onParticipantConnected($event)"
(onParticipantLeft)="onParticipantLeft($event)"
(onRecordingStartRequested)="onRecordingStartRequested($event)"
(onRecordingStopRequested)="onRecordingStopRequested($event)"
(onParticipantConnected)="eventHandler.onParticipantConnected($event)"
(onParticipantLeft)="eventHandler.onParticipantLeft($event)"
(onRecordingStartRequested)="eventHandler.onRecordingStartRequested($event)"
(onRecordingStopRequested)="eventHandler.onRecordingStopRequested($event)"
(onViewRecordingsClicked)="onViewRecordingsClicked()"
>
<ng-container *ovToolbarAdditionalButtons>
<!-- Copy Link Button -->
@if (features().canModerateRoom) {
@if (isMobile) {
<button id="copy-speaker-link" mat-menu-item (click)="copySpeakerLink()" [disableRipple]="true">
<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>
<!-- Toolbar Additional Buttons Plugin -->
@if (plugins.toolbar?.additionalButtons) {
<ng-container *ovToolbarAdditionalButtons>
<ng-container
[ngComponentOutlet]="plugins.toolbar!.additionalButtons!"
[ngComponentOutletInputs]="toolbarAdditionalButtonsInputs()"
></ng-container>
</ng-container>
}
<!-- Toolbar Leave Button Plugin -->
@if (plugins.toolbar?.leaveButton) {
<ng-container *ovToolbarLeaveButton>
<ng-container
[ngComponentOutlet]="plugins.toolbar!.leaveButton!"
[ngComponentOutletInputs]="toolbarLeaveButtonInputs()"
></ng-container>
</ng-container>
}
<!-- Participant Panel After Local Participant Plugin -->
@if (plugins.participantPanel?.afterLocalParticipant) {
<ng-container *ovParticipantPanelAfterLocalParticipant>
<ng-container
[ngComponentOutlet]="plugins.participantPanel!.afterLocalParticipant!"
[ngComponentOutletInputs]="participantPanelAfterLocalInputs()"
></ng-container>
</ng-container>
}
<!-- Layout Additional Elements Plugin -->
@if (plugins.layout?.additionalElements) {
<ng-container *ovLayoutAdditionalElements>
@if (onlyModeratorIsPresent) {
<ng-container
[ngComponentOutlet]="plugins.layout!.additionalElements!"
[ngComponentOutletInputs]="layoutAdditionalElementsInputs()"
></ng-container>
}
}
</ng-container>
</ng-container>
}
<ng-container *ovToolbarLeaveButton>
@if (features().canModerateRoom) {
<!-- Leave Button -->
<button
id="leave-btn"
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 *ovParticipantPanelAfterLocalParticipant>
@if (features().canModerateRoom) {
<div class="share-meeting-link-container">
<ov-share-meeting-link
[meetingUrl]="hostname + '/room/' + roomId"
(copyClicked)="copySpeakerLink()"
></ov-share-meeting-link>
</div>
}
</ng-container>
<ng-container *ovLayoutAdditionalElements>
@if (features().canModerateRoom && remoteParticipants.length === 0) {
<div class="main-share-meeting-link-container fade-in-delayed-more OV_big">
<ov-share-meeting-link
class="main-share-meeting-link"
[title]="'Start collaborating'"
[subtitle]="'Share this link to bring others into the meeting'"
[titleSize]="'xl'"
[titleWeight]="'bold'"
[meetingUrl]="hostname + '/room/' + roomId"
(copyClicked)="copySpeakerLink()"
></ov-share-meeting-link>
</div>
}
</ng-container>
<ng-container *ovParticipantPanelItem="let participant">
<!-- If Meet participant is moderator -->
@if (features().canModerateRoom) {
<div class="participant-item-container">
<!-- Local participant -->
@if (participant.isLocal) {
<ov-participant-panel-item [participant]="participant">
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
shield_person
</mat-icon>
</span>
</ng-container>
</ov-participant-panel-item>
} @else {
<!-- Remote participant -->
<ov-participant-panel-item [participant]="participant">
@if (participant.isModerator()) {
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
shield_person
</mat-icon>
</span>
</ng-container>
}
<div *ovParticipantPanelItemElements>
<!-- Button to make moderator if not -->
@if (localParticipant!.isOriginalModerator()) {
@if (participant.isModerator() && !participant.isOriginalModerator()) {
<button
mat-icon-button
(click)="unmakeModerator(participant)"
matTooltip="Unmake participant moderator"
class="remove-moderator-btn"
>
<mat-icon class="material-symbols-outlined">remove_moderator</mat-icon>
</button>
} @else {
@if (!participant.isModerator()) {
<button
mat-icon-button
(click)="makeModerator(participant)"
matTooltip="Make participant moderator"
class="make-moderator-btn"
>
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
</button>
}
}
} @else {
@if (!participant.isModerator()) {
<button
mat-icon-button
(click)="makeModerator(participant)"
matTooltip="Make participant moderator"
class="make-moderator-btn"
>
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
</button>
}
}
<!-- Button to kick participant -->
@if (!participant.isOriginalModerator()) {
<button
mat-icon-button
(click)="kickParticipant(participant)"
matTooltip="Kick participant"
class="force-disconnect-btn"
>
<mat-icon>call_end</mat-icon>
</button>
}
</div>
</ov-participant-panel-item>
}
</div>
} @else {
<!-- If I can't moderate the room -->
<div class="participant-item-container">
<ov-participant-panel-item [participant]="participant">
@if (participant.isModerator()) {
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
shield_person
</mat-icon>
</span>
</ng-container>
}
</ov-participant-panel-item>
</div>
}
</ng-container>
<!-- Participant Panel Item Plugin -->
@if (plugins.participantPanel?.item) {
<ng-container *ovParticipantPanelItem="let participant">
<ng-container
[ngComponentOutlet]="plugins.participantPanel!.item!"
[ngComponentOutletInputs]="participantPanelItemInputsMap().get(participant.identity)"
></ng-container>
</ng-container>
}
</ov-videoconference>
} @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>
}

View File

@ -1,327 +1,9 @@
@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);
.prejoin-loading-container,
.prejoin-error-container {
display: flex;
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);
}
}
}
.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;
}
}
height: 100%;
}

View File

@ -1,65 +1,36 @@
import { Clipboard } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { Component, effect, OnInit, Signal } from '@angular/core';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule, MatIconButton } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatRippleModule } from '@angular/material/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { ShareMeetingLinkComponent } from '../../components';
import { CustomParticipantModel, ErrorReason } from '../../models';
import { CommonModule, NgComponentOutlet } from '@angular/common';
import { Component, computed, effect, inject, OnInit, Signal, signal } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CustomParticipantModel } from '../../models';
import { MeetingComponentsPlugins, MEETING_COMPONENTS_TOKEN, MEETING_ACTION_HANDLER_TOKEN } from '../../customization';
import {
AppDataService,
ApplicationFeatures,
AuthService,
FeatureConfigurationService,
GlobalConfigService,
MeetingService,
NavigationService,
NotificationService,
ParticipantService,
RecordingService,
RoomService,
SessionStorageService,
TokenStorageService,
WebComponentManagerService
WebComponentManagerService,
MeetingEventHandlerService
} from '../../services';
import {
LeftEventReason,
MeetRoom,
MeetRoomStatus,
ParticipantRole,
WebComponentEvent,
WebComponentOutboundEventMessage,
MeetParticipantRoleUpdatedPayload,
MeetRoomConfigUpdatedPayload,
MeetSignalType
} from '@openvidu-meet/typings';
import { MeetRoom, ParticipantRole } from '@openvidu-meet/typings';
import {
ParticipantService as ComponentParticipantService,
DataPacket_Kind,
OpenViduComponentsUiModule,
OpenViduService,
OpenViduThemeMode,
OpenViduThemeService,
ParticipantLeftEvent,
ParticipantLeftReason,
ParticipantModel,
RecordingStartRequestedEvent,
RecordingStopRequestedEvent,
RemoteParticipant,
Room,
RoomEvent,
Track,
ViewportService
} from 'openvidu-components-angular';
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({
selector: 'ov-meeting',
@ -67,71 +38,56 @@ import { combineLatest, Subject, takeUntil } from 'rxjs';
styleUrls: ['./meeting.component.scss'],
imports: [
OpenViduComponentsUiModule,
// ApiDirectiveModule,
CommonModule,
MatFormFieldModule,
MatInputModule,
FormsModule,
ReactiveFormsModule,
MatCardModule,
MatButtonModule,
NgComponentOutlet,
MatIconModule,
MatIconButton,
MatMenuModule,
MatDividerModule,
MatTooltipModule,
MatRippleModule,
ShareMeetingLinkComponent
]
MatProgressSpinnerModule
],
providers: [MeetingLobbyService, MeetingPluginManagerService, MeetingEventHandlerService]
})
export class MeetingComponent implements OnInit {
participantForm = new FormGroup({
name: new FormControl('', [Validators.required])
});
lobbyState?: LobbyState;
protected localParticipant = signal<CustomParticipantModel | undefined>(undefined);
hasRecordings = false;
showRecordingCard = false;
roomClosed = false;
// Reactive signal for remote participants to trigger computed updates
protected remoteParticipants = signal<CustomParticipantModel[]>([]);
showBackButton = true;
backButtonText = 'Back';
// Signal to track participant updates (role changes, etc.) that don't change array references
protected participantsVersion = signal<number>(0);
room?: MeetRoom;
roomId = '';
roomSecret = '';
participantName = '';
participantToken = '';
localParticipant?: CustomParticipantModel;
remoteParticipants: CustomParticipantModel[] = [];
showMeeting = false;
showPrejoin = true;
prejoinReady = false;
features: Signal<ApplicationFeatures>;
meetingEndedByMe = false;
private destroy$ = new Subject<void>();
// Injected plugins
plugins: MeetingComponentsPlugins;
constructor(
protected route: ActivatedRoute,
protected roomService: RoomService,
protected meetingService: MeetingService,
protected participantService: ParticipantService,
protected recordingService: RecordingService,
protected featureConfService: FeatureConfigurationService,
protected authService: AuthService,
protected appDataService: AppDataService,
protected sessionStorageService: SessionStorageService,
protected wcManagerService: WebComponentManagerService,
protected openviduService: OpenViduService,
protected ovComponentsParticipantService: ComponentParticipantService,
protected navigationService: NavigationService,
protected notificationService: NotificationService,
protected clipboard: Clipboard,
protected viewportService: ViewportService,
protected ovThemeService: OpenViduThemeService,
protected configService: GlobalConfigService,
protected tokenStorageService: TokenStorageService
) {
protected meetingService = inject(MeetingService);
protected participantService = inject(ParticipantService);
protected featureConfService = inject(FeatureConfigurationService);
protected wcManagerService = inject(WebComponentManagerService);
protected openviduService = inject(OpenViduService);
protected ovComponentsParticipantService = inject(ComponentParticipantService);
protected viewportService = inject(ViewportService);
protected ovThemeService = inject(OpenViduThemeService);
protected configService = inject(GlobalConfigService);
protected clipboard = inject(Clipboard);
protected notificationService = inject(NotificationService);
protected lobbyService = inject(MeetingLobbyService);
protected pluginManager = inject(MeetingPluginManagerService);
// Public for direct template binding (uses arrow functions to preserve 'this' context)
public eventHandler = inject(MeetingEventHandlerService);
// Injected action handler (optional - falls back to default implementation)
protected actionHandler = inject(MEETING_ACTION_HANDLER_TOKEN, { optional: true });
protected destroy$ = new Subject<void>();
constructor() {
this.features = this.featureConfService.features;
this.plugins = inject(MEETING_COMPONENTS_TOKEN, { optional: true }) || {};
// Change theme variables when custom theme is enabled
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 {
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 {
@ -164,14 +237,18 @@ export class MeetingComponent implements OnInit {
}
async ngOnInit() {
this.roomId = this.roomService.getRoomId();
this.roomSecret = this.roomService.getRoomSecret();
this.room = await this.roomService.getRoom(this.roomId);
this.roomClosed = this.room.status === MeetRoomStatus.CLOSED;
await this.setBackButtonText();
await this.checkForRecordings();
await this.initializeParticipantName();
try {
this.lobbyState = await this.lobbyService.initialize();
this.prejoinReady = true;
} catch (error) {
console.error('Error initializing lobby state:', error);
this.notificationService.showDialog({
title: 'Error',
message: 'An error occurred while initializing the meeting lobby. Please try again later.',
showCancelButton: false,
confirmText: 'OK'
});
}
}
ngOnDestroy() {
@ -179,129 +256,14 @@ export class MeetingComponent implements OnInit {
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() {
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 {
await this.generateParticipantToken();
await this.addParticipantNameToUrl();
await this.roomService.loadRoomConfig(this.roomId);
await this.lobbyService.submitAccess();
// The meeting view must be shown before loading the appearance config,
// as it contains theme information that might be applied immediately
// when the meeting view is rendered
this.showMeeting = true;
this.showPrejoin = false;
await this.configService.loadRoomsAppearanceConfig();
combineLatest([
@ -310,8 +272,15 @@ export class MeetingComponent implements OnInit {
])
.pipe(takeUntil(this.destroy$))
.subscribe(([participants, local]) => {
this.remoteParticipants = participants as CustomParticipantModel[];
this.localParticipant = local as CustomParticipantModel;
this.remoteParticipants.set(participants 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();
});
@ -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) {
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': {
// 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,
secret,
participantName: this.participantName,
participantIdentity
});
this.localParticipant!.meetRole = newRole;
this.notificationService.showSnackbar(`You have been assigned the role of ${newRole}`);
} catch (error) {
console.error('Error refreshing participant token to update role:', error);
}
} else {
const participant = this.remoteParticipants.find((p) => p.identity === participantIdentity);
if (participant) {
participant.meetRole = newRole;
}
}
break;
}
}
this.eventHandler.setupRoomListeners(room, {
roomId: this.roomId,
roomSecret: this.roomSecret,
participantName: this.participantName,
localParticipant: () => this.localParticipant(),
remoteParticipants: () => this.remoteParticipants(),
onHasRecordingsChanged: (hasRecordings) => {
this.hasRecordings = hasRecordings;
},
onRoomSecretChanged: (secret) => {
this.roomSecret = secret;
},
onParticipantRoleUpdated: () => {
// Increment version to trigger reactivity in participant panel items
this.participantsVersion.update((v) => v + 1);
}
);
}
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() {
@ -528,96 +316,125 @@ export class MeetingComponent implements OnInit {
async endMeeting() {
if (!this.participantService.isModeratorParticipant()) return;
this.meetingEndedByMe = true;
this.eventHandler.setMeetingEndedByMe(true);
try {
await this.meetingService.endMeeting(this.roomId);
} catch (error) {
console.error('Error ending meeting:', error);
this.notificationService.showSnackbar('Failed to end meeting');
}
}
async kickParticipant(participant: CustomParticipantModel) {
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.kickParticipant(this.roomId, participant.identity);
} catch (error) {
console.error('Error kicking participant:', error);
this.notificationService.showSnackbar('Failed to kick participant');
}
}
/**
* Makes a participant as moderator.
* @param participant The participant to make as moderator.
*/
async makeModerator(participant: CustomParticipantModel) {
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.changeParticipantRole(
this.roomId,
participant.identity,
ParticipantRole.MODERATOR
);
} catch (error) {
console.error('Error making participant moderator:', error);
this.notificationService.showSnackbar('Failed to make participant moderator');
}
}
/**
* Unmakes a participant as moderator.
* @param participant The participant to unmake as moderator.
*/
async unmakeModerator(participant: CustomParticipantModel) {
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.changeParticipantRole(this.roomId, participant.identity, ParticipantRole.SPEAKER);
} catch (error) {
console.error('Error unmaking participant moderator:', error);
this.notificationService.showSnackbar('Failed to unmake participant moderator');
}
}
async copyModeratorLink() {
this.clipboard.copy(this.room!.moderatorUrl);
this.notificationService.showSnackbar('Moderator link copied to clipboard');
}
async copySpeakerLink() {
this.clipboard.copy(this.room!.speakerUrl);
this.notificationService.showSnackbar('Speaker link copied to clipboard');
}
async onRecordingStartRequested(event: RecordingStartRequestedEvent) {
try {
await this.recordingService.startRecording(event.roomName);
} catch (error: unknown) {
if ((error as any).status === 503) {
console.error(
`No egress service was able to register a request.
Check your CPU usage or if there's any Media Node with enough CPU.
Remember that by default, a recording uses 4 CPUs for each room.`
);
} else {
console.error(error);
}
}
}
async onRecordingStopRequested(event: RecordingStopRequestedEvent) {
try {
await this.recordingService.stopRecording(event.recordingId);
} catch (error) {
console.error(error);
}
}
async onViewRecordingsClicked() {
window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank');
}
/**
* Centralized logic for managing video pinning based on
* remote participants and local screen sharing state.
*/
protected updateVideoPinState(): void {
if (!this.localParticipant) return;
const isSharing = this.localParticipant()?.isScreenShareEnabled;
if (this.hasRemoteParticipants && isSharing) {
// Pin the local screen share to appear bigger
this.localParticipant()?.setVideoPinnedBySource(Track.Source.ScreenShare, true);
} else {
// Unpin everything if no remote participants or not sharing
this.localParticipant()?.setAllVideoPinned(false);
}
}
/**
* Event handler wrappers - delegates to actionHandler if provided, otherwise uses default implementation
*/
protected async handleKickParticipant(participant: CustomParticipantModel) {
if (this.actionHandler) {
await this.actionHandler.kickParticipant(participant);
} else {
// Default implementation
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.kickParticipant(this.roomId, participant.identity);
console.log('Participant kicked successfully');
} catch (error) {
console.error('Error kicking participant:', error);
}
}
}
protected async handleMakeModerator(participant: CustomParticipantModel) {
if (this.actionHandler) {
await this.actionHandler.makeModerator(participant);
} else {
// Default implementation
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.changeParticipantRole(
this.roomId,
participant.identity,
ParticipantRole.MODERATOR
);
console.log('Moderator assigned successfully');
} catch (error) {
console.error('Error assigning moderator:', error);
}
}
}
protected async handleUnmakeModerator(participant: CustomParticipantModel) {
if (this.actionHandler) {
await this.actionHandler.unmakeModerator(participant);
} else {
// Default implementation
if (!this.participantService.isModeratorParticipant()) return;
try {
await this.meetingService.changeParticipantRole(
this.roomId,
participant.identity,
ParticipantRole.SPEAKER
);
console.log('Moderator unassigned successfully');
} catch (error) {
console.error('Error unassigning moderator:', error);
}
}
}
// private async handleCopyModeratorLink() {
// if (this.actionHandler) {
// await this.actionHandler.copyModeratorLink();
// } else {
// // Default implementation
// try {
// this.clipboard.copy(this.room!.moderatorUrl);
// this.notificationService.showSnackbar('Moderator link copied to clipboard');
// console.log('Moderator link copied to clipboard');
// } catch (error) {
// console.error('Failed to copy moderator link:', error);
// }
// }
// }
protected async handleCopySpeakerLink() {
if (this.actionHandler) {
await this.actionHandler.copySpeakerLink();
} else {
// Default implementation
try {
const speakerLink = this.room!.speakerUrl;
this.clipboard.copy(speakerLink);
this.notificationService.showSnackbar('Speaker link copied to clipboard');
console.log('Speaker link copied to clipboard');
} catch (error) {
console.error('Failed to copy speaker link:', error);
}
}
}
}

View File

@ -4,7 +4,10 @@ export * from './auth.service';
export * from './global-config.service';
export * from './room.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 './recording.service';
export * from './webcomponent-manager.service';

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { HttpService, ParticipantService } from '../services';
import { HttpService, ParticipantService } from '..';
import { LoggerService } from 'openvidu-components-angular';
@Injectable({

View File

@ -9,3 +9,4 @@ export * from './lib/interceptors/index';
export * from './lib/guards/index';
export * from './lib/routes/base-routes';
export * from './lib/utils/index';
export * from './lib/customization/index';

View File

@ -9,7 +9,7 @@ import {
} from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter } from '@angular/router';
import { routes } from '@app/app.routes';
import { ceRoutes } from '@app/app.routes';
import { environment } from '@environment/environment';
import { CustomParticipantModel, httpInterceptor, ThemeService } from '@openvidu-meet/shared-components';
import { OpenViduComponentsConfig, OpenViduComponentsModule, ParticipantProperties } from 'openvidu-components-angular';
@ -30,7 +30,7 @@ export const appConfig: ApplicationConfig = {
}),
importProvidersFrom(OpenViduComponentsModule.forRoot(ovComponentsconfig)),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideRouter(ceRoutes),
provideAnimationsAsync(),
provideHttpClient(withInterceptors([httpInterceptor])),
{

View File

@ -1,4 +1,14 @@
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;

View File

@ -0,0 +1 @@
export * from './meeting-ce.providers';

View File

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

View File

@ -1,5 +1,8 @@
{
"jest.jestCommandLine": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.config.mjs",
"jest.rootPath": "./",
"jest.jestCommandLine": "node --experimental-vm-modules ../../../node_modules/.bin/jest --config jest.config.mjs",
"jest.rootPath": ".",
"jest.nodeEnv": {
"NODE_OPTIONS": "--experimental-vm-modules"
},
"jest.runMode": "on-demand"
}
}

View File

@ -6,11 +6,16 @@ const jestConfig = {
...createDefaultEsmPreset({
tsconfig: 'tsconfig.json'
}),
// Set the root directory to the webcomponent folder
rootDir: './',
resolver: 'ts-jest-resolver',
testEnvironment: 'jsdom',
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
moduleFileExtensions: ['js', 'ts', 'json', 'node'],
testPathIgnorePatterns: ['/node_modules/', '/dist/', '/tests/e2e/'],
transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }]
},
globals: {
'ts-jest': {
tsconfig: 'tsconfig.json'

View File

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

View File

@ -1 +1,2 @@
// Mock para imports de CSS en tests de Jest
module.exports = ''

View File

@ -8,6 +8,7 @@ import {
leaveRoom,
prepareForJoiningRoom
} from '../../helpers/function-helpers';
import { LeftEventReason } from '@openvidu-meet/typings';
let subscribedToAppErrors = false;
@ -49,49 +50,274 @@ test.describe('Web Component E2E Tests', () => {
});
test.describe('Event Handling', () => {
test('should successfully join as moderator and receive joined event', async ({ page }) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined');
const joinElements = await page.locator('.event-joined').all();
expect(joinElements.length).toBe(1);
test.describe('JOINED Event', () => {
test('should receive joined event when joining as moderator', async ({ page }) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
const joinElements = await page.locator('.event-joined').all();
expect(joinElements.length).toBe(1);
// Verify event payload contains required data
const eventText = await joinElements[0].textContent();
expect(eventText).toContain('roomId');
expect(eventText).toContain('participantIdentity');
expect(eventText).toContain(roomId);
});
test('should receive joined event when joining as speaker', async ({ page }) => {
await joinRoomAs('speaker', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
const joinElements = await page.locator('.event-joined').all();
expect(joinElements.length).toBe(1);
// Verify event payload contains required data
const eventText = await joinElements[0].textContent();
expect(eventText).toContain('roomId');
expect(eventText).toContain('participantIdentity');
expect(eventText).toContain(roomId);
});
test('should receive only one joined event per join action', async ({ page }) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
// Wait a bit to ensure no duplicate events
await page.waitForTimeout(1000);
const joinElements = await page.locator('.event-joined').all();
expect(joinElements.length).toBe(1);
});
});
test('should successfully join as speaker and receive joined event', async ({ page }) => {
await joinRoomAs('speaker', participantName, page);
await page.waitForSelector('.event-joined');
const joinElements = await page.locator('.event-joined').all();
expect(joinElements.length).toBe(1);
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 page.waitForSelector('.event-joined', { timeout: 10000 });
await page.click('#leave-room-btn');
await page.waitForSelector('.event-left', { timeout: 10000 });
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
// Verify event payload contains required data including reason
const eventText = await leftElements[0].textContent();
expect(eventText).toContain('roomId');
expect(eventText).toContain('participantIdentity');
expect(eventText).toContain('reason');
expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE);
});
test('should receive left event with voluntary_leave reason when using disconnect button', async ({
page
}) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
await leaveRoom(page, 'moderator');
await page.waitForSelector('.event-left', { timeout: 10000 });
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
// Verify event payload
const eventText = await leftElements[0].textContent();
expect(eventText).toContain('reason');
expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE);
});
test('should receive left event with meeting_ended reason when moderator ends meeting', async ({
page
}) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
await page.click('#end-meeting-btn');
await page.waitForSelector('.event-left', { timeout: 10000 });
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
// Verify event payload contains meeting_ended_by_self reason
const eventText = await leftElements[0].textContent();
expect(eventText).toContain('reason');
expect(eventText).toContain(LeftEventReason.MEETING_ENDED);
});
test('should receive left event when speaker leaves room', async ({ page }) => {
await joinRoomAs('speaker', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
await leaveRoom(page, 'speaker');
await page.waitForSelector('.event-left', { timeout: 10000 });
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
// Verify event payload
const eventText = await leftElements[0].textContent();
expect(eventText).toContain('roomId');
expect(eventText).toContain('participantIdentity');
expect(eventText).toContain('reason');
});
});
test('should successfully join to room and receive left event when using leave command', async ({ page }) => {
await joinRoomAs('moderator', participantName, page);
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');
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
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('should successfully join to room and receive left event when using disconnect button', async ({
page
}) => {
await joinRoomAs('moderator', participantName, page);
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 });
await leaveRoom(page, 'moderator');
await page.waitForSelector('.event-left');
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
// 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('should successfully join to room and receive left event when using end meeting command', async ({
page
}) => {
await joinRoomAs('moderator', participantName, page);
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 });
await page.click('#end-meeting-btn');
await page.waitForSelector('.event-left');
const meetingEndedElements = await page.locator('.event-left').all();
expect(meetingEndedElements.length).toBe(1);
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();
expect(leftElements.length).toBe(1);
});
test('should not emit duplicate events on rapid actions', async ({ page }) => {
await joinRoomAs('moderator', participantName, page);
await page.waitForSelector('.event-joined', { timeout: 10000 });
// Rapid clicking on leave button
await page.click('#leave-room-btn');
await page.click('#leave-room-btn').catch(() => {
/* Button might not be available */
});
await page.click('#leave-room-btn').catch(() => {
/* Button might not be available */
});
await page.waitForSelector('.event-left', { timeout: 10000 });
await page.waitForTimeout(1000); // Wait for any potential duplicate events
// Should only have one left event
const leftElements = await page.locator('.event-left').all();
expect(leftElements.length).toBe(1);
});
});
});
});

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

View File

@ -401,3 +401,148 @@ export const closeMoreOptionsMenu = async (page: Page) => {
await interactWithElementInIframe(page, 'body', { action: 'click' });
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;
};

View File

@ -1,4 +1,4 @@
import { describe, expect, it, jest } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { OpenViduMeet } from '../../src/components/OpenViduMeet';
import '../../src/index';

View File

@ -1,4 +1,4 @@
import { describe, expect, it, jest } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { CommandsManager } from '../../src/components/CommandsManager';
import { OpenViduMeet } from '../../src/components/OpenViduMeet';
import '../../src/index';

View File

@ -1,4 +1,4 @@
import { describe, expect, it, jest } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { EventsManager } from '../../src/components/EventsManager';
import { OpenViduMeet } from '../../src/components/OpenViduMeet';
import '../../src/index';

View File

@ -393,6 +393,12 @@ add_common_dev_commands() {
CMD_NAMES+=("shared-meet-components")
CMD_COLORS+=("bgYellow.dark")
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)
@ -473,6 +479,7 @@ add_browsersync_commands() {
const local = urls?.get('local') ?? 'undefined';
const external = urls?.get('external') ?? 'undefined';
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 LAN: ' + external));

124
nginx.conf Normal file
View File

@ -0,0 +1,124 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Configuración de logs
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Configuración básica
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Compresión
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# Upstream para OpenVidu Meet
upstream openvidu-backend {
server openvidu-meet:6080;
keepalive 32;
}
# Servidor principal - Solo HTTP puerto 80
# EasyPanel/Traefik maneja SSL y subdominios
server {
listen 80;
server_name _;
# Headers para proxy (EasyPanel/Traefik)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# API endpoints con rate limiting
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://openvidu-backend;
}
# Login endpoint con rate limiting estricto
location /api/auth/login {
limit_req zone=login burst=5 nodelay;
proxy_pass http://openvidu-backend;
}
# WebSocket para LiveKit
location /ws {
proxy_pass http://openvidu-backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# Archivos estáticos con cache
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://openvidu-backend;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Todas las demás rutas
location / {
proxy_pass http://openvidu-backend;
}
# Health check para EasyPanel
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Security headers básicos
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Ocultar versión de nginx
server_tokens off;
}
}

View File

@ -42,20 +42,146 @@
"**/node_modules": true,
"**/test-results": true,
},
"jest.disabledWorkspaceFolders": ["openvidu-meet", "typings", "frontend"],
"jest.disabledWorkspaceFolders": ["openvidu-meet (root)", "openvidu-components-angular", "shared-meet-components", "meet-testapp"],
"mochaExplorer.files": "tests/e2e/**/*.test.ts",
"mochaExplorer.require": ".mocharc.js",
"files.watcherExclude": {
"**/node_modules/**": true,
"**/dist/**": true,
},
// "mochaExplorer.files": "./frontend/tests/e2e/**/*.test.ts",
// "mochaExplorer.mochaPath": "./frontend/node_modules/mocha",
// "mochaExplorer.require": "ts-node/register",
// "mochaExplorer.configFile": "./frontend/.mocharc.js",
// "mochaExplorer.timeout": 30000,
// "mochaExplorer.env": {
// "NODE_ENV": "test"
// }
},
"launch": {
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest Test (CE Backend)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest",
"args": [
"--config",
"${workspaceFolder:openvidu-meet (CE)}/backend/jest.config.mjs",
"--runInBand",
"--no-cache",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest.cmd"
},
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder:openvidu-meet (CE)}/backend"
},
{
"name": "Debug Jest Test (PRO Backend)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest",
"args": [
"--config",
"${workspaceFolder:openvidu-meet (PRO)}/backend/jest.config.mjs",
"--runInBand",
"--no-cache",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest.cmd"
},
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder:openvidu-meet (PRO)}/backend"
},
{
"name": "Debug Jest Test (Webcomponent)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest",
"args": [
"--config",
"${workspaceFolder:meet-webcomponent}/jest.config.mjs",
"--runInBand",
"--no-cache",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"windows": {
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest.cmd"
},
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder:meet-webcomponent}"
},
{
"name": "Debug Current Jest Test (CE Backend)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest",
"args": [
"--config",
"${workspaceFolder:openvidu-meet (CE)}/backend/jest.config.mjs",
"--runInBand",
"--testNamePattern",
"${input:testName}",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder:openvidu-meet (CE)}/backend"
},
{
"name": "Debug Current Jest Test (PRO Backend)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder:openvidu-meet (root)}/node_modules/.bin/jest",
"args": [
"--config",
"${workspaceFolder:openvidu-meet (PRO)}/backend/jest.config.mjs",
"--runInBand",
"--testNamePattern",
"${input:testName}",
"${file}"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"runtimeArgs": [
"--experimental-vm-modules"
],
"env": {
"NODE_ENV": "test"
},
"cwd": "${workspaceFolder:openvidu-meet (PRO)}/backend"
}
],
"inputs": [
{
"id": "testName",
"description": "Test name pattern (optional, leave empty to run all tests in file)",
"default": "",
"type": "promptString"
}
]
},
}

View File

@ -39,6 +39,7 @@
"devDependencies": {
"browser-sync": "3.0.4",
"concurrently": "9.2.1",
"identity-obj-proxy": "3.0.0",
"tree-kill": "1.2.2",
"wait-on": "9.0.1"
}

621
pnpm-lock.yaml generated
View File

@ -14,6 +14,9 @@ importers:
concurrently:
specifier: 9.2.1
version: 9.2.1
identity-obj-proxy:
specifier: 3.0.0
version: 3.0.0
tree-kill:
specifier: 1.2.2
version: 1.2.2
@ -21,42 +24,6 @@ importers:
specifier: 9.0.1
version: 9.0.1
../openvidu/openvidu-components-angular/projects/openvidu-components-angular:
dependencies:
'@angular/animations':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
'@angular/cdk':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.2.9(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/common':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/core':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/forms':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
'@angular/material':
specifier: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0
version: 20.2.9(b517547b325ffc8400ae4cda6a618bfd)
'@livekit/track-processors':
specifier: ^0.6.0
version: 0.6.1(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.11(@types/dom-mediacapture-record@1.0.22))
autolinker:
specifier: ^4.0.0
version: 4.1.5
buffer:
specifier: ^6.0.3
version: 6.0.3
livekit-client:
specifier: ^2.15.0
version: 2.15.11(@types/dom-mediacapture-record@1.0.22)
tslib:
specifier: ^2.3.0
version: 2.8.1
meet-ce/backend:
dependencies:
'@aws-sdk/client-s3':
@ -274,8 +241,8 @@ importers:
specifier: 2.15.11
version: 2.15.11(@types/dom-mediacapture-record@1.0.22)
openvidu-components-angular:
specifier: workspace:*
version: link:../../../openvidu/openvidu-components-angular/projects/openvidu-components-angular
specifier: 3.4.0
version: 3.4.0(3c63e791699a7778b296d869e22cd258)
rxjs:
specifier: 7.8.2
version: 7.8.2
@ -407,8 +374,8 @@ importers:
meet-ce/frontend/projects/shared-meet-components:
dependencies:
openvidu-components-angular:
specifier: workspace:*
version: link:../../../../../openvidu/openvidu-components-angular/projects/openvidu-components-angular
specifier: 3.4.0
version: 3.4.0(3c63e791699a7778b296d869e22cd258)
tslib:
specifier: ^2.3.0
version: 2.8.1
@ -495,365 +462,6 @@ importers:
specifier: 5.9.2
version: 5.9.2
meet-pro/backend:
dependencies:
'@aws-sdk/client-s3':
specifier: 3.846.0
version: 3.846.0
'@azure/storage-blob':
specifier: 12.27.0
version: 12.27.0
'@google-cloud/storage':
specifier: 7.17.1
version: 7.17.1(encoding@0.1.13)
'@openvidu-meet-pro/typings':
specifier: workspace:*
version: link:../typings
'@openvidu-meet/backend':
specifier: workspace:*
version: link:../../meet-ce/backend
'@sesamecare-oss/redlock':
specifier: 1.4.0
version: 1.4.0(ioredis@5.6.1)
archiver:
specifier: 7.0.1
version: 7.0.1
bcrypt:
specifier: 5.1.1
version: 5.1.1(encoding@0.1.13)
body-parser:
specifier: 2.2.0
version: 2.2.0
chalk:
specifier: 5.6.2
version: 5.6.2
cookie-parser:
specifier: 1.4.7
version: 1.4.7
cors:
specifier: 2.8.5
version: 2.8.5
cron:
specifier: 4.3.3
version: 4.3.3
dotenv:
specifier: 16.6.1
version: 16.6.1
express:
specifier: 4.21.2
version: 4.21.2
express-rate-limit:
specifier: 7.5.1
version: 7.5.1(express@4.21.2)
inversify:
specifier: 6.2.2
version: 6.2.2(reflect-metadata@0.2.2)
ioredis:
specifier: 5.6.1
version: 5.6.1
jwt-decode:
specifier: 4.0.0
version: 4.0.0
livekit-server-sdk:
specifier: 2.13.1
version: 2.13.1
ms:
specifier: 2.1.3
version: 2.1.3
uid:
specifier: 2.0.2
version: 2.0.2
winston:
specifier: 3.18.3
version: 3.18.3
yamljs:
specifier: 0.3.0
version: 0.3.0
zod:
specifier: 3.25.76
version: 3.25.76
devDependencies:
'@types/archiver':
specifier: 6.0.3
version: 6.0.3
'@types/bcrypt':
specifier: 5.0.2
version: 5.0.2
'@types/cookie-parser':
specifier: 1.4.9
version: 1.4.9(@types/express@4.17.23)
'@types/cors':
specifier: 2.8.19
version: 2.8.19
'@types/express':
specifier: 4.17.23
version: 4.17.23
'@types/jest':
specifier: 29.5.14
version: 29.5.14
'@types/ms':
specifier: 2.1.0
version: 2.1.0
'@types/node':
specifier: 22.16.4
version: 22.16.4
'@types/supertest':
specifier: 6.0.3
version: 6.0.3
'@types/unzipper':
specifier: 0.10.11
version: 0.10.11
'@types/validator':
specifier: 13.15.2
version: 13.15.2
'@types/yamljs':
specifier: 0.2.34
version: 0.2.34
'@typescript-eslint/eslint-plugin':
specifier: 6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)
'@typescript-eslint/parser':
specifier: 6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.2)
cross-env:
specifier: 7.0.3
version: 7.0.3
eslint:
specifier: 8.57.1
version: 8.57.1
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0(eslint@8.57.1)
jest:
specifier: 29.7.0
version: 29.7.0(@types/node@22.16.4)(ts-node@10.9.2(@types/node@22.16.4)(typescript@5.9.2))
jest-fetch-mock:
specifier: 3.0.3
version: 3.0.3(encoding@0.1.13)
jest-junit:
specifier: 16.0.0
version: 16.0.0
nodemon:
specifier: 3.1.10
version: 3.1.10
openapi-generate-html:
specifier: 0.5.3
version: 0.5.3(@types/node@22.16.4)
prettier:
specifier: 3.6.2
version: 3.6.2
supertest:
specifier: 7.1.3
version: 7.1.3
ts-jest:
specifier: 29.4.0
version: 29.4.0(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.16.4)(ts-node@10.9.2(@types/node@22.16.4)(typescript@5.9.2)))(typescript@5.9.2)
ts-jest-resolver:
specifier: 2.0.1
version: 2.0.1
tsx:
specifier: 4.20.3
version: 4.20.3
typescript:
specifier: 5.9.2
version: 5.9.2
unzipper:
specifier: 0.12.3
version: 0.12.3
meet-pro/frontend:
dependencies:
'@angular/animations':
specifier: 20.3.4
version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
'@angular/cdk':
specifier: 20.2.9
version: 20.2.9(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/common':
specifier: 20.3.4
version: 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/compiler':
specifier: 20.3.4
version: 20.3.4
'@angular/core':
specifier: 20.3.4
version: 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/forms':
specifier: 20.3.4
version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
'@angular/material':
specifier: 20.2.9
version: 20.2.9(b517547b325ffc8400ae4cda6a618bfd)
'@angular/platform-browser':
specifier: 20.3.4
version: 20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
'@angular/platform-browser-dynamic':
specifier: 20.3.4
version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))
'@angular/router':
specifier: 20.3.4
version: 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
'@livekit/track-processors':
specifier: 0.6.1
version: 0.6.1(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.11(@types/dom-mediacapture-record@1.0.22))
'@openvidu-meet/shared-components':
specifier: workspace:*
version: link:../../meet-ce/frontend/projects/shared-meet-components
'@openvidu-meet/typings':
specifier: workspace:*
version: link:../../meet-ce/typings
autolinker:
specifier: 4.1.5
version: 4.1.5
core-js:
specifier: 3.45.1
version: 3.45.1
jwt-decode:
specifier: 4.0.0
version: 4.0.0
livekit-client:
specifier: 2.15.11
version: 2.15.11(@types/dom-mediacapture-record@1.0.22)
openvidu-components-angular:
specifier: workspace:*
version: link:../../../openvidu/openvidu-components-angular/projects/openvidu-components-angular
rxjs:
specifier: 7.8.2
version: 7.8.2
tslib:
specifier: 2.8.1
version: 2.8.1
unique-names-generator:
specifier: 4.7.1
version: 4.7.1
zone.js:
specifier: 0.15.1
version: 0.15.1
devDependencies:
'@angular-builders/custom-webpack':
specifier: 20.0.0
version: 20.0.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)
'@angular-devkit/build-angular':
specifier: 20.3.4
version: 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(tsx@4.20.3)(typescript@5.9.2)
'@angular-eslint/builder':
specifier: 20.3.0
version: 20.3.0(chokidar@4.0.3)(eslint@8.57.1)(typescript@5.9.2)
'@angular-eslint/eslint-plugin':
specifier: 20.3.0
version: 20.3.0(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)
'@angular-eslint/eslint-plugin-template':
specifier: 20.3.0
version: 20.3.0(@angular-eslint/template-parser@20.3.0(eslint@8.57.1)(typescript@5.9.2))(@typescript-eslint/types@8.46.1)(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)
'@angular-eslint/schematics':
specifier: 20.3.0
version: 20.3.0(@angular-eslint/template-parser@20.3.0(eslint@8.57.1)(typescript@5.9.2))(@typescript-eslint/types@8.46.1)(@typescript-eslint/utils@8.46.1(eslint@8.57.1)(typescript@5.9.2))(chokidar@4.0.3)(eslint@8.57.1)(typescript@5.9.2)
'@angular-eslint/template-parser':
specifier: 20.3.0
version: 20.3.0(eslint@8.57.1)(typescript@5.9.2)
'@angular/cli':
specifier: 20.3.4
version: 20.3.4(@types/node@22.18.8)(chokidar@4.0.3)
'@angular/compiler-cli':
specifier: 20.3.4
version: 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2)
'@types/chai':
specifier: 4.3.20
version: 4.3.20
'@types/fluent-ffmpeg':
specifier: 2.1.27
version: 2.1.27
'@types/jasmine':
specifier: 5.1.9
version: 5.1.9
'@types/mocha':
specifier: 9.1.1
version: 9.1.1
'@types/node':
specifier: 22.18.8
version: 22.18.8
'@types/pixelmatch':
specifier: 5.2.6
version: 5.2.6
'@types/pngjs':
specifier: 6.0.5
version: 6.0.5
'@types/selenium-webdriver':
specifier: 4.35.1
version: 4.35.1
'@typescript-eslint/eslint-plugin':
specifier: 8.46.1
version: 8.46.1(@typescript-eslint/parser@8.46.1(eslint@8.57.1)(typescript@5.9.2))(eslint@8.57.1)(typescript@5.9.2)
'@typescript-eslint/parser':
specifier: 8.46.1
version: 8.46.1(eslint@8.57.1)(typescript@5.9.2)
chai:
specifier: 4.5.0
version: 4.5.0
chromedriver:
specifier: 141.0.0
version: 141.0.0
cross-env:
specifier: 7.0.3
version: 7.0.3
eslint:
specifier: 8.57.1
version: 8.57.1
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0(eslint@8.57.1)
fluent-ffmpeg:
specifier: 2.1.3
version: 2.1.3
jasmine-core:
specifier: 5.6.0
version: 5.6.0
jasmine-spec-reporter:
specifier: 7.0.0
version: 7.0.0
karma:
specifier: 6.4.4
version: 6.4.4
karma-chrome-launcher:
specifier: 3.2.0
version: 3.2.0
karma-coverage:
specifier: 2.2.1
version: 2.2.1
karma-jasmine:
specifier: 5.1.0
version: 5.1.0(karma@6.4.4)
karma-jasmine-html-reporter:
specifier: 2.1.0
version: 2.1.0(jasmine-core@5.6.0)(karma-jasmine@5.1.0(karma@6.4.4))(karma@6.4.4)
mocha:
specifier: 10.7.3
version: 10.7.3
ng-packagr:
specifier: 20.3.0
version: 20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2)
prettier:
specifier: 3.3.3
version: 3.3.3
selenium-webdriver:
specifier: 4.25.0
version: 4.25.0
ts-node:
specifier: 10.9.2
version: 10.9.2(@types/node@22.18.8)(typescript@5.9.2)
typescript:
specifier: 5.9.2
version: 5.9.2
meet-pro/typings:
devDependencies:
'@openvidu-meet/typings':
specifier: workspace:*
version: link:../../meet-ce/typings
typescript:
specifier: 5.9.2
version: 5.9.2
testapp:
dependencies:
'@openvidu-meet/typings':
@ -7464,6 +7072,20 @@ packages:
resolution: {integrity: sha512-24epA6vxDX0hqEg+jKhMzeMZ9CvNoJlTcqat6+CL8U8bETM4Kc5i7wv2jhbqGFXHHw7kHLtPFz4QjgTooV5nHQ==}
hasBin: true
openvidu-components-angular@3.4.0:
resolution: {integrity: sha512-WsJfBLBUjsnM6jDfjsOmtGlHgG1HFhIkk238f0u0VxqRdhwXlh/BK/kESxJVl6/RkhhIoU6GJ5Q3bCRXrXNzqg==}
peerDependencies:
'@angular/animations': ^17.0.0 || ^18.0.0 || ^19.0.0
'@angular/cdk': ^17.0.0 || ^18.0.0 || ^19.0.0
'@angular/common': ^17.0.0 || ^18.0.0 || ^19.0.0
'@angular/core': ^17.0.0 || ^18.0.0 || ^19.0.0
'@angular/forms': ^17.0.0 || ^18.0.0 || ^19.0.0
'@angular/material': ^17.0.0 || ^18.0.0 || ^19.0.0
'@livekit/track-processors': ^0.6.0
autolinker: ^4.0.0
buffer: ^6.0.3
livekit-client: ^2.15.0
opn@5.3.0:
resolution: {integrity: sha512-bYJHo/LOmoTd+pfiYhfZDnf9zekVJrY+cnS2a5F2x+w5ppvTqObojTP7WiFG+kVZs9Inw+qQ/lw7TroWwhdd2g==}
engines: {node: '>=4'}
@ -9599,59 +9221,6 @@ snapshots:
- webpack-cli
- yaml
'@angular-builders/custom-webpack@20.0.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)':
dependencies:
'@angular-builders/common': 4.0.0(@types/node@22.18.8)(chokidar@4.0.3)(typescript@5.9.2)
'@angular-devkit/architect': 0.2003.5(chokidar@4.0.3)
'@angular-devkit/build-angular': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(browser-sync@3.0.4)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@22.18.8)(ts-node@10.9.2(@types/node@22.18.8)(typescript@5.9.2)))(jiti@1.21.7)(karma@6.4.4)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(tsx@4.20.3)(typescript@5.9.2)
'@angular-devkit/core': 20.3.5(chokidar@4.0.3)
'@angular/build': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)
'@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2)
lodash: 4.17.21
webpack-merge: 6.0.1
transitivePeerDependencies:
- '@angular/compiler'
- '@angular/core'
- '@angular/localize'
- '@angular/platform-browser'
- '@angular/platform-server'
- '@angular/service-worker'
- '@angular/ssr'
- '@rspack/core'
- '@swc/core'
- '@swc/wasm'
- '@types/node'
- '@web/test-runner'
- browser-sync
- bufferutil
- chokidar
- debug
- html-webpack-plugin
- jest
- jest-environment-jsdom
- jiti
- karma
- less
- lightningcss
- ng-packagr
- node-sass
- postcss
- protractor
- sass-embedded
- stylus
- sugarss
- supports-color
- tailwindcss
- terser
- tslib
- tsx
- typescript
- uglify-js
- utf-8-validate
- vitest
- webpack-cli
- yaml
'@angular-devkit/architect@0.2003.4(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 20.3.4(chokidar@4.0.3)
@ -9670,7 +9239,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.2003.4(chokidar@4.0.3)
'@angular-devkit/build-webpack': 0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)))(webpack@5.101.2(esbuild@0.25.9))
'@angular-devkit/build-webpack': 0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)
'@angular-devkit/core': 20.3.4(chokidar@4.0.3)
'@angular/build': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.4.0)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.43.1)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)
'@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2)
@ -9684,13 +9253,13 @@ snapshots:
'@babel/preset-env': 7.28.3(@babel/core@7.28.3)
'@babel/runtime': 7.28.3
'@discoveryjs/json-ext': 0.6.3
'@ngtools/webpack': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9))
'@ngtools/webpack': 20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2)
ansi-colors: 4.1.3
autoprefixer: 10.4.21(postcss@8.5.6)
babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9))
babel-loader: 10.0.0(@babel/core@7.28.3)(webpack@5.101.2)
browserslist: 4.26.3
copy-webpack-plugin: 13.0.1(webpack@5.101.2(esbuild@0.25.9))
css-loader: 7.1.2(webpack@5.101.2(esbuild@0.25.9))
copy-webpack-plugin: 13.0.1(webpack@5.101.2)
css-loader: 7.1.2(webpack@5.101.2)
esbuild-wasm: 0.25.9
fast-glob: 3.3.3
http-proxy-middleware: 3.0.5
@ -9698,32 +9267,32 @@ snapshots:
jsonc-parser: 3.3.1
karma-source-map-support: 1.4.0
less: 4.4.0
less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9))
license-webpack-plugin: 4.0.2(webpack@5.101.2(esbuild@0.25.9))
less-loader: 12.3.0(less@4.4.0)(webpack@5.101.2)
license-webpack-plugin: 4.0.2(webpack@5.101.2)
loader-utils: 3.3.1
mini-css-extract-plugin: 2.9.4(webpack@5.101.2(esbuild@0.25.9))
mini-css-extract-plugin: 2.9.4(webpack@5.101.2)
open: 10.2.0
ora: 8.2.0
picomatch: 4.0.3
piscina: 5.1.3
postcss: 8.5.6
postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9))
postcss-loader: 8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2)
resolve-url-loader: 5.0.0
rxjs: 7.8.2
sass: 1.90.0
sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9))
sass-loader: 16.0.5(sass@1.90.0)(webpack@5.101.2)
semver: 7.7.2
source-map-loader: 5.0.0(webpack@5.101.2(esbuild@0.25.9))
source-map-loader: 5.0.0(webpack@5.101.2)
source-map-support: 0.5.21
terser: 5.43.1
tree-kill: 1.2.2
tslib: 2.8.1
typescript: 5.9.2
webpack: 5.101.2(esbuild@0.25.9)
webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9))
webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9))
webpack-dev-middleware: 7.4.2(webpack@5.101.2)
webpack-dev-server: 5.2.2(webpack@5.101.2)
webpack-merge: 6.0.1
webpack-subresource-integrity: 5.1.0(webpack@5.101.2(esbuild@0.25.9))
webpack-subresource-integrity: 5.1.0(webpack@5.101.2)
optionalDependencies:
'@angular/core': 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/platform-browser': 20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
@ -9756,12 +9325,12 @@ snapshots:
- webpack-cli
- yaml
'@angular-devkit/build-webpack@0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)))(webpack@5.101.2(esbuild@0.25.9))':
'@angular-devkit/build-webpack@0.2003.4(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.101.2))(webpack@5.101.2)':
dependencies:
'@angular-devkit/architect': 0.2003.4(chokidar@4.0.3)
rxjs: 7.8.2
webpack: 5.101.2(esbuild@0.25.9)
webpack-dev-server: 5.2.2(webpack@5.101.2(esbuild@0.25.9))
webpack-dev-server: 5.2.2(webpack@5.101.2)
transitivePeerDependencies:
- chokidar
@ -9981,59 +9550,6 @@ snapshots:
- tsx
- yaml
'@angular/build@20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(@angular/compiler@20.3.4)(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@types/node@22.18.8)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.4.2)(ng-packagr@20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2))(postcss@8.5.6)(terser@5.44.0)(tslib@2.8.1)(tsx@4.20.3)(typescript@5.9.2)':
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.2003.4(chokidar@4.0.3)
'@angular/compiler': 20.3.4
'@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2)
'@babel/core': 7.28.3
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-split-export-declaration': 7.24.7
'@inquirer/confirm': 5.1.14(@types/node@22.18.8)
'@vitejs/plugin-basic-ssl': 2.1.0(vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3))
beasties: 0.3.5
browserslist: 4.26.3
esbuild: 0.25.9
https-proxy-agent: 7.0.6
istanbul-lib-instrument: 6.0.3
jsonc-parser: 3.3.1
listr2: 9.0.1
magic-string: 0.30.17
mrmime: 2.0.1
parse5-html-rewriting-stream: 8.0.0
picomatch: 4.0.3
piscina: 5.1.3
rollup: 4.52.3
sass: 1.90.0
semver: 7.7.2
source-map-support: 0.5.21
tinyglobby: 0.2.14
tslib: 2.8.1
typescript: 5.9.2
vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3)
watchpack: 2.4.4
optionalDependencies:
'@angular/core': 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/platform-browser': 20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
karma: 6.4.4
less: 4.4.2
lmdb: 3.4.2
ng-packagr: 20.3.0(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(tslib@2.8.1)(typescript@5.9.2)
postcss: 8.5.6
transitivePeerDependencies:
- '@types/node'
- chokidar
- jiti
- lightningcss
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
'@angular/cdk@20.2.9(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)':
dependencies:
'@angular/common': 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
@ -12523,7 +12039,7 @@ snapshots:
'@napi-rs/nice-win32-x64-msvc': 1.1.1
optional: true
'@ngtools/webpack@20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9))':
'@ngtools/webpack@20.3.4(@angular/compiler-cli@20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2))(typescript@5.9.2)(webpack@5.101.2)':
dependencies:
'@angular/compiler-cli': 20.3.4(@angular/compiler@20.3.4)(typescript@5.9.2)
typescript: 5.9.2
@ -13848,10 +13364,6 @@ snapshots:
dependencies:
vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.0)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3)
'@vitejs/plugin-basic-ssl@2.1.0(vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3))':
dependencies:
vite: 7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3)
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@ -14223,7 +13735,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2(esbuild@0.25.9)):
babel-loader@10.0.0(@babel/core@7.28.3)(webpack@5.101.2):
dependencies:
'@babel/core': 7.28.3
find-up: 5.0.0
@ -14808,7 +14320,7 @@ snapshots:
dependencies:
is-what: 3.14.1
copy-webpack-plugin@13.0.1(webpack@5.101.2(esbuild@0.25.9)):
copy-webpack-plugin@13.0.1(webpack@5.101.2):
dependencies:
glob-parent: 6.0.2
normalize-path: 3.0.0
@ -14919,7 +14431,7 @@ snapshots:
dependencies:
postcss: 8.5.6
css-loader@7.1.2(webpack@5.101.2(esbuild@0.25.9)):
css-loader@7.1.2(webpack@5.101.2):
dependencies:
icss-utils: 5.1.0(postcss@8.5.6)
postcss: 8.5.6
@ -17468,7 +16980,7 @@ snapshots:
dependencies:
readable-stream: 2.3.8
less-loader@12.3.0(less@4.4.0)(webpack@5.101.2(esbuild@0.25.9)):
less-loader@12.3.0(less@4.4.0)(webpack@5.101.2):
dependencies:
less: 4.4.0
optionalDependencies:
@ -17509,7 +17021,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
license-webpack-plugin@4.0.2(webpack@5.101.2(esbuild@0.25.9)):
license-webpack-plugin@4.0.2(webpack@5.101.2):
dependencies:
webpack-sources: 3.3.3
optionalDependencies:
@ -17763,7 +17275,7 @@ snapshots:
mimic-function@5.0.1: {}
mini-css-extract-plugin@2.9.4(webpack@5.101.2(esbuild@0.25.9)):
mini-css-extract-plugin@2.9.4(webpack@5.101.2):
dependencies:
schema-utils: 4.3.3
tapable: 2.3.0
@ -18168,6 +17680,20 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
openvidu-components-angular@3.4.0(3c63e791699a7778b296d869e22cd258):
dependencies:
'@angular/animations': 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))
'@angular/cdk': 20.2.9(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/common': 20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
'@angular/core': 20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)
'@angular/forms': 20.3.4(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.4(@angular/animations@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@20.3.4(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.4(@angular/compiler@20.3.4)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
'@angular/material': 20.2.9(b517547b325ffc8400ae4cda6a618bfd)
'@livekit/track-processors': 0.6.1(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.11(@types/dom-mediacapture-record@1.0.22))
autolinker: 4.1.5
buffer: 6.0.3
livekit-client: 2.15.11(@types/dom-mediacapture-record@1.0.22)
tslib: 2.8.1
opn@5.3.0:
dependencies:
is-wsl: 1.1.0
@ -18431,7 +17957,7 @@ snapshots:
postcss: 8.5.6
ts-node: 10.9.2(@types/node@22.18.8)(typescript@5.7.3)
postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2(esbuild@0.25.9)):
postcss-loader@8.1.1(postcss@8.5.6)(typescript@5.9.2)(webpack@5.101.2):
dependencies:
cosmiconfig: 9.0.0(typescript@5.9.2)
jiti: 1.21.7
@ -19008,7 +18534,7 @@ snapshots:
safer-buffer@2.1.2: {}
sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2(esbuild@0.25.9)):
sass-loader@16.0.5(sass@1.90.0)(webpack@5.101.2):
dependencies:
neo-async: 2.6.2
optionalDependencies:
@ -19314,7 +18840,7 @@ snapshots:
source-map-js@1.2.1: {}
source-map-loader@5.0.0(webpack@5.101.2(esbuild@0.25.9)):
source-map-loader@5.0.0(webpack@5.101.2):
dependencies:
iconv-lite: 0.6.3
source-map-js: 1.2.1
@ -20080,23 +19606,6 @@ snapshots:
terser: 5.44.0
tsx: 4.20.3
vite@7.1.5(@types/node@22.18.8)(jiti@1.21.7)(less@4.4.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.20.3):
dependencies:
esbuild: 0.25.10
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.52.3
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.18.8
fsevents: 2.3.3
jiti: 1.21.7
less: 4.4.2
sass: 1.90.0
terser: 5.44.0
tsx: 4.20.3
void-elements@2.0.1: {}
w3c-xmlserializer@4.0.0:
@ -20133,7 +19642,7 @@ snapshots:
webidl-conversions@7.0.0: {}
webpack-dev-middleware@7.4.2(webpack@5.101.2(esbuild@0.25.9)):
webpack-dev-middleware@7.4.2(webpack@5.101.2):
dependencies:
colorette: 2.0.20
memfs: 4.49.0
@ -20144,7 +19653,7 @@ snapshots:
optionalDependencies:
webpack: 5.101.2(esbuild@0.25.9)
webpack-dev-server@5.2.2(webpack@5.101.2(esbuild@0.25.9)):
webpack-dev-server@5.2.2(webpack@5.101.2):
dependencies:
'@types/bonjour': 3.5.13
'@types/connect-history-api-fallback': 1.5.4
@ -20172,7 +19681,7 @@ snapshots:
serve-index: 1.9.1
sockjs: 0.3.24
spdy: 4.0.2
webpack-dev-middleware: 7.4.2(webpack@5.101.2(esbuild@0.25.9))
webpack-dev-middleware: 7.4.2(webpack@5.101.2)
ws: 8.18.3
optionalDependencies:
webpack: 5.101.2(esbuild@0.25.9)
@ -20190,7 +19699,7 @@ snapshots:
webpack-sources@3.3.3: {}
webpack-subresource-integrity@5.1.0(webpack@5.101.2(esbuild@0.25.9)):
webpack-subresource-integrity@5.1.0(webpack@5.101.2):
dependencies:
typed-assert: 1.0.9
webpack: 5.101.2(esbuild@0.25.9)

View File

@ -16,6 +16,7 @@ packages:
- meet-ce/frontend/webcomponent
- meet-ce/frontend/projects/shared-meet-components
- meet-ce/backend
- testapp
ignoredBuiltDependencies:
- chromedriver

View File

@ -1,25 +1,21 @@
# =============================================================================
# Development Workspace Configuration
# Docker/CI Workspace Configuration
# =============================================================================
# This workspace configuration is used for LOCAL DEVELOPMENT.
# It INCLUDES external packages (like openvidu-components-angular) that
# are located in sibling repositories for fast development with hot-reload.
# This workspace configuration is used for Docker builds and CI workflows.
# It EXCLUDES external packages (like openvidu-components-angular) that
# are not part of this repository.
#
# For Docker builds and CI, use pnpm-workspace.docker.yaml instead.
# For local development, use pnpm-workspace.yaml instead.
#
# See docs/ci-docker-dependencies-strategy.md for more information.
# =============================================================================
packages:
- ../openvidu/openvidu-components-angular/projects/openvidu-components-angular
- meet-ce/typings
- meet-ce/frontend
- meet-ce/frontend/webcomponent
- meet-ce/frontend/projects/shared-meet-components
- meet-ce/backend
- meet-pro/frontend
- meet-pro/backend
- meet-pro/typings
- testapp
ignoredBuiltDependencies:

63
restart-stack.sh Executable file
View File

@ -0,0 +1,63 @@
#!/bin/bash
set -e
echo "🔄 REINICIANDO STACK COMPLETO OPENVIDU MEET"
echo "=========================================="
# Crear directorio de logs
mkdir -p /tmp/ovm-logs
echo -e "\n1⃣ Matando procesos existentes..."
pkill -f "node.*dist/src/server.js" || true
pkill -f "livekit-server" || true
pkill -f "ng serve" || true
pkill -f "pnpm.*start" || true
sleep 3
echo -e "\n2⃣ Reiniciando Backend..."
cd /home/xesar/Documentos/openvidu-meet/meet-ce/backend
pnpm run build
nohup env \
NODE_ENV=development \
LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host \
LIVEKIT_URL_PRIVATE=wss://livekit-server.bfzqqk.easypanel.host \
LIVEKIT_API_KEY=devkey \
LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret \
MEET_REDIS_HOST=192.168.1.19 \
MEET_REDIS_PORT=6379 \
MEET_REDIS_PASSWORD=redispassword \
MEET_BLOB_STORAGE_MODE=s3 \
MEET_S3_SERVICE_ENDPOINT=http://192.168.1.19:9000 \
MEET_S3_ACCESS_KEY=minioadmin \
MEET_S3_SECRET_KEY=minioadmin \
MEET_S3_BUCKET=openvidu-appdata \
MEET_S3_WITH_PATH_STYLE_ACCESS=true \
MEET_INITIAL_ADMIN_USER=admin \
MEET_INITIAL_ADMIN_PASSWORD=admin \
node dist/src/server.js > /tmp/ovm-logs/backend.log 2>&1 &
echo "✅ Backend iniciado"
echo -e "\n3⃣ Reiniciando LiveKit..."
cd /home/xesar/Documentos/openvidu-meet
nohup livekit-server --config livekit.yaml > /tmp/ovm-logs/livekit.log 2>&1 &
echo "✅ LiveKit iniciado"
echo -e "\n⏳ Esperando arranque de servicios (15s)..."
sleep 15
echo -e "\n🔍 Verificación final:"
echo "Puertos activos:"
ss -ltn | egrep '6080|7880|6379|9000' | sort
echo -e "\nTest conectividad:"
curl -s -o /dev/null -w "✅ Backend (6080): %{http_code}\n" https://openvidu.bfzqqk.easypanel.host || echo "❌ Backend no responde"
redis-cli -h 192.168.1.19 -p 6379 -a redispassword ping 2>/dev/null && echo "✅ Redis: PONG" || echo "❌ Redis no responde"
echo -e "\n🚀 STACK REINICIADO COMPLETAMENTE"
echo "📄 Logs en: /tmp/ovm-logs/"
echo -e "\n🌐 URLs:"
echo " • Frontend: http://192.168.1.19:4200"
echo " • Backend: https://openvidu.bfzqqk.easypanel.host"
echo " • LiveKit: wss://livekit-server.bfzqqk.easypanel.host"

Some files were not shown because too many files have changed in this diff Show More