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:
parent
70317f95f8
commit
0ca2b36b5c
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal 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
|
||||||
205
docs/STUDIO_IMPLEMENTATION.md
Normal file
205
docs/STUDIO_IMPLEMENTATION.md
Normal 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
649
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
161
packages/broadcast-panel/LIVEKIT_SETUP.md
Normal file
161
packages/broadcast-panel/LIVEKIT_SETUP.md
Normal 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
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
479
packages/broadcast-panel/src/components/Studio.module.css
Normal file
479
packages/broadcast-panel/src/components/Studio.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/broadcast-panel/src/components/Studio.tsx
Normal file
45
packages/broadcast-panel/src/components/Studio.tsx
Normal 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
|
||||||
19
packages/studio-panel/.dockerignore
Normal file
19
packages/studio-panel/.dockerignore
Normal 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
|
||||||
4
packages/studio-panel/.env.local
Normal file
4
packages/studio-panel/.env.local
Normal 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
|
||||||
145
packages/studio-panel/DOCKER.md
Normal file
145
packages/studio-panel/DOCKER.md
Normal 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
|
||||||
|
```
|
||||||
31
packages/studio-panel/Dockerfile
Normal file
31
packages/studio-panel/Dockerfile
Normal 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"]
|
||||||
@ -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"
|
||||||
|
|||||||
BIN
packages/studio-panel/public/assets/Requiner-6RRLM.woff
Normal file
BIN
packages/studio-panel/public/assets/Requiner-6RRLM.woff
Normal file
Binary file not shown.
1641
packages/studio-panel/public/assets/images/app/logo_avanzacast.svg
Normal file
1641
packages/studio-panel/public/assets/images/app/logo_avanzacast.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 231 KiB |
16
packages/studio-panel/server-package.json
Normal file
16
packages/studio-panel/server-package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
packages/studio-panel/server.js
Normal file
105
packages/studio-panel/server.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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
|
||||||
|
|||||||
162
packages/studio-panel/src/components/Studio.tsx
Normal file
162
packages/studio-panel/src/components/Studio.tsx
Normal 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
|
||||||
|
|
||||||
165
packages/studio-panel/src/components/StudioControls.tsx
Normal file
165
packages/studio-panel/src/components/StudioControls.tsx
Normal 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
|
||||||
103
packages/studio-panel/src/components/StudioHeader.tsx
Normal file
103
packages/studio-panel/src/components/StudioHeader.tsx
Normal 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
|
||||||
|
|
||||||
179
packages/studio-panel/src/components/StudioLeftSidebar.tsx
Normal file
179
packages/studio-panel/src/components/StudioLeftSidebar.tsx
Normal 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
|
||||||
443
packages/studio-panel/src/components/StudioRightPanel.tsx
Normal file
443
packages/studio-panel/src/components/StudioRightPanel.tsx
Normal 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
|
||||||
186
packages/studio-panel/src/components/StudioSidebar.tsx
Normal file
186
packages/studio-panel/src/components/StudioSidebar.tsx
Normal 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
|
||||||
30
packages/studio-panel/src/components/StudioVideoArea.tsx
Normal file
30
packages/studio-panel/src/components/StudioVideoArea.tsx
Normal 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
|
||||||
129
packages/studio-panel/src/config/demo.ts
Normal file
129
packages/studio-panel/src/config/demo.ts
Normal 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' },
|
||||||
|
]
|
||||||
@ -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
12
packages/studio-panel/src/vite-env.d.ts
vendored
Normal 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
|
||||||
|
}
|
||||||
85
packages/studio-panel/token-server.js
Normal file
85
packages/studio-panel/token-server.js
Normal 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'}`)
|
||||||
|
})
|
||||||
@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user