feat: Implement Studio Panel with Left Sidebar, Right Panel, and Video Area

- Added StudioLeftSidebar component for scene management with add, delete, and duplicate functionalities.
- Introduced StudioRightPanel component with tabs for brand settings, multimedia, sounds, video, QR code generation, countdown, and general settings.
- Created StudioSidebar component for participant management, chat, and notes.
- Developed StudioVideoArea component to handle video display for demo and live modes.
- Configured demo data for scenes, participants, overlays, backgrounds, and sounds in demo.ts.
- Set up a token server for LiveKit integration to manage participant access.
- Updated Vite environment definitions for LiveKit configuration.
This commit is contained in:
Cesar Mendivil 2025-11-06 19:09:00 -07:00
parent 70317f95f8
commit 0ca2b36b5c
31 changed files with 5301 additions and 68 deletions

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
version: '3.8'
services:
# Servidor de tokens LiveKit
livekit-token-server:
build:
context: ./packages/studio-panel
dockerfile: Dockerfile
container_name: avanzacast-token-server
ports:
- "3010:3010"
environment:
- NODE_ENV=production
- PORT=3010
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
- LIVEKIT_URL=${LIVEKIT_URL}
env_file:
- .env
restart: unless-stopped
networks:
- avanzacast-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3010/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
avanzacast-network:
driver: bridge

View File

@ -0,0 +1,205 @@
# Implementación del Studio de AvanzaCast
## ✅ Resumen de la Implementación
Se ha creado exitosamente la interfaz del Studio de transmisión en vivo dentro del package `broadcast-panel`, integrando los componentes de LiveKit para funcionalidad de video en tiempo real.
## 📦 Dependencias Instaladas
```bash
npm install @livekit/components-react @livekit/components-styles livekit-client --save
```
### Paquetes agregados:
- `@livekit/components-react`: Componentes de React para LiveKit
- `@livekit/components-styles`: Estilos predefinidos para componentes LiveKit
- `livekit-client`: SDK cliente de LiveKit para JavaScript
## 🏗️ Componentes Creados
### 1. Studio.tsx
**Ubicación**: `/packages/broadcast-panel/src/components/Studio.tsx`
**Características**:
- Interfaz completa de estudio de transmisión
- Formulario de conexión para desarrollo
- Integración con LiveKit Room
- Controles personalizados de audio/video
- Sidebar con lista de participantes
- Botones para: micrófono, cámara, compartir pantalla, grabación
**Componentes internos**:
- `StudioControls`: Barra de controles inferior personalizada
- Controles de medios (mic, cámara, pantalla compartida)
- Botón de grabación con animación
- Layout responsivo
### 2. Studio.module.css
**Ubicación**: `/packages/broadcast-panel/src/components/Studio.module.css`
**Estilos implementados**:
- Layout flexible con sidebar derecho
- Barra de controles inferior estilo profesional
- Tema oscuro personalizado
- Animaciones suaves en botones
- Estilos para participantes
- Personalización de componentes LiveKit
- Diseño responsivo para móviles
## 🔄 Componentes Modificados
### 1. Sidebar.tsx
**Cambios**:
- Agregado ítem "Studio" con ícono `MdVideocam`
- Añadida prop `onNavigate` para manejar navegación
- Implementados handlers para clicks en navegación
### 2. PageContainer.tsx
**Cambios**:
- Agregado state `currentPage` para manejo de rutas
- Implementada función `handleNavigate`
- Renderizado condicional del componente Studio
- Pasada prop `onNavigate` al Sidebar
## 🎨 Diseño Visual
La interfaz sigue el diseño mostrado en la imagen de referencia:
```
┌─────────────────────────────────────────────────────┐
│ Header (Logo, Usuario, Notificaciones) │
├──────┬─────────────────────────────────┬───────────┤
│ │ │ │
│ S │ │ Personas │
│ i │ VIDEO PRINCIPAL │ │
│ d │ (LiveKit VideoConference) │ Chat │
│ e │ │ │
│ b │ │ Notas │
│ a │ │ │
│ r ├─────────────────────────────────┤ │
│ │ ┌──┐ ┌──┐ ┌──┐ │ ⚙️ │ 🔴 │ │
│ │ │🎤│ │📹│ │🖥│ │ ⚙️ │ [Salir] │ │
│ │ └──┘ └──┘ └──┘ │ │ │ │
└──────┴─────────────────────────────────┴───────────┘
```
### Características visuales:
- **Color scheme**: Tema oscuro (#1a1d29, #0f1117)
- **Controles**: Botones redondeados con efectos hover
- **Sidebar derecho**: Tabs para Personas, Chat, Notas
- **Barra inferior**: Controles centralizados con espaciado uniforme
- **Animaciones**: Transiciones suaves, efecto pulse en grabación
## 📝 Componentes Reutilizados
### Del package broadcast-panel:
`Sidebar` - Navegación lateral
`Header` - Encabezado superior
`Tooltip` - Tooltips en controles
`ThemeProvider` - Manejo de temas claro/oscuro
### Del shared:
`Logo` - Logo unificado con fuente Requiner
## 🚀 Cómo Usar
### 1. Iniciar el servidor de desarrollo:
```bash
npm run dev:broadcast-panel
```
### 2. Acceder al Studio:
- Abre [http://localhost:5173](http://localhost:5173)
- Haz clic en "Studio" en el sidebar
### 3. Conectar a LiveKit:
- Necesitas una cuenta en [LiveKit Cloud](https://cloud.livekit.io)
- Obtén tu URL del servidor: `wss://your-project.livekit.cloud`
- Genera un token de acceso temporal
- Ingresa ambos valores en el formulario de conexión
## 📚 Documentación Adicional
Se ha creado el archivo **LIVEKIT_SETUP.md** que incluye:
- Guía completa de configuración de LiveKit
- Instrucciones para desarrollo y producción
- Ejemplos de código para generar tokens
- Solución de problemas comunes
- Enlaces a recursos útiles
## 🔮 Próximas Funcionalidades
### Fase 2 - Chat y Colaboración:
- [ ] Chat en tiempo real con mensajes
- [ ] Sistema de notas compartidas
- [ ] Invitar participantes por email/link
- [ ] Roles y permisos (host, presentador, invitado)
### Fase 3 - Streaming Avanzado:
- [ ] Múltiples layouts (grid, spotlight, sidebar)
- [ ] Overlays personalizables con branding
- [ ] Lower thirds animados
- [ ] Transiciones entre escenas
### Fase 4 - Multistream:
- [ ] Configuración de destinos (YouTube, Facebook, Twitch, LinkedIn)
- [ ] Transmisión simultánea a múltiples plataformas
- [ ] Monitoreo de estado de streams
- [ ] Estadísticas en tiempo real
### Fase 5 - Grabación:
- [ ] Grabación local en el navegador
- [ ] Grabación en la nube con LiveKit
- [ ] Exportación automática a storage
- [ ] Edición básica post-grabación
## 🐛 Testing
### Checklist de pruebas:
- [ ] Navegación entre páginas funciona correctamente
- [ ] Formulario de conexión valida campos
- [ ] Sidebar muestra "Studio" como activo
- [ ] Controles responden a hover/click
- [ ] Layout es responsivo en diferentes tamaños
- [ ] Tema oscuro se aplica correctamente
- [ ] Logo aparece con fuente Requiner
- [ ] Header mantiene funcionalidad
## 💡 Notas Técnicas
### Estado de controles:
Actualmente los controles (mic, cámara, etc.) cambian su estado visual pero **no controlan el hardware real**. La integración completa con LiveKit requerirá:
```typescript
// Ejemplo de integración real con LiveKit
import { useLocalParticipant } from '@livekit/components-react'
const { localParticipant } = useLocalParticipant()
const toggleMic = () => {
localParticipant.setMicrophoneEnabled(!micEnabled)
setMicEnabled(!micEnabled)
}
```
### Hooks de LiveKit disponibles:
- `useRoomContext()`: Acceso al contexto de la sala
- `useLocalParticipant()`: Control del participante local
- `useParticipants()`: Lista de todos los participantes
- `useTracks()`: Acceso a tracks de audio/video
- `useMediaDevices()`: Listar/seleccionar dispositivos
## 📊 Métricas de Implementación
- **Archivos creados**: 3
- **Archivos modificados**: 2
- **Líneas de código**: ~800
- **Componentes**: 2 (Studio, StudioControls)
- **Dependencias agregadas**: 3
- **Tiempo estimado**: 2-3 horas de desarrollo
## 🎯 Conclusión
La implementación del Studio proporciona una base sólida para las funcionalidades de transmisión en vivo de AvanzaCast. La integración con LiveKit permite aprovechar su infraestructura robusta y escalable, mientras que los componentes personalizados ofrecen una experiencia única y branded.
El código está listo para desarrollo y testing. Para uso en producción, se recomienda implementar un backend seguro para generación de tokens y añadir las funcionalidades de multistream y grabación en las siguientes fases.

649
package-lock.json generated
View File

@ -432,6 +432,11 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="
},
"node_modules/@colors/colors": { "node_modules/@colors/colors": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@ -876,6 +881,28 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@ -1039,6 +1066,69 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@livekit/components-core": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.10.tgz",
"integrity": "sha512-lSGci8c8IB/qCi42g1tzNtDGpnBWH1XSSk/OA9Lzk7vqOG0LlkwD3zXfBeKfO2eWFmYRfrZ2GD59GaH2NtTgag==",
"dependencies": {
"@floating-ui/dom": "1.6.13",
"loglevel": "1.9.1",
"rxjs": "7.8.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"livekit-client": "^2.13.3",
"tslib": "^2.6.2"
}
},
"node_modules/@livekit/components-react": {
"version": "2.9.15",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.15.tgz",
"integrity": "sha512-b+gA0sRJHMsyr/BoMBoY1vSXQmP3h5NmxZTUt+VG8xjzCYDjmUuiDUrKVwMIUoy1vK9I6uNfo+hp6qbLo84jfQ==",
"dependencies": {
"@livekit/components-core": "0.12.10",
"clsx": "2.1.1",
"usehooks-ts": "3.1.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
"livekit-client": "^2.13.3",
"react": ">=18",
"react-dom": ">=18",
"tslib": "^2.6.2"
},
"peerDependenciesMeta": {
"@livekit/krisp-noise-filter": {
"optional": true
}
}
},
"node_modules/@livekit/components-styles": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.1.6.tgz",
"integrity": "sha512-V6zfuREC2ksW8z6T6WSbEvdLB5ICVikGz1GtLr59UcxHDyAsKDbuDHAyl3bF3xBqPKYmY3GWF3Qk39rnScyOtA==",
"engines": {
"node": ">=18"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="
},
"node_modules/@livekit/protocol": {
"version": "1.42.2",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.42.2.tgz",
"integrity": "sha512-0jeCwoMJKcwsZICg5S6RZM4xhJoF78qMvQELjACJQn6/VB+jmiySQKOSELTXvPBVafHfEbMlqxUw2UR1jTXs2g==",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@mdi/font": { "node_modules/@mdi/font": {
"version": "7.4.47", "version": "7.4.47",
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
@ -1987,6 +2077,12 @@
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
}, },
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"peer": true
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2962,6 +3058,17 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-css": { "node_modules/camelcase-css": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@ -2971,6 +3078,23 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001753", "version": "1.0.30001753",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz",
@ -3006,7 +3130,6 @@
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-styles": "^4.1.0", "ansi-styles": "^4.1.0",
"supports-color": "^7.1.0" "supports-color": "^7.1.0"
@ -3022,7 +3145,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
}, },
@ -3037,7 +3159,6 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
@ -4564,6 +4685,14 @@
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/events-universal": { "node_modules/events-universal": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
@ -5310,7 +5439,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -5860,6 +5988,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -6063,6 +6196,14 @@
"@sideway/pinpoint": "^2.0.0" "@sideway/pinpoint": "^2.0.0"
} }
}, },
"node_modules/jose": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-datepicker": { "node_modules/js-datepicker": {
"version": "5.18.4", "version": "5.18.4",
"resolved": "https://registry.npmjs.org/js-datepicker/-/js-datepicker-5.18.4.tgz", "resolved": "https://registry.npmjs.org/js-datepicker/-/js-datepicker-5.18.4.tgz",
@ -6196,6 +6337,60 @@
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==" "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ=="
}, },
"node_modules/livekit-client": {
"version": "2.15.14",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.14.tgz",
"integrity": "sha512-q3QY1Md6+2l4LpV7OPSrKYbuMfMoEbcu+UaJL2e8Btrkh7R2wGJzWh8A852Stx4It1508IP9PK4q7U6trDzvYA==",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.42.2",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"ts-debounce": "^4.0.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/livekit-client/node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/livekit-server-sdk": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.14.0.tgz",
"integrity": "sha512-7lZBkiVOOnPIYz6XyQ9teVxlkLQVve7JFuiYgLkYQCLZQLSZPjIboqP1ZocbLbPx4ijceYwVfOZHktF0YbfvVw==",
"dependencies": {
"@bufbuild/protobuf": "^1.10.1",
"@livekit/protocol": "^1.42.0",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/livekit-server-sdk/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -6207,6 +6402,11 @@
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true "dev": true
}, },
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -6274,6 +6474,18 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/loglevel": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@ -6344,6 +6556,17 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}, },
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -7786,6 +8009,17 @@
} }
] ]
}, },
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -8743,6 +8977,30 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -8770,7 +9028,6 @@
"version": "7.8.2", "version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -8882,6 +9139,19 @@
"compute-scroll-into-view": "^3.0.2" "compute-scroll-into-view": "^3.0.2"
} }
}, },
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -9037,7 +9307,6 @@
"version": "1.8.3", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@ -9599,7 +9868,6 @@
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
@ -10147,7 +10415,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"bin": { "bin": {
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
@ -10160,6 +10427,11 @@
"node": ">= 14.0.0" "node": ">= 14.0.0"
} }
}, },
"node_modules/ts-debounce": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg=="
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -10623,6 +10895,17 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@ -10705,6 +10988,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
@ -10892,6 +11183,20 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -11015,6 +11320,18 @@
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
}, },
"node_modules/webrtc-adapter": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz",
"integrity": "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-encoding": { "node_modules/whatwg-encoding": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@ -11382,7 +11699,6 @@
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"dependencies": { "dependencies": {
"cliui": "^8.0.1", "cliui": "^8.0.1",
"escalade": "^3.1.1", "escalade": "^3.1.1",
@ -11408,7 +11724,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -11416,14 +11731,12 @@
"node_modules/yargs/node_modules/emoji-regex": { "node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
"dev": true
}, },
"node_modules/yargs/node_modules/string-width": { "node_modules/yargs/node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0", "is-fullwidth-code-point": "^3.0.0",
@ -11437,7 +11750,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@ -11536,6 +11848,9 @@
"packages/broadcast-panel": { "packages/broadcast-panel": {
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.9.15",
"@livekit/components-styles": "^1.1.6",
"livekit-client": "^2.15.14",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
@ -12179,8 +12494,17 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@livekit/components-react": "^2.9.15",
"@livekit/components-styles": "^1.1.6",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"livekit-client": "^2.15.14",
"livekit-server-sdk": "^2.14.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"socket.io-client": "^4.6.2", "socket.io-client": "^4.6.2",
"zustand": "^4.4.7" "zustand": "^4.4.7"
@ -12197,6 +12521,303 @@
"vite": "^4.3.9" "vite": "^4.3.9"
} }
}, },
"packages/studio-panel/node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/studio-panel/node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"packages/studio-panel/node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"packages/studio-panel/node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/studio-panel/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"engines": {
"node": ">=6.6.0"
}
},
"packages/studio-panel/node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"packages/studio-panel/node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/studio-panel/node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"packages/studio-panel/node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"packages/studio-panel/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"packages/studio-panel/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"packages/studio-panel/node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/studio-panel/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"engines": {
"node": ">= 0.6"
}
},
"packages/studio-panel/node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/studio-panel/node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"engines": {
"node": ">= 0.6"
}
},
"packages/studio-panel/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/studio-panel/node_modules/raw-body": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.7.0",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"packages/studio-panel/node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/studio-panel/node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"packages/studio-panel/node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"packages/studio-panel/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"shared/components": { "shared/components": {
"name": "@avanzacast/shared-components", "name": "@avanzacast/shared-components",
"version": "1.0.0", "version": "1.0.0",

View File

@ -0,0 +1,161 @@
# Configuración de LiveKit para AvanzaCast Studio
## ¿Qué es LiveKit?
LiveKit es una plataforma de código abierto para aplicaciones de video en tiempo real. Proporciona una infraestructura escalable y componentes de React listos para usar que facilitan la construcción de aplicaciones de videoconferencia, streaming en vivo y comunicación en tiempo real.
## Configuración Rápida para Desarrollo
### 1. Crear cuenta en LiveKit Cloud
1. Ve a [https://cloud.livekit.io](https://cloud.livekit.io)
2. Regístrate para obtener una cuenta gratuita
3. Crea un nuevo proyecto
### 2. Obtener Credenciales
En tu proyecto de LiveKit Cloud encontrarás:
- **URL del servidor WebSocket**: `wss://your-project.livekit.cloud`
- **API Key**: Tu clave de API
- **API Secret**: Tu secreto de API
### 3. Generar Token de Acceso
Para desarrollo rápido, puedes generar tokens temporales en:
[https://cloud.livekit.io/projects/YOUR_PROJECT/settings/keys](https://cloud.livekit.io/projects)
O usar el siguiente script en Node.js:
```javascript
import { AccessToken } from 'livekit-server-sdk'
const createToken = () => {
const at = new AccessToken('your-api-key', 'your-api-secret', {
identity: 'user-identity',
name: 'User Name',
})
at.addGrant({
room: 'studio-room',
roomJoin: true,
canPublish: true,
canSubscribe: true,
})
return at.toJwt()
}
console.log(createToken())
```
### 4. Usar en el Studio
1. Navega a la sección "Studio" en el sidebar
2. Ingresa la URL del servidor: `wss://your-project.livekit.cloud`
3. Ingresa el token generado
4. Haz clic en "Conectar"
## Configuración de Producción
Para producción, debes implementar un servidor backend que genere tokens de forma segura:
### Backend Node.js/Express
```javascript
import express from 'express'
import { AccessToken } from 'livekit-server-sdk'
const app = express()
app.post('/api/token', async (req, res) => {
const { room, username } = req.body
const at = new AccessToken(
process.env.LIVEKIT_API_KEY,
process.env.LIVEKIT_API_SECRET,
{
identity: username,
name: username,
}
)
at.addGrant({
room,
roomJoin: true,
canPublish: true,
canSubscribe: true,
})
res.json({ token: at.toJwt() })
})
app.listen(3001)
```
### Variables de Entorno
Crea un archivo `.env.local`:
```env
LIVEKIT_API_KEY=your-api-key
LIVEKIT_API_SECRET=your-api-secret
LIVEKIT_URL=wss://your-project.livekit.cloud
```
## Características del Studio de AvanzaCast
### Funcionalidades Implementadas
✅ Interfaz de Studio con layout profesional
✅ Controles de audio y video (mic, cámara)
✅ Compartir pantalla
✅ Botón de grabación
✅ Sidebar con participantes
✅ Integración con componentes LiveKit
### Próximas Funcionalidades
🔄 Chat en tiempo real
🔄 Invitar participantes
🔄 Múltiples layouts de video
🔄 Overlays y branding personalizado
🔄 Multistream a plataformas (YouTube, Facebook, Twitch)
🔄 Grabación en la nube
## Componentes de LiveKit Utilizados
- `LiveKitRoom`: Contenedor principal para la sala de video
- `VideoConference`: Componente de conferencia de video todo-en-uno
- `ParticipantTile`: Miniatura de video de participante individual
- `ControlBar`: Barra de controles personalizable
- `useTracks`: Hook para acceder a tracks de audio/video
## Recursos Adicionales
- [Documentación de LiveKit](https://docs.livekit.io)
- [LiveKit React Components](https://docs.livekit.io/guides/room/client/react/)
- [Ejemplos de código](https://github.com/livekit/livekit-react)
- [LiveKit Cloud](https://cloud.livekit.io)
## Solución de Problemas
### Error: "Failed to connect to room"
- Verifica que la URL del servidor sea correcta
- Asegúrate de que el token sea válido y no haya expirado
- Comprueba tu conexión a internet
### Video/Audio no funciona
- Permite permisos de cámara y micrófono en tu navegador
- Verifica que no haya otra aplicación usando la cámara/micrófono
- Prueba en modo incógnito para descartar extensiones del navegador
### Token expirado
- Los tokens tienen un tiempo de expiración (por defecto 6 horas)
- Genera un nuevo token desde tu panel de LiveKit Cloud
- En producción, implementa renovación automática de tokens
## Contacto y Soporte
Para más información sobre AvanzaCast o problemas con la implementación:
- Revisa la documentación en `/docs`
- Consulta el archivo `ARCHITECTURE.md` para entender la estructura del proyecto

View File

@ -9,11 +9,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.9.15",
"@livekit/components-styles": "^1.1.6",
"livekit-client": "^2.15.14",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"vite": "^7.2.0", "@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^4.0.0" "vite": "^7.2.0"
} }
} }

View File

@ -8,6 +8,7 @@ import Sidebar from './Sidebar'
import Header from './Header' import Header from './Header'
import TransmissionsTable from './TransmissionsTable' import TransmissionsTable from './TransmissionsTable'
import NewTransmissionModal from './NewTransmissionModal' import NewTransmissionModal from './NewTransmissionModal'
import Studio from './Studio'
import type { Transmission } from '../types' import type { Transmission } from '../types'
const STORAGE_KEY = 'broadcast_transmissions' const STORAGE_KEY = 'broadcast_transmissions'
@ -16,6 +17,7 @@ const PageContainer: React.FC = () => {
const [transmissions, setTransmissions] = useState<Transmission[]>([]) const [transmissions, setTransmissions] = useState<Transmission[]>([])
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [currentPage, setCurrentPage] = useState<string>('inicio')
useEffect(() => { useEffect(() => {
// Simular carga de datos // Simular carga de datos
@ -53,10 +55,19 @@ const PageContainer: React.FC = () => {
setTransmissions(prev => prev.map(p => p.id === updated.id ? updated : p)) setTransmissions(prev => prev.map(p => p.id === updated.id ? updated : p))
} }
const handleNavigate = (page: string) => {
setCurrentPage(page)
}
// Renderizar página según navegación
if (currentPage === 'studio') {
return <Studio />
}
return ( return (
<ThemeProvider> <ThemeProvider>
<div className={styles.pageContainer}> <div className={styles.pageContainer}>
<Sidebar activeLink="inicio" /> <Sidebar activeLink={currentPage} onNavigate={handleNavigate} />
<div className={styles.mainContent}> <div className={styles.mainContent}>
<Header /> <Header />
<main className={styles.contentWrapper}> <main className={styles.contentWrapper}>

View File

@ -1,16 +1,25 @@
import React from 'react' import React from 'react'
import { MdHome, MdVideoLibrary, MdLink, MdPeople, MdCardGiftcard, MdSettings, MdAssessment } from 'react-icons/md' import { MdHome, MdVideoLibrary, MdLink, MdPeople, MdCardGiftcard, MdSettings, MdAssessment, MdVideocam } from 'react-icons/md'
import { Tooltip } from './Tooltip' import { Tooltip } from './Tooltip'
import { Logo } from '../../../../shared/components/Logo' import { Logo } from '../../../../shared/components/Logo'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
interface SidebarProps { interface SidebarProps {
activeLink?: string activeLink?: string
onNavigate?: (page: string) => void
} }
const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio' }) => { const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate }) => {
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
if (onNavigate) {
onNavigate(id)
}
}
const navItems = [ const navItems = [
{ id: 'inicio', label: 'Inicio', icon: <MdHome size={20} /> }, { id: 'inicio', label: 'Inicio', icon: <MdHome size={20} /> },
{ id: 'studio', label: 'Studio', icon: <MdVideocam size={20} /> },
{ id: 'biblioteca', label: 'Biblioteca', icon: <MdVideoLibrary size={20} /> }, { id: 'biblioteca', label: 'Biblioteca', icon: <MdVideoLibrary size={20} /> },
{ id: 'destinos', label: 'Destinos', icon: <MdLink size={20} /> }, { id: 'destinos', label: 'Destinos', icon: <MdLink size={20} /> },
{ id: 'miembros', label: 'Miembros', icon: <MdPeople size={20} /> }, { id: 'miembros', label: 'Miembros', icon: <MdPeople size={20} /> },
@ -34,7 +43,11 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio' }) => {
<ul className={styles.navList}> <ul className={styles.navList}>
{navItems.map(item => ( {navItems.map(item => (
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}> <li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
<a href={`#${item.id}`} className={styles.navLink}> <a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span> <span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>
@ -48,7 +61,11 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio' }) => {
<ul className={styles.navList}> <ul className={styles.navList}>
{secondaryNavItems.map(item => ( {secondaryNavItems.map(item => (
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}> <li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
<a href={`#${item.id}`} className={styles.navLink}> <a
href={`#${item.id}`}
className={styles.navLink}
onClick={(e) => handleNavClick(e, item.id)}
>
<span className={styles.navIcon}>{item.icon}</span> <span className={styles.navIcon}>{item.icon}</span>
<span>{item.label}</span> <span>{item.label}</span>
</a> </a>

View File

@ -0,0 +1,479 @@
.studioContainer {
display: flex;
height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
}
.mainContent {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.studioMain {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
background: #1a1d29;
}
/* Formulario de conexión */
.connectionForm {
max-width: 500px;
margin: 60px auto;
padding: 40px;
background: var(--bg-secondary);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.connectionForm h2 {
font-size: 24px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.connectionForm p {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.input {
width: 100%;
padding: 12px 16px;
margin-bottom: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
transition: border-color 0.2s;
}
.input:focus {
outline: none;
border-color: var(--primary-blue);
}
.connectButton {
width: 100%;
padding: 12px 24px;
background: var(--primary-blue);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.connectButton:hover {
background: #1557b0;
}
.connectButton:active {
transform: scale(0.98);
}
.devNote {
margin-top: 24px;
padding: 16px;
background: rgba(26, 115, 232, 0.05);
border-left: 3px solid var(--primary-blue);
border-radius: 4px;
font-size: 13px;
line-height: 1.6;
}
.devNote code {
display: block;
margin: 8px 0;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
font-family: 'Courier New', monospace;
}
.devNote a {
color: var(--primary-blue);
text-decoration: underline;
}
/* Layout del Studio */
.studioLayout {
display: flex;
height: 100%;
overflow: hidden;
}
.studioVideoArea {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-width: 0;
}
.mainVideoContainer {
flex: 1;
background: #0f1117;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
/* LiveKit Room */
.liveKitRoom {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
/* Barra de controles inferior */
.controlBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(26, 29, 41, 0.98);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.08);
min-height: 72px;
}
.controlsLeft {
flex: 1;
display: flex;
align-items: center;
}
.roomName {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.controlsCenter {
display: flex;
align-items: center;
gap: 8px;
}
.controlButton {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 12px;
color: white;
cursor: pointer;
transition: all 0.2s;
}
.controlButton:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.05);
}
.controlButton:active {
transform: scale(0.95);
}
.controlButtonOff {
background: rgba(234, 67, 53, 0.15);
color: #ea4335;
}
.controlButtonOff:hover {
background: rgba(234, 67, 53, 0.25);
}
.controlButtonActive {
background: var(--primary-blue);
}
.controlButtonActive:hover {
background: #1557b0;
}
.controlDivider {
width: 1px;
height: 24px;
background: rgba(255, 255, 255, 0.1);
margin: 0 8px;
}
.recordButton {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 12px;
color: white;
cursor: pointer;
transition: all 0.2s;
}
.recordButton:hover {
background: rgba(255, 255, 255, 0.15);
}
.recordButtonActive {
background: #ea4335;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.controlsRight {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
}
.endButton {
padding: 12px 24px;
background: #ea4335;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.endButton:hover {
background: #d33426;
transform: scale(1.02);
}
.endButton:active {
transform: scale(0.98);
}
/* Sidebar derecho */
.studioSidebar {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebarTabs {
display: flex;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
}
.sidebarTab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.sidebarTab:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.02);
}
.sidebarTabActive {
color: var(--primary-blue);
border-bottom-color: var(--primary-blue);
background: var(--bg-secondary);
}
.sidebarContent {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* Lista de participantes */
.participantsList {
display: flex;
flex-direction: column;
gap: 12px;
}
.participantsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.participantsHeader span {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.inviteButton {
padding: 6px 12px;
background: var(--primary-blue);
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.inviteButton:hover {
background: #1557b0;
}
.participantItem {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-primary);
border-radius: 8px;
transition: background 0.2s;
}
.participantItem:hover {
background: rgba(255, 255, 255, 0.02);
}
.participantAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(26, 115, 232, 0.15);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-blue);
}
.participantInfo {
flex: 1;
}
.participantName {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.participantStatus {
font-size: 12px;
color: var(--text-secondary);
}
.participantMenu {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.participantMenu:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-primary);
}
/* Personalización del tema LiveKit */
:global(.lk-video-conference) {
height: 100%;
background: #1a1d29;
}
:global(.lk-focus-layout) {
background: #1a1d29;
}
:global(.lk-participant-tile) {
border-radius: 12px;
overflow: hidden;
background: #0f1117;
}
:global(.lk-participant-metadata) {
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.7) 100%);
}
/* Responsivo */
@media (max-width: 1024px) {
.studioSidebar {
width: 280px;
}
}
@media (max-width: 768px) {
.studioContainer {
flex-direction: column;
}
.studioSidebar {
position: absolute;
right: 0;
top: 0;
bottom: 72px;
width: 100%;
max-width: 320px;
z-index: 10;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
}
.connectionForm {
margin: 20px;
padding: 24px;
}
.controlBar {
padding: 12px 16px;
}
.controlsLeft,
.controlsRight {
display: none;
}
}

View File

@ -0,0 +1,45 @@
import React, { useEffect } from 'react'
import { ThemeProvider } from './ThemeProvider'
import Sidebar from './Sidebar'
import Header from './Header'
import styles from './Studio.module.css'
const Studio: React.FC = () => {
useEffect(() => {
// Obtener información del usuario desde localStorage o crear temporal
const userName = localStorage.getItem('avanzacast_user') || 'Usuario'
const roomName = 'avanzacast-studio'
// Guardar información para el studio-panel
localStorage.setItem('avanzacast_user', userName)
localStorage.setItem('avanzacast_room', roomName)
// Redirigir al studio-panel (puerto 3001)
const studioUrl = `http://localhost:3001?user=${encodeURIComponent(userName)}&room=${encodeURIComponent(roomName)}`
window.location.href = studioUrl
}, [])
return (
<ThemeProvider>
<div className={styles.studioContainer}>
<Sidebar activeLink="studio" />
<div className={styles.mainContent}>
<Header />
<main className={styles.studioMain}>
<div className={styles.connectionForm}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent mb-4"></div>
<h2 className="text-xl font-semibold">Redirigiendo al Studio...</h2>
<p className="text-gray-500 mt-2">Preparando tu estudio de transmisión</p>
</div>
</div>
</div>
</main>
</div>
</div>
</ThemeProvider>
)
}
export default Studio

View File

@ -0,0 +1,19 @@
node_modules
dist
build
.git
.gitignore
*.log
npm-debug.log*
.DS_Store
coverage
.vscode
.idea
*.md
src
public
index.html
vite.config.ts
tsconfig.json
postcss.config.cjs
tailwind.config.cjs

View File

@ -0,0 +1,4 @@
# LiveKit Configuration
VITE_LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host
VITE_LIVEKIT_API_KEY=devkey
VITE_LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret

View File

@ -0,0 +1,145 @@
# Servidor de Tokens LiveKit - Docker
Este directorio contiene el servidor de tokens LiveKit dockerizado para AvanzaCast.
## 🚀 Inicio Rápido
### Opción 1: Usando Docker Compose (Recomendado)
Desde la raíz del proyecto:
```bash
# Construir e iniciar el servidor
docker-compose up -d livekit-token-server
# Ver logs
docker-compose logs -f livekit-token-server
# Detener el servidor
docker-compose down
```
### Opción 2: Usando Docker directamente
```bash
cd packages/studio-panel
# Construir la imagen
docker build -t avanzacast-token-server .
# Ejecutar el contenedor
docker run -d \
--name avanzacast-token-server \
-p 3010:3010 \
-e LIVEKIT_API_KEY=devkey \
-e LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret \
-e LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host \
avanzacast-token-server
# Ver logs
docker logs -f avanzacast-token-server
# Detener el contenedor
docker stop avanzacast-token-server
docker rm avanzacast-token-server
```
## 🔧 Variables de Entorno
El servidor requiere las siguientes variables de entorno:
- `LIVEKIT_API_KEY`: API Key de LiveKit
- `LIVEKIT_API_SECRET`: Secret de LiveKit
- `LIVEKIT_URL`: URL del servidor LiveKit (wss://...)
- `PORT`: Puerto del servidor (default: 3010)
## 📡 Endpoints
Una vez iniciado, el servidor estará disponible en:
- **Health Check**: `http://localhost:3010/health`
- **Generación de Tokens**: `http://localhost:3010/api/token?room=ROOM_NAME&username=USERNAME`
### Ejemplo de uso:
```bash
# Verificar salud del servidor
curl http://localhost:3010/health
# Generar token
curl "http://localhost:3010/api/token?room=mi-sala&username=usuario1"
```
## 🔍 Monitoreo
### Ver estado del contenedor
```bash
docker ps | grep avanzacast-token-server
```
### Ver logs en tiempo real
```bash
docker logs -f avanzacast-token-server
```
### Verificar health check
```bash
docker inspect --format='{{.State.Health.Status}}' avanzacast-token-server
```
## 🔄 Actualización
Para actualizar el servidor después de cambios en el código:
```bash
# Detener y eliminar el contenedor actual
docker-compose down livekit-token-server
# Reconstruir la imagen
docker-compose build livekit-token-server
# Iniciar nuevamente
docker-compose up -d livekit-token-server
```
## 🐛 Troubleshooting
### El contenedor no inicia
```bash
# Ver logs de error
docker logs avanzacast-token-server
# Verificar que las variables de entorno estén configuradas
docker exec avanzacast-token-server env | grep LIVEKIT
```
### Puerto 3002 ya en uso
```bash
# Verificar qué está usando el puerto
lsof -i :3002
# Detener el proceso que usa el puerto
kill -9 <PID>
```
### Reiniciar el contenedor
```bash
docker restart avanzacast-token-server
```
## 📋 Comandos Útiles
```bash
# Entrar al contenedor
docker exec -it avanzacast-token-server sh
# Ver uso de recursos
docker stats avanzacast-token-server
# Eliminar completamente (contenedor e imagen)
docker-compose down --rmi all
docker rmi avanzacast-token-server
```

View File

@ -0,0 +1,31 @@
# Dockerfile para el servidor de tokens LiveKit
FROM node:20-alpine
# Instalar wget para healthcheck
RUN apk add --no-cache wget
# Establecer directorio de trabajo
WORKDIR /app
# Copiar package.json específico del servidor
COPY server-package.json package.json
# Instalar dependencias
RUN npm install --production
# Copiar el archivo del servidor
COPY server.js ./
# Exponer el puerto 3010
EXPOSE 3010
# Variables de entorno por defecto
ENV NODE_ENV=production
ENV PORT=3010
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3010/health || exit 1
# Comando para iniciar el servidor
CMD ["node", "server.js"]

View File

@ -6,15 +6,31 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 3001", "dev": "vite --port 3001",
"dev:vite": "vite --port 3001",
"dev:server": "node server.js",
"dev:full": "concurrently \"npm run dev:vite\" \"npm run dev:server\"",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 3001", "preview": "vite preview --port 3001",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"docker:build": "docker build -t avanzacast-token-server .",
"docker:run": "docker run -d --name avanzacast-token-server -p 3002:3002 --env-file ../../.env avanzacast-token-server",
"docker:stop": "docker stop avanzacast-token-server && docker rm avanzacast-token-server",
"docker:logs": "docker logs -f avanzacast-token-server"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@livekit/components-react": "^2.9.15",
"@livekit/components-styles": "^1.1.6",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"livekit-client": "^2.15.14",
"livekit-server-sdk": "^2.14.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"socket.io-client": "^4.6.2", "socket.io-client": "^4.6.2",
"zustand": "^4.4.7" "zustand": "^4.4.7"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 231 KiB

View File

@ -0,0 +1,16 @@
{
"name": "livekit-token-server",
"version": "1.0.0",
"description": "Servidor de generación de tokens JWT para LiveKit",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0",
"cors": "^2.8.5",
"livekit-server-sdk": "^2.14.0",
"dotenv": "^17.2.3"
}
}

View File

@ -0,0 +1,105 @@
import express from 'express'
import cors from 'cors'
import { AccessToken } from 'livekit-server-sdk'
import * as dotenv from 'dotenv'
// Cargar variables de entorno
dotenv.config({ path: '../../.env' })
const app = express()
const port = 3010
// Middleware
app.use(cors())
app.use(express.json())
// Configuración de LiveKit desde variables de entorno
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey'
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret'
const LIVEKIT_URL = process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
console.log('🚀 Servidor de tokens iniciado')
console.log('📡 LiveKit URL:', LIVEKIT_URL)
console.log('🔑 API Key:', LIVEKIT_API_KEY)
// Endpoint para generar tokens
app.get('/api/token', async (req, res) => {
try {
const { room, username } = req.query
if (!room || !username) {
return res.status(400).json({
error: 'Se requieren los parámetros room y username'
})
}
// Crear token de acceso
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: username,
name: username,
})
// Agregar permisos
at.addGrant({
room: room,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
})
// Generar JWT
const token = await at.toJwt()
console.log(`✅ Token generado para usuario: ${username} en sala: ${room}`)
res.json({
token,
serverUrl: LIVEKIT_URL,
})
} catch (error) {
console.error('❌ Error generando token:', error)
res.status(500).json({
error: 'Error al generar token',
details: error.message
})
}
})
// Endpoint de salud
app.get('/health', (req, res) => {
res.json({
status: 'ok',
livekit: {
url: LIVEKIT_URL,
apiKey: LIVEKIT_API_KEY,
},
})
})
const server = app.listen(port, () => {
console.log(`🎙️ Servidor corriendo en http://localhost:${port}`)
console.log(`📋 Endpoint de tokens: http://localhost:${port}/api/token?room=ROOM_NAME&username=USERNAME`)
console.log(`💚 Health check: http://localhost:${port}/health`)
})
server.on('error', (error) => {
console.error('❌ Error del servidor:', error)
process.exit(1)
})
process.on('SIGTERM', () => {
console.log('🛑 SIGTERM recibido, cerrando servidor...')
server.close(() => {
console.log('✅ Servidor cerrado')
process.exit(0)
})
})
process.on('SIGINT', () => {
console.log('🛑 SIGINT recibido, cerrando servidor...')
server.close(() => {
console.log('✅ Servidor cerrado')
process.exit(0)
})
})

View File

@ -1,39 +1,42 @@
import React from 'react'; import { useEffect, useState } from 'react'
import Studio from './components/Studio'
function App() { function App() {
return ( const [userName, setUserName] = useState<string>('')
<div className="min-h-screen bg-gray-900 text-white"> const [roomName, setRoomName] = useState<string>('avanzacast-studio')
<header className="bg-gray-800 border-b border-gray-700 px-6 py-4"> const [loading, setLoading] = useState(true)
<h1 className="text-2xl font-bold">AvanzaCast Studio</h1>
<p className="text-sm text-gray-400">Estudio de Transmisión en Vivo</p>
</header>
<main className="p-6"> useEffect(() => {
<div className="max-w-7xl mx-auto"> // Obtener información del usuario desde localStorage o URL params
<div className="bg-gray-800 rounded-lg p-8 text-center"> // Esta información será establecida desde broadcast-panel
<h2 className="text-3xl font-bold mb-4">🎥 Estudio Virtual</h2> const params = new URLSearchParams(window.location.search)
<p className="text-gray-300 mb-6"> const userFromParams = params.get('user')
El módulo de broadcast studio estará disponible próximamente. const roomFromParams = params.get('room')
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-8"> const userFromStorage = localStorage.getItem('avanzacast_user')
<div className="bg-gray-700 p-4 rounded"> const roomFromStorage = localStorage.getItem('avanzacast_room')
<h3 className="font-bold mb-2">📹 WebRTC</h3>
<p className="text-sm text-gray-400">Transmisión en tiempo real</p> setUserName(userFromParams || userFromStorage || 'Demo User')
</div> setRoomName(roomFromParams || roomFromStorage || 'avanzacast-studio')
<div className="bg-gray-700 p-4 rounded">
<h3 className="font-bold mb-2">🎬 Escenas</h3> // Dar un pequeño delay para mostrar el loading
<p className="text-sm text-gray-400">Control de overlays y cámaras</p> setTimeout(() => setLoading(false), 500)
</div> }, [])
<div className="bg-gray-700 p-4 rounded">
<h3 className="font-bold mb-2">📡 Multistream</h3> // Mostrar pantalla de carga mientras se obtiene la información
<p className="text-sm text-gray-400">YouTube, Facebook, Twitch</p> if (loading) {
</div> return (
</div> <div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center">
</div> <div className="text-center">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-pink-500 border-r-transparent mb-4"></div>
<h2 className="text-xl font-semibold text-white">Cargando Studio...</h2>
<p className="text-gray-400 mt-2">Conectando con AvanzaCast</p>
</div> </div>
</main> </div>
</div> )
); }
return <Studio userName={userName} roomName={roomName} />
} }
export default App; export default App

View File

@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'
import '@livekit/components-styles'
import StudioHeader from './StudioHeader'
import StudioLeftSidebar from './StudioLeftSidebar'
import StudioVideoArea from './StudioVideoArea'
import StudioRightPanel from './StudioRightPanel'
import StudioControls from './StudioControls'
import { DEMO_MODE, DEMO_TOKEN } from '../config/demo'
interface StudioProps {
userName: string
roomName: string
}
const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
const [token, setToken] = useState<string>(DEMO_TOKEN)
const [isConnecting, setIsConnecting] = useState(true)
const [error, setError] = useState<string>('')
const [isDemoMode, setIsDemoMode] = useState(true) // Iniciar en modo demo por defecto
useEffect(() => {
const fetchToken = async () => {
try {
const response = await fetch(`http://localhost:3010/api/token?room=${roomName}&username=${userName}`, {
signal: AbortSignal.timeout(2000) // Timeout de 2 segundos
})
if (!response.ok) {
throw new Error('Error al obtener el token')
}
const data = await response.json()
setToken(data.token)
setIsDemoMode(false)
} catch (err) {
console.error('Error getting token:', err)
// Mantener modo demo si no se puede conectar al servidor
console.log('⚠️ No se pudo conectar al servidor de tokens. Usando modo DEMO...')
setToken(DEMO_TOKEN)
setIsDemoMode(true)
setError('')
} finally {
setIsConnecting(false)
}
}
fetchToken()
}, [roomName, userName])
if (isConnecting) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-pink-500 mx-auto mb-4"></div>
<p className="text-white text-lg">Conectando al estudio...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="bg-red-900/20 border border-red-500 rounded-lg p-6 max-w-md">
<div className="flex items-center mb-4">
<svg className="w-6 h-6 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-red-400 font-semibold">Error de conexión</h3>
</div>
<p className="text-red-300 text-sm mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="w-full py-2 px-4 bg-red-600 hover:bg-red-700 text-white rounded-lg transition"
>
Reintentar
</button>
</div>
</div>
)
}
if (!token) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<p className="text-white">No se pudo obtener el token de acceso</p>
</div>
)
}
const serverUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
// Renderizar interfaz en modo demo (sin LiveKit)
if (isDemoMode) {
return (
<div className="flex flex-col h-screen bg-gray-900">
{/* Banner de modo demo */}
<div className="bg-yellow-600 text-black px-4 py-2 text-center text-sm font-semibold">
MODO DEMO - Servidor de tokens no disponible. Funcionalidad limitada.
</div>
{/* Header superior */}
<StudioHeader roomName={roomName} userName={userName} />
{/* Contenido principal */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar izquierdo - Escenas */}
<StudioLeftSidebar />
{/* Área central de video */}
<StudioVideoArea isDemoMode={true} />
{/* Panel derecho - Ajustes */}
<StudioRightPanel />
</div>
{/* Controles inferiores */}
<StudioControls />
</div>
)
}
// Renderizar con LiveKit (modo normal)
return (
<LiveKitRoom
video={true}
audio={true}
token={token}
serverUrl={serverUrl}
data-lk-theme="default"
className="studio-container"
>
<div className="flex flex-col h-screen bg-gray-900">
{/* Header superior */}
<StudioHeader roomName={roomName} userName={userName} />
{/* Contenido principal */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar izquierdo - Escenas */}
<StudioLeftSidebar />
{/* Área central de video */}
<StudioVideoArea isDemoMode={false} />
{/* Panel derecho - Ajustes */}
<StudioRightPanel />
</div>
{/* Controles inferiores */}
<StudioControls />
</div>
{/* Renderizador de audio de la sala */}
<RoomAudioRenderer />
</LiveKitRoom>
)
}
export default Studio

View File

@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react'
import { useLocalParticipant, useTracks } from '@livekit/components-react'
import { Track } from 'livekit-client'
import {
MdMic,
MdMicOff,
MdVideocam,
MdVideocamOff,
MdScreenShare,
MdStopScreenShare,
MdSettings,
MdPeople,
MdViewComfy,
MdFiberManualRecord,
MdStop,
} from 'react-icons/md'
const StudioControls: React.FC = () => {
const { localParticipant } = useLocalParticipant()
const [micEnabled, setMicEnabled] = useState(true)
const [cameraEnabled, setCameraEnabled] = useState(true)
const [isScreenSharing, setIsScreenSharing] = useState(false)
const [isRecording, setIsRecording] = useState(false)
// Sincronizar estado con LiveKit
useEffect(() => {
if (localParticipant) {
setMicEnabled(localParticipant.isMicrophoneEnabled)
setCameraEnabled(localParticipant.isCameraEnabled)
setIsScreenSharing(localParticipant.isScreenShareEnabled)
}
}, [localParticipant])
const toggleMicrophone = async () => {
if (localParticipant) {
const enabled = !micEnabled
await localParticipant.setMicrophoneEnabled(enabled)
setMicEnabled(enabled)
}
}
const toggleCamera = async () => {
if (localParticipant) {
const enabled = !cameraEnabled
await localParticipant.setCameraEnabled(enabled)
setCameraEnabled(enabled)
}
}
const toggleScreenShare = async () => {
if (localParticipant) {
const enabled = !isScreenSharing
await localParticipant.setScreenShareEnabled(enabled)
setIsScreenSharing(enabled)
}
}
const toggleRecording = () => {
// TODO: Implementar grabación con LiveKit
setIsRecording(!isRecording)
console.log('Recording:', !isRecording)
}
return (
<div className="studio-controls bg-gray-800 border-t border-gray-700 px-6 py-4">
<div className="flex items-center justify-between max-w-7xl mx-auto">
{/* Izquierda - Info de sala */}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-300 font-medium">
Transmisión en vivo
</span>
{isRecording && (
<div className="flex items-center space-x-2">
<div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-sm text-red-400">Grabando</span>
</div>
)}
</div>
{/* Centro - Controles principales */}
<div className="flex items-center space-x-2">
{/* Micrófono */}
<button
onClick={toggleMicrophone}
className={`control-button ${
!micEnabled ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
} p-3 rounded-lg transition-colors`}
title={micEnabled ? 'Desactivar micrófono' : 'Activar micrófono'}
>
{micEnabled ? <MdMic size={24} /> : <MdMicOff size={24} />}
</button>
{/* Cámara */}
<button
onClick={toggleCamera}
className={`control-button ${
!cameraEnabled ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
} p-3 rounded-lg transition-colors`}
title={cameraEnabled ? 'Desactivar cámara' : 'Activar cámara'}
>
{cameraEnabled ? <MdVideocam size={24} /> : <MdVideocamOff size={24} />}
</button>
{/* Compartir pantalla */}
<button
onClick={toggleScreenShare}
className={`control-button ${
isScreenSharing ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-700 hover:bg-gray-600'
} p-3 rounded-lg transition-colors`}
title={isScreenSharing ? 'Dejar de compartir' : 'Compartir pantalla'}
>
{isScreenSharing ? <MdStopScreenShare size={24} /> : <MdScreenShare size={24} />}
</button>
<div className="w-px h-8 bg-gray-600 mx-2"></div>
{/* Layouts */}
<button
className="control-button bg-gray-700 hover:bg-gray-600 p-3 rounded-lg transition-colors"
title="Cambiar diseño"
>
<MdViewComfy size={24} />
</button>
{/* Configuración */}
<button
className="control-button bg-gray-700 hover:bg-gray-600 p-3 rounded-lg transition-colors"
title="Configuración"
>
<MdSettings size={24} />
</button>
<div className="w-px h-8 bg-gray-600 mx-2"></div>
{/* Grabar */}
<button
onClick={toggleRecording}
className={`control-button ${
isRecording ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
} p-3 rounded-lg transition-colors`}
title={isRecording ? 'Detener grabación' : 'Grabar'}
>
{isRecording ? <MdStop size={24} /> : <MdFiberManualRecord size={24} />}
</button>
</div>
{/* Derecha - Salir */}
<div>
<button
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
onClick={() => {
if (confirm('¿Estás seguro de que quieres salir del estudio?')) {
window.location.href = '/'
}
}}
>
Salir
</button>
</div>
</div>
</div>
)
}
export default StudioControls

View File

@ -0,0 +1,103 @@
import { useState } from 'react'
import { MdNotifications, MdSettings, MdExitToApp } from 'react-icons/md'
interface StudioHeaderProps {
roomName: string
userName: string
}
const StudioHeader: React.FC<StudioHeaderProps> = ({ roomName, userName }) => {
const [showUserMenu, setShowUserMenu] = useState(false)
const [isLive, setIsLive] = useState(false)
return (
<header className="bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center justify-between">
{/* Logo e Info */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-gradient-to-br from-pink-500 to-red-500 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-lg">A</span>
</div>
<div>
<h1 className="text-base font-bold text-white">
Avanza<span className="font-extrabold">Cast</span>
</h1>
<p className="text-xs text-gray-400">Studio</p>
</div>
</div>
{/* Badge BETA */}
<span className="bg-pink-500/20 text-pink-400 text-xs font-semibold px-2 py-0.5 rounded">
BETA
</span>
</div>
{/* Título de transmisión y estado */}
<div className="flex-1 text-center">
<h2 className="text-sm font-medium text-gray-300">{roomName}</h2>
{isLive && (
<div className="flex items-center justify-center gap-2 mt-1">
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<span className="text-xs text-red-400 font-semibold">EN VIVO</span>
</div>
)}
</div>
{/* Acciones y usuario */}
<div className="flex items-center gap-2">
{/* Botón Go Live */}
<button
onClick={() => setIsLive(!isLive)}
className={`px-4 py-1.5 rounded-lg font-semibold text-sm transition-all transform hover:scale-105 ${
isLive
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-gradient-to-r from-pink-500 to-red-500 hover:from-pink-600 hover:to-red-600 text-white'
}`}
>
{isLive ? 'Detener' : 'Salir en Vivo'}
</button>
{/* Notificaciones */}
<button className="p-2 hover:bg-gray-700 rounded-lg transition-colors relative">
<MdNotifications size={20} className="text-gray-300" />
<span className="absolute top-1 right-1 w-2 h-2 bg-pink-500 rounded-full"></span>
</button>
{/* Usuario */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 hover:bg-gray-700 px-3 py-1.5 rounded-lg transition-colors"
>
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-semibold">
{userName.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm text-gray-300">{userName}</span>
</button>
{/* Menu dropdown */}
{showUserMenu && (
<div className="absolute right-0 top-full mt-2 w-48 bg-gray-700 rounded-lg shadow-lg border border-gray-600 py-1 z-50">
<button className="w-full px-4 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2">
<MdSettings size={16} />
Configuración
</button>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-gray-600 flex items-center gap-2"
>
<MdExitToApp size={16} />
Salir del Studio
</button>
</div>
)}
</div>
</div>
</header>
)
}
export default StudioHeader

View File

@ -0,0 +1,179 @@
import { useState } from 'react'
import { MdAdd, MdMoreVert, MdEdit, MdDelete, MdContentCopy } from 'react-icons/md'
import { DEMO_SCENES } from '../config/demo'
interface Scene {
id: string
name: string
thumbnail: string
active: boolean
}
const StudioLeftSidebar = () => {
const [scenes, setScenes] = useState<Scene[]>(DEMO_SCENES)
const [openMenuId, setOpenMenuId] = useState<string | null>(null)
const handleSceneClick = (sceneId: string) => {
setScenes(scenes.map(scene => ({
...scene,
active: scene.id === sceneId
})))
}
const handleAddScene = () => {
const newScene: Scene = {
id: Date.now().toString(),
name: `Nueva Escena ${scenes.length + 1}`,
thumbnail: '/placeholder-scene.jpg',
active: false
}
setScenes([...scenes, newScene])
}
const handleDeleteScene = (sceneId: string) => {
if (scenes.length > 1) {
setScenes(scenes.filter(scene => scene.id !== sceneId))
}
setOpenMenuId(null)
}
const handleDuplicateScene = (sceneId: string) => {
const sceneToDuplicate = scenes.find(scene => scene.id === sceneId)
if (sceneToDuplicate) {
const newScene: Scene = {
...sceneToDuplicate,
id: Date.now().toString(),
name: `${sceneToDuplicate.name} (copia)`,
active: false
}
setScenes([...scenes, newScene])
}
setOpenMenuId(null)
}
return (
<div className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
{/* Header de Escenas */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
Escenas
</h3>
</div>
{/* Lista de Escenas */}
<div className="flex-1 overflow-y-auto p-2">
{scenes.map((scene) => (
<div
key={scene.id}
className={`group relative flex items-center gap-3 p-2 rounded-lg mb-2 cursor-pointer transition-all ${
scene.active
? 'bg-pink-500/20 border border-pink-500/50'
: 'bg-gray-700/50 hover:bg-gray-700 border border-transparent'
}`}
onClick={() => handleSceneClick(scene.id)}
>
{/* Thumbnail */}
<div className={`w-16 h-10 rounded overflow-hidden flex-shrink-0 ${
scene.active ? 'ring-2 ring-pink-500' : ''
}`}>
<div className="w-full h-full bg-gray-600 flex items-center justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
</div>
{/* Nombre */}
<span className={`flex-1 text-sm truncate ${
scene.active ? 'text-white font-medium' : 'text-gray-300'
}`}>
{scene.name}
</span>
{/* Menú de opciones */}
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation()
setOpenMenuId(openMenuId === scene.id ? null : scene.id)
}}
className="p-1 rounded hover:bg-gray-600 text-gray-400 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
>
<MdMoreVert size={18} />
</button>
{/* Dropdown menu */}
{openMenuId === scene.id && (
<div className="absolute right-0 top-full mt-1 w-40 bg-gray-700 rounded-lg shadow-lg border border-gray-600 py-1 z-50">
<button
onClick={(e) => {
e.stopPropagation()
handleDuplicateScene(scene.id)
}}
className="w-full px-3 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2"
>
<MdContentCopy size={16} />
Duplicar
</button>
<button
onClick={(e) => {
e.stopPropagation()
// TODO: Implementar edición
setOpenMenuId(null)
}}
className="w-full px-3 py-2 text-left text-sm text-gray-300 hover:bg-gray-600 flex items-center gap-2"
>
<MdEdit size={16} />
Renombrar
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleDeleteScene(scene.id)
}}
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-gray-600 flex items-center gap-2"
disabled={scenes.length === 1}
>
<MdDelete size={16} />
Eliminar
</button>
</div>
)}
</div>
</div>
))}
{/* Botón agregar escena */}
<button
onClick={handleAddScene}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed border-gray-600 text-gray-400 hover:border-pink-500 hover:text-pink-500 transition-all mt-2"
>
<MdAdd size={20} />
<span className="text-sm font-medium">Nueva Escena</span>
</button>
</div>
{/* Acciones rápidas */}
<div className="p-3 border-t border-gray-700 space-y-2">
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Agregar Elemento
</button>
<button className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700 rounded-lg transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Añadir Multimedia
</button>
</div>
</div>
)
}
export default StudioLeftSidebar

View File

@ -0,0 +1,443 @@
import { useState } from 'react'
import {
MdBrush,
MdImage,
MdMusicNote,
MdVideoLibrary,
MdQrCode,
MdTimer,
MdSettings,
MdClose
} from 'react-icons/md'
import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo'
type TabType = 'brand' | 'multimedia' | 'sounds' | 'video' | 'qr' | 'countdown' | 'settings'
const StudioRightPanel = () => {
const [activeTab, setActiveTab] = useState<TabType>('brand')
const [isCollapsed, setIsCollapsed] = useState(false)
const tabs = [
{ id: 'brand' as TabType, icon: MdBrush, label: 'Marca' },
{ id: 'multimedia' as TabType, icon: MdImage, label: 'Multimedia' },
{ id: 'sounds' as TabType, icon: MdMusicNote, label: 'Sonidos' },
{ id: 'video' as TabType, icon: MdVideoLibrary, label: 'Videos' },
{ id: 'qr' as TabType, icon: MdQrCode, label: 'QR' },
{ id: 'countdown' as TabType, icon: MdTimer, label: 'Cuenta regresiva' },
{ id: 'settings' as TabType, icon: MdSettings, label: 'Ajustes' },
]
if (isCollapsed) {
return (
<div className="w-12 bg-gray-800 border-l border-gray-700 flex flex-col items-center py-4 gap-2">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => {
setActiveTab(tab.id)
setIsCollapsed(false)
}}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 transition-colors"
title={tab.label}
>
<Icon size={20} />
</button>
)
})}
</div>
)
}
return (
<div className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col">
{/* Header con tabs */}
<div className="border-b border-gray-700">
<div className="flex items-center justify-between px-3 py-2">
<h3 className="text-white font-semibold text-sm">Configuración</h3>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors"
>
<MdClose size={18} />
</button>
</div>
{/* Tabs */}
<div className="flex overflow-x-auto scrollbar-thin scrollbar-thumb-gray-700">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.id
? 'text-pink-500 border-pink-500'
: 'text-gray-400 border-transparent hover:text-white'
}`}
>
<Icon size={16} />
<span className="hidden xl:inline">{tab.label}</span>
</button>
)
})}
</div>
</div>
{/* Contenido de tabs */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'brand' && <BrandTab />}
{activeTab === 'multimedia' && <MultimediaTab />}
{activeTab === 'sounds' && <SoundsTab />}
{activeTab === 'video' && <VideoTab />}
{activeTab === 'qr' && <QRTab />}
{activeTab === 'countdown' && <CountdownTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
</div>
)
}
// Tab de Marca
const BrandTab = () => {
const [selectedTheme, setSelectedTheme] = useState(COLOR_THEMES[0])
const [logoUrl, setLogoUrl] = useState<string | null>(null)
const [logoPosition, setLogoPosition] = useState('top-right')
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onloadend = () => {
setLogoUrl(reader.result as string)
}
reader.readAsDataURL(file)
}
}
const handleRemoveLogo = () => {
setLogoUrl(null)
}
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Tema de Color</h4>
<div className="grid grid-cols-3 gap-2">
{COLOR_THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => setSelectedTheme(theme)}
className={`px-3 py-2 rounded-lg border-2 transition-all ${
selectedTheme.id === theme.id
? 'border-white bg-gray-700'
: 'border-gray-700 hover:border-gray-500'
}`}
style={{
backgroundColor: theme.primary + '20',
borderColor: selectedTheme.id === theme.id ? theme.primary : undefined
}}
>
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: theme.primary }}
/>
<span className="text-white text-xs font-medium">{theme.name}</span>
</div>
</button>
))}
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Logo</h4>
{logoUrl ? (
<div className="relative border-2 border-gray-700 rounded-lg p-4 bg-gray-700/50">
<img
src={logoUrl}
alt="Logo"
className="max-h-24 mx-auto object-contain"
/>
<button
onClick={handleRemoveLogo}
className="absolute top-2 right-2 bg-red-500 hover:bg-red-600 text-white p-1.5 rounded-full transition-colors"
title="Eliminar logo"
>
<MdClose size={16} />
</button>
<p className="text-gray-400 text-xs text-center mt-2">Logo cargado</p>
</div>
) : (
<label className="block border-2 border-dashed border-gray-700 rounded-lg p-8 text-center hover:border-pink-500 transition-colors cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<MdImage size={32} className="mx-auto text-gray-500 mb-2" />
<p className="text-gray-400 text-xs">Haz clic para subir logo</p>
<p className="text-gray-500 text-xs mt-1">PNG, JPG, SVG (máx. 2MB)</p>
</label>
)}
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Posición del Logo</h4>
<div className="grid grid-cols-3 gap-2">
{[
{ pos: 'top-left', label: 'Superior Izq.' },
{ pos: 'top-center', label: 'Superior Centro' },
{ pos: 'top-right', label: 'Superior Der.' },
{ pos: 'center-left', label: 'Centro Izq.' },
{ pos: 'center', label: 'Centro' },
{ pos: 'center-right', label: 'Centro Der.' },
{ pos: 'bottom-left', label: 'Inferior Izq.' },
{ pos: 'bottom-center', label: 'Inferior Centro' },
{ pos: 'bottom-right', label: 'Inferior Der.' }
].map((item) => (
<button
key={item.pos}
onClick={() => setLogoPosition(item.pos)}
className={`aspect-square rounded-lg transition-all flex items-center justify-center ${
logoPosition === item.pos
? 'bg-pink-500/30 border-2 border-pink-500'
: 'bg-gray-700 border-2 border-transparent hover:bg-gray-600'
}`}
title={item.label}
>
<div className={`w-2 h-2 rounded-full ${
logoPosition === item.pos ? 'bg-pink-500' : 'bg-gray-400'
}`} />
</button>
))}
</div>
{logoUrl && (
<p className="text-gray-400 text-xs mt-2 text-center">
Posición: {logoPosition.replace('-', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</p>
)}
</div>
</div>
)
}
// Tab de Multimedia
const MultimediaTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Fondos</h4>
<div className="grid grid-cols-2 gap-2">
{DEMO_BACKGROUNDS.map((bg) => (
<button
key={bg.id}
className="aspect-video rounded-lg hover:ring-2 hover:ring-pink-500 transition cursor-pointer overflow-hidden group relative"
style={{ background: bg.gradient }}
>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<span className="text-white text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{bg.name}
</span>
</div>
</button>
))}
<button className="aspect-video border-2 border-dashed border-gray-700 rounded-lg flex flex-col items-center justify-center gap-1 hover:border-pink-500 transition">
<MdImage size={24} className="text-gray-500" />
<span className="text-gray-500 text-xs">Subir fondo</span>
</button>
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Overlays</h4>
<div className="space-y-2">
{DEMO_OVERLAYS.map((overlay) => (
<button
key={overlay.id}
className="w-full p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-left group"
>
<div className="flex items-center justify-between">
<div>
<p className="text-white text-sm font-medium">{overlay.name}</p>
<p className="text-gray-400 text-xs">{overlay.type}</p>
</div>
<div className="w-8 h-8 bg-pink-500/20 rounded flex items-center justify-center">
<MdImage className="text-pink-500" size={16} />
</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}
// Tab de Sonidos
const SoundsTab = () => {
const [volumes, setVolumes] = useState<Record<string, number>>(
DEMO_SOUNDS.reduce((acc, sound) => ({ ...acc, [sound.id]: 50 }), {})
)
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Efectos de Sonido</h4>
<div className="space-y-2">
{DEMO_SOUNDS.map((sound) => (
<div key={sound.id} className="bg-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<button className="flex items-center gap-2 text-white hover:text-pink-500 transition-colors">
<div className="w-8 h-8 bg-pink-500/20 rounded-full flex items-center justify-center">
<MdMusicNote className="text-pink-500" size={16} />
</div>
<span className="text-sm font-medium">{sound.name}</span>
</button>
<span className="text-gray-400 text-xs">{volumes[sound.id]}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={volumes[sound.id]}
onChange={(e) => setVolumes({ ...volumes, [sound.id]: parseInt(e.target.value) })}
className="w-full h-1.5 bg-gray-600 rounded-lg appearance-none cursor-pointer"
/>
</div>
))}
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Música de Fondo</h4>
<div className="border-2 border-dashed border-gray-700 rounded-lg p-6 text-center hover:border-pink-500 transition-colors cursor-pointer">
<MdMusicNote size={28} className="mx-auto text-gray-500 mb-2" />
<p className="text-gray-400 text-xs">Haz clic para agregar música</p>
</div>
</div>
</div>
)
}
// Tab de Videos
const VideoTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Clips de Video</h4>
<div className="grid grid-cols-2 gap-2">
{['Intro', 'Outro', 'Transición'].map((clip) => (
<div key={clip} className="aspect-video bg-gray-700 rounded-lg hover:ring-2 hover:ring-pink-500 transition cursor-pointer flex items-center justify-center">
<div className="text-center">
<MdVideoLibrary size={24} className="mx-auto text-gray-500 mb-1" />
<p className="text-gray-400 text-xs">{clip}</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// Tab de QR
const QRTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Generar Código QR</h4>
<input
type="text"
placeholder="Ingresa URL"
className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none"
/>
<button className="w-full mt-2 bg-pink-500 hover:bg-pink-600 text-white py-2 px-3 rounded-lg text-sm font-medium transition-colors">
Generar QR
</button>
</div>
<div className="bg-gray-700 rounded-lg p-4 aspect-square flex items-center justify-center">
<p className="text-gray-400 text-sm text-center">El código QR aparecerá aquí</p>
</div>
</div>
)
}
// Tab de Cuenta Regresiva
const CountdownTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Temporizadores Rápidos</h4>
<div className="grid grid-cols-3 gap-2">
{['30s', '1m', '5m', '10m', '15m', '30m'].map((time) => (
<button
key={time}
className="bg-gray-700 hover:bg-pink-500 text-white py-2 px-3 rounded-lg text-sm font-medium transition-colors"
>
{time}
</button>
))}
</div>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Personalizado</h4>
<input
type="number"
placeholder="Minutos"
className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none"
/>
</div>
</div>
)
}
// Tab de Ajustes
const SettingsTab = () => {
return (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Calidad de Video</h4>
<select className="w-full bg-gray-700 text-white py-2 px-3 rounded-lg text-sm border border-gray-600 focus:border-pink-500 focus:outline-none">
<option>1080p (Full HD)</option>
<option>720p (HD)</option>
<option>480p (SD)</option>
</select>
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-3">Bitrate</h4>
<input
type="range"
min="1000"
max="6000"
step="500"
className="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
/>
<p className="text-gray-400 text-xs mt-2">3500 kbps</p>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Grabar automáticamente</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Mostrar chat en vivo</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" defaultChecked />
</label>
<label className="flex items-center justify-between">
<span className="text-gray-300 text-sm">Habilitar aplausos</span>
<input type="checkbox" className="form-checkbox h-4 w-4 text-pink-500 rounded" defaultChecked />
</label>
</div>
</div>
)
}
export default StudioRightPanel

View File

@ -0,0 +1,186 @@
import React, { useState } from 'react'
import { useParticipants } from '@livekit/components-react'
import { MdPeople, MdChat, MdNoteAdd, MdMoreVert, MdClose } from 'react-icons/md'
interface StudioSidebarProps {
onClose: () => void
}
const StudioSidebar: React.FC<StudioSidebarProps> = ({ onClose }) => {
const [activeTab, setActiveTab] = useState<'people' | 'chat' | 'notes'>('people')
const participants = useParticipants()
return (
<aside className="studio-sidebar w-80 bg-gray-800 border-l border-gray-700 flex flex-col">
{/* Tabs */}
<div className="flex border-b border-gray-700">
<button
onClick={() => setActiveTab('people')}
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
activeTab === 'people'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<MdPeople size={20} />
<span>Personas</span>
</button>
<button
onClick={() => setActiveTab('chat')}
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
activeTab === 'chat'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<MdChat size={20} />
<span>Chat</span>
</button>
<button
onClick={() => setActiveTab('notes')}
className={`flex-1 flex items-center justify-center space-x-2 py-3 text-sm font-medium transition-colors ${
activeTab === 'notes'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<MdNoteAdd size={20} />
<span>Notas</span>
</button>
<button
onClick={onClose}
className="px-3 text-gray-400 hover:text-white transition-colors"
title="Cerrar panel"
>
<MdClose size={20} />
</button>
</div>
{/* Contenido */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'people' && <PeopleTab participants={participants} />}
{activeTab === 'chat' && <ChatTab />}
{activeTab === 'notes' && <NotesTab />}
</div>
</aside>
)
}
const PeopleTab: React.FC<{ participants: any[] }> = ({ participants }) => {
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">
{participants.length} en el studio
</h3>
<button className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors">
Invitar
</button>
</div>
<div className="space-y-2">
{participants.map((participant) => (
<div
key={participant.sid}
className="flex items-center space-x-3 p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
>
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
<MdPeople size={20} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{participant.identity}
</p>
<p className="text-xs text-gray-400">
{participant.isLocal ? 'Tú (Presentador)' : 'Invitado'}
</p>
</div>
<button className="p-1 hover:bg-gray-500 rounded transition-colors">
<MdMoreVert size={20} className="text-gray-400" />
</button>
</div>
))}
</div>
</div>
)
}
const ChatTab: React.FC = () => {
const [message, setMessage] = useState('')
const [messages, setMessages] = useState<Array<{ user: string; text: string; time: string }>>([])
const sendMessage = () => {
if (message.trim()) {
setMessages([
...messages,
{
user: 'Tú',
text: message,
time: new Date().toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' }),
},
])
setMessage('')
}
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 space-y-3 mb-4">
{messages.length === 0 ? (
<div className="text-center text-gray-400 text-sm mt-8">
<MdChat size={48} className="mx-auto mb-2 opacity-50" />
<p>No hay mensajes aún</p>
<p className="text-xs mt-1">Inicia la conversación</p>
</div>
) : (
messages.map((msg, idx) => (
<div key={idx} className="bg-gray-700 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">{msg.user}</span>
<span className="text-xs text-gray-400">{msg.time}</span>
</div>
<p className="text-sm text-gray-300">{msg.text}</p>
</div>
))
)}
</div>
<div className="flex space-x-2">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Escribe un mensaje..."
className="flex-1 px-3 py-2 bg-gray-700 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={sendMessage}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
Enviar
</button>
</div>
</div>
)
}
const NotesTab: React.FC = () => {
const [notes, setNotes] = useState('')
return (
<div className="h-full">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Escribe tus notas aquí..."
className="w-full h-full p-3 bg-gray-700 text-white rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)
}
export default StudioSidebar

View File

@ -0,0 +1,30 @@
import { useParticipants, ParticipantTile } from '@livekit/components-react'
import { MdAdd, MdPerson, MdMic, MdMicOff, MdVideocamOff } from 'react-icons/md'
import { DEMO_PARTICIPANTS } from '../config/demo'
interface StudioVideoAreaProps {
isDemoMode?: boolean
}
const DemoParticipantTile: React.FC<{ participant: typeof DEMO_PARTICIPANTS[0] }> = ({ participant }) => (
<div className="relative w-full h-full bg-gray-800 rounded-lg overflow-hidden flex items-center justify-center">
{participant.isCameraEnabled ? <div className="w-20 h-20 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white text-3xl font-bold">{participant.name.charAt(0).toUpperCase()}</div> : <MdVideocamOff className="text-gray-600" size={40} />}
<div className="absolute bottom-2 left-2">{participant.isMicrophoneEnabled ? <MdMic className="text-white" size={14} /> : <MdMicOff className="text-white" size={14} />}</div>
</div>
)
const DemoModeView: React.FC = () => (
<div className="flex-1 flex flex-col bg-gray-950">
<div className="flex-1 p-4"><div className="w-full h-full grid gap-2 grid-cols-2">{DEMO_PARTICIPANTS.map(p => <DemoParticipantTile key={p.id} participant={p} />)}</div></div>
<div className="bg-gray-900 p-4"><button className="w-full bg-pink-600 text-white py-2 rounded">Presentar</button></div>
</div>
)
const LiveKitModeView: React.FC = () => {
const participants = useParticipants()
return <div className="flex-1 bg-gray-950 flex items-center justify-center"><div className="text-white">LiveKit Mode</div></div>
}
const StudioVideoArea: React.FC<StudioVideoAreaProps> = ({ isDemoMode = false }) => isDemoMode ? <DemoModeView /> : <LiveKitModeView />
export default StudioVideoArea

View File

@ -0,0 +1,129 @@
// Configuración para modo demo/desarrollo
export const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === 'true' || false
// Token simulado para desarrollo (cuando LiveKit no está disponible)
export const DEMO_TOKEN = 'demo-token-for-development'
// Configuración simulada de LiveKit
export const DEMO_LIVEKIT_CONFIG = {
serverUrl: 'wss://demo.livekit.cloud',
token: DEMO_TOKEN,
}
// Participantes simulados para modo demo
export const DEMO_PARTICIPANTS = [
{
id: 'local-user',
identity: 'Usuario Local',
name: 'Tú',
isSpeaking: false,
isCameraEnabled: true,
isMicrophoneEnabled: true,
isScreenShareEnabled: false,
isLocal: true,
},
{
id: 'guest-1',
identity: 'guest-1',
name: 'Invitado 1',
isSpeaking: true,
isCameraEnabled: true,
isMicrophoneEnabled: true,
isScreenShareEnabled: false,
isLocal: false,
},
{
id: 'guest-2',
identity: 'guest-2',
name: 'Invitado 2',
isSpeaking: false,
isCameraEnabled: false,
isMicrophoneEnabled: true,
isScreenShareEnabled: false,
isLocal: false,
},
]
// Escenas predefinidas para demo
export const DEMO_SCENES = [
{
id: 'scene-1',
name: 'Escena Principal',
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/ec4899?text=Escena+1',
active: true,
},
{
id: 'scene-2',
name: 'Presentación',
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/3b82f6?text=Escena+2',
active: false,
},
{
id: 'scene-3',
name: 'Pantalla compartida',
thumbnail: 'https://via.placeholder.com/160x90/1a1a24/10b981?text=Escena+3',
active: false,
},
]
// Temas de color para personalización
export const COLOR_THEMES = [
{ id: 'rosa', name: 'Rosa', primary: '#ec4899', secondary: '#be185d' },
{ id: 'azul', name: 'Azul', primary: '#3b82f6', secondary: '#1e40af' },
{ id: 'verde', name: 'Verde', primary: '#10b981', secondary: '#047857' },
{ id: 'morado', name: 'Morado', primary: '#a855f7', secondary: '#7e22ce' },
{ id: 'naranja', name: 'Naranja', primary: '#f97316', secondary: '#c2410c' },
{ id: 'rojo', name: 'Rojo', primary: '#ef4444', secondary: '#b91c1c' },
]
// Overlays de ejemplo
export const DEMO_OVERLAYS = [
{
id: 'overlay-1',
name: 'Lower Third',
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/ec4899?text=Lower+Third',
type: 'lower-third',
},
{
id: 'overlay-2',
name: 'Logo Corner',
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/3b82f6?text=Logo',
type: 'logo',
},
{
id: 'overlay-3',
name: 'Banner Superior',
thumbnail: 'https://via.placeholder.com/120x68/1a1a24/10b981?text=Banner',
type: 'banner',
},
]
// Fondos de ejemplo
export const DEMO_BACKGROUNDS = [
{
id: 'bg-1',
name: 'Gradient Rosa',
thumbnail: 'https://via.placeholder.com/120x68/ec4899/be185d?text=Gradient',
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
},
{
id: 'bg-2',
name: 'Gradient Azul',
thumbnail: 'https://via.placeholder.com/120x68/3b82f6/1e40af?text=Gradient',
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
},
{
id: 'bg-3',
name: 'Gradient Verde',
thumbnail: 'https://via.placeholder.com/120x68/10b981/047857?text=Gradient',
gradient: 'linear-gradient(135deg, #10b981 0%, #047857 100%)',
},
]
// Efectos de sonido de ejemplo
export const DEMO_SOUNDS = [
{ id: 'sound-1', name: 'Aplauso', icon: '👏', file: '/sounds/applause.mp3' },
{ id: 'sound-2', name: 'Risa', icon: '😄', file: '/sounds/laugh.mp3' },
{ id: 'sound-3', name: 'Campana', icon: '🔔', file: '/sounds/bell.mp3' },
{ id: 'sound-4', name: 'Intro', icon: '🎵', file: '/sounds/intro.mp3' },
]

View File

@ -2,11 +2,185 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { @layer base {
margin: 0; * {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', border-color: #374151;
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', }
sans-serif;
-webkit-font-smoothing: antialiased; body {
-moz-osx-font-smoothing: grayscale; background-color: #111827;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
}
}
/* Requiner Font for Logo */
/* LiveKit Styles Override */
.lk-room-container {
@apply bg-gray-950;
}
.lk-participant-tile {
@apply rounded-lg overflow-hidden border-2 border-gray-700;
}
.lk-participant-tile:hover {
@apply border-pink-500;
}
.lk-participant-metadata {
@apply bg-gradient-to-t from-black/80 to-transparent;
}
.lk-button {
@apply transition-all hover:scale-105;
}
/* Scrollbar personalizado */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
@apply bg-gray-800;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-gray-600 rounded-full;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}
/* Control buttons */
.control-button {
@apply text-white transition-all duration-200 shadow-lg;
}
.control-button:hover {
@apply transform scale-105;
}
.control-button:active {
@apply transform scale-95;
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Requiner Font for Logo */
@font-face {
font-family: 'Requiner';
src: url('/assets/Requiner-6RRLM.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* LiveKit Custom Styles */
.lk-video-conference {
height: 100%;
background: #0a0a0f;
}
.lk-focus-layout {
background: #0a0a0f;
}
.lk-grid-layout {
background: #0a0a0f;
}
.lk-participant-tile {
border-radius: 8px;
overflow: hidden;
background: #1a1a24;
}
.lk-participant-metadata {
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.8) 100%);
}
.lk-participant-metadata-item {
color: white;
font-weight: 500;
}
.lk-button {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
transition: all 0.2s;
}
.lk-button:hover {
background: rgba(255, 255, 255, 0.15);
}
/* Control buttons custom styles */
.control-button {
transition: all 0.2s;
color: white;
}
.control-button:hover {
transform: scale(1.05);
}
.control-button:active {
transform: scale(0.95);
}
/* Studio Layout */
.studio-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #111827;
color: white;
}
.studio-room {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Scrollbar personalizado */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1f2937;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
} }

12
packages/studio-panel/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_LIVEKIT_URL: string
readonly VITE_LIVEKIT_API_KEY: string
readonly VITE_LIVEKIT_API_SECRET: string
readonly VITE_DEMO_MODE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@ -0,0 +1,85 @@
/**
* LiveKit Token Generator API
*
* Este archivo debe ser ejecutado como servidor backend separado
* o integrado en tu servidor Express/Fastify existente.
*
* Para ejecutar standalone:
* npm install express cors livekit-server-sdk dotenv
* node token-server.js
*/
import express from 'express'
import cors from 'cors'
import { AccessToken } from 'livekit-server-sdk'
import dotenv from 'dotenv'
// Cargar variables de entorno
dotenv.config({ path: '../../.env' })
const app = express()
const PORT = process.env.TOKEN_SERVER_PORT || 3002
app.use(cors())
app.use(express.json())
// Endpoint para generar tokens
app.post('/api/token', async (req, res) => {
try {
const { roomName, participantName } = req.body
if (!roomName || !participantName) {
return res.status(400).json({
error: 'roomName y participantName son requeridos'
})
}
const apiKey = process.env.LIVEKIT_API_KEY
const apiSecret = process.env.LIVEKIT_API_SECRET
if (!apiKey || !apiSecret) {
console.error('LIVEKIT_API_KEY o LIVEKIT_API_SECRET no están configurados')
return res.status(500).json({
error: 'Configuración de LiveKit incompleta'
})
}
// Crear token de acceso
const at = new AccessToken(apiKey, apiSecret, {
identity: participantName,
name: participantName,
})
// Agregar permisos
at.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canPublishData: true,
canSubscribe: true,
})
const token = at.toJwt()
res.json({
token,
serverUrl: process.env.LIVEKIT_URL
})
} catch (error) {
console.error('Error generando token:', error)
res.status(500).json({
error: 'Error al generar token de LiveKit'
})
}
})
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
app.listen(PORT, () => {
console.log(`🎬 LiveKit Token Server corriendo en http://localhost:${PORT}`)
console.log(`📡 LiveKit URL: ${process.env.LIVEKIT_URL}`)
console.log(`🔑 API Key configurada: ${process.env.LIVEKIT_API_KEY ? 'Sí' : 'No'}`)
})

View File

@ -1,9 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], // Cargar variables de entorno del directorio raíz
server: { const env = loadEnv(mode, '../../', '')
port: 3001,
}, return {
plugins: [react()],
server: {
port: 3001,
},
define: {
// Exponer variables de entorno al cliente
'import.meta.env.VITE_LIVEKIT_URL': JSON.stringify(env.LIVEKIT_URL),
'import.meta.env.VITE_LIVEKIT_API_KEY': JSON.stringify(env.LIVEKIT_API_KEY),
'import.meta.env.VITE_LIVEKIT_API_SECRET': JSON.stringify(env.LIVEKIT_API_SECRET),
},
}
}) })