feat: Implement main Studio component with user authentication and connection handling

feat: Create BroadcastStudio component as the main UI container for broadcasting

feat: Develop ControlPanel component for managing broadcast controls and layouts

feat: Add LiveKitBroadcastWrapper to encapsulate LiveKitRoom and manage broadcasting

feat: Implement StreamView component for rendering video output with overlays and layouts

feat: Create SceneContext for managing scene configurations and layouts

chore: Update index exports for broadcast components
This commit is contained in:
Cesar Mendivil 2025-11-07 23:22:35 -07:00
parent 3ed1ac7cc2
commit 78e83b46dd
17 changed files with 2568 additions and 1873 deletions

View File

@ -0,0 +1,234 @@
# LiveKit Broadcast Studio UI - Documentación
## 📋 Descripción General
Sistema de interfaz de usuario para un estudio de producción de video en vivo estilo StreamYard, construido con React, TypeScript y LiveKit Components.
## 🏗️ Arquitectura
### Estructura de Componentes
```
BroadcastStudio (Contenedor Principal)
├── SceneProvider (Context)
│ ├── StreamView (Visualización - CONSUMIDOR)
│ │ ├── Layouts dinámicos basados en sceneConfig
│ │ ├── Renderizado de participantes (LiveKit)
│ │ └── Overlays (logos, lower thirds)
│ │
│ └── ControlPanel (Controles - MODIFICADOR)
│ ├── LocalControls (Izquierda - Vista local + Presentar)
│ ├── ScrollableLayoutsContainer (Centro - Botones de layouts)
│ └── ActionControls (Derecha - Config y recursos)
```
### Sistema de Estado (SceneContext)
**Ubicación:** `src/context/SceneContext.tsx`
El contexto centralizado gestiona toda la configuración de escenas:
```typescript
interface SceneConfig {
participantLayout: ParticipantLayoutType // Tipo de layout activo
mediaSource: MediaSourceType | null // Contenido adicional (screen, file, etc)
overlays: OverlayConfig // Logos, lower thirds, etc
}
```
**Regla de oro:**
- **ControlPanel**: Único componente que MODIFICA `sceneConfig`
- **StreamView**: Único componente que CONSUME `sceneConfig` para renderizar
## 🎨 Componentes Principales
### 1. StreamView
**Archivo:** `src/components/broadcast/StreamView.tsx`
Renderiza la salida final de video en formato 16:9.
**Características:**
- Aspecto ratio fijo 16:9 (`aspect-ratio: 16 / 9`)
- 6 layouts predefinidos:
- `grid_4`: Grid 2×2
- `grid_6`: Grid 3×2
- `focus_side`: Foco principal + sidebar
- `side_by_side`: Dos participantes lado a lado
- `presentation`: Pantalla compartida + speaker pequeño
- `single_speaker`: Un solo participante
- Sistema de overlays configurable
- Integración con hooks de LiveKit (`useParticipants`, `useTracks`)
### 2. ControlPanel
**Archivo:** `src/components/broadcast/ControlPanel.tsx`
Panel de control interactivo dividido en 3 secciones.
**Estructura CSS:**
```css
.control-panel-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
```
#### 2.1 LocalControls (Izquierda)
- Vista previa del usuario local
- Botón "Presentar"
- `flex-shrink: 0` (ancho fijo)
#### 2.2 ScrollableLayoutsContainer (Centro)
- Scroll horizontal de botones de layouts
- `flex-grow: 1; overflow-x: auto; overflow-y: hidden`
- Botones con `flex-shrink: 0` en fila única
#### 2.3 ActionControls (Derecha)
- Botones de acción (Editor, Config, Añadir)
- `flex-shrink: 0` (ancho fijo)
### 3. BroadcastStudio
**Archivo:** `src/components/broadcast/BroadcastStudio.tsx`
Contenedor principal que alinea todo.
**CSS clave:**
```css
.main-app-container {
display: flex;
flex-direction: column;
align-items: center;
}
.stream-view-container,
.control-panel-wrapper {
width: 100%;
max-width: 1200px; /* Mismo ancho máximo para ambos */
}
```
### 4. LiveKitBroadcastWrapper
**Archivo:** `src/components/broadcast/LiveKitBroadcastWrapper.tsx`
Wrapper que conecta el BroadcastStudio con LiveKit.
## 🔧 Uso
### Integración Básica
```tsx
import { LiveKitBroadcastWrapper } from './components/broadcast'
function App() {
return (
<LiveKitBroadcastWrapper
token="your-livekit-token"
serverUrl="wss://your-server.com"
userName="Usuario"
roomName="sala-demo"
onDisconnect={() => console.log('Desconectado')}
/>
)
}
```
### Uso Standalone (sin LiveKit)
```tsx
import { BroadcastStudio } from './components/broadcast'
import { LiveKitRoom } from '@livekit/components-react'
function App() {
return (
<LiveKitRoom token={token} serverUrl={serverUrl}>
<BroadcastStudio />
</LiveKitRoom>
)
}
```
### Acceso al Contexto de Escenas
```tsx
import { useScene } from './context/SceneContext'
function MiComponente() {
const { sceneConfig, applyPreset, updateOverlays } = useScene()
// Aplicar un preset
const handleChangeLayout = () => {
applyPreset('FOCUS_SIDE')
}
// Actualizar overlays
const handleToggleLogo = () => {
updateOverlays({ showLogo: !sceneConfig.overlays.showLogo })
}
return (...)
}
```
## 📦 Presets de Layouts
Definidos en `SceneContext.tsx`:
```typescript
PRESET_LAYOUTS = {
GRID_4: { participantLayout: 'grid_4', ... },
GRID_6: { participantLayout: 'grid_6', ... },
FOCUS_SIDE: { participantLayout: 'focus_side', ... },
SIDE_BY_SIDE: { participantLayout: 'side_by_side', ... },
PRESENTATION: { participantLayout: 'presentation', ... },
SINGLE_SPEAKER: { participantLayout: 'single_speaker', ... },
}
```
## 🎯 Próximos Pasos
### Features Pendientes
- [ ] Integración con LiveKit Egress para grabación/streaming
- [ ] Editor visual de escenas (modal con drag & drop)
- [ ] Gestión de overlays personalizado
- [ ] Soporte para múltiples cámaras por participante
- [ ] Transiciones animadas entre layouts
- [ ] Guardado/carga de escenas personalizadas
### Mejoras de UX
- [ ] Tooltips informativos en botones de layout
- [ ] Preview en miniatura de cada layout
- [ ] Keyboard shortcuts para cambio rápido
- [ ] Indicador visual del layout activo más prominente
- [ ] Confirmación antes de cambios críticos
## 🐛 Debugging
### Verificar estado de escenas
```tsx
// En DevTools Console:
window.__SCENE_DEBUG__ = true
// O agregar en tu componente:
console.log('[SceneDebug]', sceneConfig)
```
### Logs de LiveKit
Los hooks de LiveKit (`useParticipants`, `useTracks`) ya incluyen logs internos.
Para más detalle, habilitar en LiveKitRoom:
```tsx
<LiveKitRoom logLevel="debug" ... />
```
## 📝 Notas de Implementación
1. **Aspecto Ratio:** StreamView usa `aspect-ratio: 16/9` nativo de CSS (compatibilidad moderna)
2. **Scroll Horizontal:** El scroll en LayoutsContainer es solo horizontal para mejor UX
3. **Flexibilidad:** Todos los componentes son modulares y pueden usarse independientemente
4. **Performance:** Los layouts se renderizan condicionalmente para evitar re-renders innecesarios
5. **TypeScript:** Todo el código está fuertemente tipado para mejor DX
---
**Creado el:** 7 de noviembre de 2025
**Versión:** 1.0.0
**Stack:** React + TypeScript + LiveKit + Tailwind CSS

View File

@ -0,0 +1,36 @@
// Ejemplo de cómo integrar BroadcastStudio en Studio.tsx existente
import { LiveKitBroadcastWrapper } from './broadcast'
// Reemplazar la sección de render actual con:
// Opción 1: Reemplazar completamente StudioVideoArea con BroadcastStudio
{!isDemoMode && token && serverUrl && (
<LiveKitBroadcastWrapper
token={token}
serverUrl={serverUrl}
userName={userName}
roomName={roomName}
onDisconnect={() => {
console.log('Desconectado del estudio')
setIsDemoMode(true)
}}
/>
)}
// Opción 2: Usar solo dentro del LiveKitRoom existente
<LiveKitRoom token={token} serverUrl={serverUrl} ...>
<BroadcastStudio />
</LiveKitRoom>
// Opción 3: Modo híbrido - Toggle entre vista clásica y BroadcastStudio
const [useBroadcastUI, setUseBroadcastUI] = useState(false)
{useBroadcastUI ? (
<BroadcastStudio />
) : (
<>
<StudioVideoArea ... />
<StudioControls ... />
</>
)}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,789 @@
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 StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel'
import { DEMO_TOKEN } from '../config/demo'
import StreamView from './broadcast/StreamView'
import ControlPanel from './broadcast/ControlPanel'
import { SceneProvider } from '../context/SceneContext'
interface StudioProps {
userName: string
roomName: string
}
const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
const [token, setToken] = useState<string>(DEMO_TOKEN)
const [serverUrl, setServerUrl] = useState<string>('')
const [isConnecting, setIsConnecting] = useState(true)
const [isDemoMode, setIsDemoMode] = useState(true) // Iniciar en modo demo por defecto
const [showLeftPanel, setShowLeftPanel] = useState(true)
const [showRightPanel, setShowRightPanel] = useState(true)
const [activeRightTab, setActiveRightTab] = useState<TabType>('brand')
// Utility: heurística ligera para validar token en cliente
const isTokenLikelyValid = (t?: string) => {
if (!t) return false
// los tokens de LiveKit suelen ser JWT (contienen '.') o bastante largos
if (t.includes('.')) return true
if (t.length > 80) return true
return false
}
const decodeJwt = (t: string) => {
try {
const parts = t.split('.')
if (parts.length < 2) return null
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')
const json = JSON.parse(atob(padded))
return json
} catch (e) {
return null
}
}
// Log decoded token claims for diagnostic when token changes
useEffect(() => {
if (!token) return
const claims = decodeJwt(token)
if (claims) {
console.log('[Studio] token claims:', claims)
} else {
console.log('[Studio] token does not look like JWT or could not be decoded')
}
}, [token])
useEffect(() => {
// Leer token, room y user desde URL query params (pasados por broadcast-panel)
// El serverUrl se lee del .env
try {
const urlParams = new URLSearchParams(window.location.search)
const storedToken = urlParams.get('token')
const storedRoom = urlParams.get('room')
const storedUser = urlParams.get('user')
// ServerUrl siempre viene del .env
const envServerUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
// Log detallado para depuración
console.log('[Studio] Leyendo datos de URL params:', {
token: storedToken ? `recibido (${storedToken.length} caracteres)` : 'no encontrado',
room: storedRoom || 'no encontrado',
user: storedUser || 'no encontrado',
})
console.log('[Studio] ServerUrl desde .env:', envServerUrl)
if (storedToken) {
console.log('[Studio] Token encontrado en URL. Configurando para conexión real.')
setToken(storedToken)
setServerUrl(envServerUrl)
setIsDemoMode(false)
// Guardar en localStorage para que persista en recargas
localStorage.setItem('avanzacast_studio_token', storedToken)
localStorage.setItem('avanzacast_studio_serverUrl', envServerUrl)
if (storedRoom) localStorage.setItem('avanzacast_studio_room', storedRoom)
if (storedUser) localStorage.setItem('avanzacast_studio_user', storedUser)
// Limpiar URL para que no se vea el token
window.history.replaceState({}, '', window.location.pathname)
} else {
// Intentar leer de localStorage (si ya se guardó antes)
const cachedToken = localStorage.getItem('avanzacast_studio_token')
const cachedServerUrl = localStorage.getItem('avanzacast_studio_serverUrl')
if (cachedToken && cachedServerUrl) {
console.log('[Studio] Token y Server URL encontrados en localStorage. Configurando para conexión real.')
setToken(cachedToken)
setServerUrl(cachedServerUrl)
setIsDemoMode(false)
} else {
console.warn('⚠️ No se encontró token en URL ni localStorage. Usando modo DEMO...')
setToken(DEMO_TOKEN)
setServerUrl(envServerUrl)
setIsDemoMode(true)
}
}
} catch (err) {
console.error('Error crítico leyendo datos:', err)
setToken(DEMO_TOKEN)
setIsDemoMode(true)
} finally {
setIsConnecting(false)
}
}, [roomName, userName])
// Dev-only: wrap window.WebSocket once to capture close codes/reasons/messages
useEffect(() => {
try {
const w = window as any
if (w && !w.__AVZ_WS_WRAPPED) {
const OriginalWS = w.WebSocket
function WrappedWebSocket(url: string, protocols?: string | string[]) {
console.log(`[WS Monitor] new WebSocket(${url})`)
// @ts-ignore
const sock = protocols ? new OriginalWS(url, protocols) : new OriginalWS(url)
try {
sock.addEventListener('open', (ev: Event) => console.log('[WS Monitor] Evento: open', { url, type: (ev && (ev as any).type) || 'open' }))
sock.addEventListener('close', (ev: any) => console.log('[WS Monitor] Evento: close', { url, code: ev.code, reason: ev.reason, wasClean: ev.wasClean }))
sock.addEventListener('error', (ev: any) => console.error('[WS Monitor] Evento: error', { url, ev }))
sock.addEventListener('message', (ev: any) => {
// don't log binary payloads
try {
const data = typeof ev.data === 'string' ? ev.data : '[payload binario]'
// limit length
console.log('[WS Monitor] Evento: message', { url, data: data && data.slice ? data.slice(0, 250) : data })
} catch (e) {
// ignore
}
})
} catch (e) {
// ignore
}
return sock
}
WrappedWebSocket.prototype = OriginalWS.prototype
w.WebSocket = WrappedWebSocket
w.__AVZ_WS_WRAPPED = true
console.log('[Studio] WebSocket envuelto para depuración: se registrarán todos los eventos de WS.')
}
} catch (e) {
// ignore failures in exotic environments
}
}, [])
// Global error capture to help debugging connection failures
useEffect(() => {
const onErr = (ev: ErrorEvent) => console.error('[Studio][Error Global]', { message: ev.message, error: ev.error, filename: ev.filename, lineno: ev.lineno })
const onReject = (ev: PromiseRejectionEvent) => console.error('[Studio][Promesa Rechazada]', ev.reason)
window.addEventListener('error', onErr)
window.addEventListener('unhandledrejection', onReject)
return () => {
window.removeEventListener('error', onErr)
window.removeEventListener('unhandledrejection', onReject)
}
}, [])
// Development-only WebSocket monitor: wrap window.WebSocket to log events for debugging
// NOTE: development WebSocket monitor removed to keep hooks stable during HMR
useEffect(() => {
console.log(`[Studio] Renderizando LiveKitRoom. Longitud del token: ${token ? token.length : 0}. ¿Es válido?: ${isTokenLikelyValid(token)}`)
return () => console.log('[Studio] Desmontando LiveKitRoom')
}, [token])
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>
)
}
// Heurística para no montar LiveKitRoom con tokens de demo o cortos
const hasValidToken = isTokenLikelyValid(token)
const hasValidServerUrl = serverUrl && serverUrl.startsWith('wss://')
console.log('[Studio] Validación antes de renderizar:', { hasValidToken, hasValidServerUrl, serverUrl })
if (!hasValidToken || !hasValidServerUrl) {
// Mostrar diagnóstico más útil cuando el token es claramente inválido/corto
const preview = token ? `${token.slice(0, 6)}...${token.slice(-6)}` : 'ninguno'
console.error(`[Studio] Token o ServerUrl inválidos, no se montará LiveKitRoom.`)
console.error(`[Studio] Token preview: ${preview}, Longitud: ${token ? token.length : 0}`)
console.error(`[Studio] ServerUrl: ${serverUrl || 'ninguno'}`)
const broadcastUrl = import.meta.env.VITE_BROADCAST_URL || 'http://localhost:5175'
const goBackToBroadcast = () => {
// Limpiar localStorage (por si acaso quedaron datos)
try {
console.log('[Studio] Limpiando localStorage antes de volver.')
localStorage.removeItem('avanzacast_studio_token')
localStorage.removeItem('avanzacast_studio_serverUrl')
localStorage.removeItem('avanzacast_studio_room')
localStorage.removeItem('avanzacast_studio_user')
} catch (e) {}
// Intentar volver atrás en el historial si hay entrada previa
if (window.history.length > 1) {
console.log('[Studio] Volviendo atrás en el historial.')
window.history.back()
} else {
// Redirigir al broadcast
console.log(`[Studio] No hay historial, redirigiendo a ${broadcastUrl}`)
window.location.href = broadcastUrl
}
}
// Modal bloqueante: overlay completo que no permite interacción con UI
return (
<div className="fixed inset-0 z-[9999] bg-gray-900 bg-opacity-90 flex items-center justify-center">
{/* Modal */}
<div role="dialog" aria-modal="true" className="bg-gray-800 p-8 rounded-lg max-w-md w-full mx-4 shadow-2xl border border-red-500/50">
<div className="flex items-center mb-4">
<svg className="w-8 h-8 text-red-500 mr-3 flex-shrink-0" 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-xl text-white font-semibold">Error de Conexión</h3>
</div>
<p className="text-gray-300 mb-3">No se pudo conectar al estudio porque no se obtuvo un token de autenticación válido.</p>
<p className="text-gray-400 text-sm mb-6">Esto puede ocurrir si el servidor de tokens no está disponible o si el token ha expirado.</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => window.location.reload()}
className="py-2 px-4 bg-slate-600 hover:bg-slate-700 text-white rounded-lg font-medium transition-colors"
>
Reintentar
</button>
<button
onClick={goBackToBroadcast}
className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white rounded-lg font-medium transition-colors"
>
Volver al Panel
</button>
</div>
</div>
</div>
)
}
// 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 - No se encontró un token en sessionStorage. Funcionalidad de streaming deshabilitada.
</div>
{/* Header superior */}
<StudioHeader roomName={roomName} userName={userName} />
{/* Contenido principal */}
<div className="flex-1 overflow-hidden relative">
{/* Sidebar izquierdo - Escenas (posición absoluta izquierda) */}
<div
className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showLeftPanel ? 'translate-x-0' : '-translate-x-full'
}`}
>
<StudioLeftSidebar />
</div>
{/* Botón toggle izquierdo (en el borde derecho del panel izquierdo) */}
<button
onClick={() => setShowLeftPanel(!showLeftPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ left: showLeftPanel ? '256px' : '0px' }}
title={showLeftPanel ? 'Ocultar panel izquierdo' : 'Mostrar panel izquierdo'}
>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity">
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path
d={showLeftPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"}
stroke="#1B1F29"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-300"
/>
</svg>
</button>
{/* Contenedor central - Área entre paneles laterales */}
<div
className="absolute top-0 bottom-0 transition-all duration-300 ease-in-out"
style={{
left: showLeftPanel ? '256px' : '0px',
right: showRightPanel ? '400px' : '80px'
}}
>
{/* Wrapper interno con padding y espacio para UI adicional */}
<div className="w-full h-full flex flex-col p-4 gap-3">
{/* Área superior - Controles de layout y calidad */}
<div className="flex-none flex items-center justify-between gap-4">
{/* Indicador de calidad */}
<div className="flex items-center gap-2 bg-gray-800 px-3 py-1.5 rounded-lg">
<span className="text-white text-sm font-semibold">720p</span>
</div>
{/* Controles de layout (Grid/Focus) */}
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
<button
onClick={() => setLayout('grid')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
layout === 'grid'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="Vista de cuadrícula"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="14" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="3" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="14" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
</svg>
</button>
<button
onClick={() => setLayout('focus')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
layout === 'focus'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="Vista enfocada"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="12" strokeWidth="2" rx="1"/>
<rect x="4" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
<rect x="10" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
<rect x="16" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
</svg>
</button>
</div>
{/* Branding - Logo StreamYard style */}
<div className="flex items-center gap-2 text-gray-400 text-xs ml-auto">
<span>Producido con</span>
<div className="flex items-center gap-1">
<div className="w-6 h-6 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
<span className="text-white text-xs font-bold">A</span>
</div>
<span className="text-white font-semibold">AvanzaCast</span>
</div>
</div>
</div>
{/* StudioVideoArea - Área principal de video (flexible) */}
<div className="flex-1 min-h-0 bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg overflow-hidden relative">
<StudioVideoArea isDemoMode={true} layout={layout} mode={mode} />
</div>
{/* Área inferior - Barra de herramientas estilo StreamYard */}
<div className="flex-none flex items-center justify-center gap-3 bg-gray-800/50 backdrop-blur-sm px-4 py-3 rounded-lg">
{/* Botones de participantes */}
<div className="flex items-center gap-1">
{/* Avatar del presentador */}
<div className="relative group">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform">
<span className="text-white text-sm font-bold">{userName?.charAt(0)?.toUpperCase() || 'U'}</span>
</div>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-gray-800"></div>
</div>
{/* Slots de invitados (5 espacios) */}
{[1, 2, 3, 4, 5].map((slot) => (
<button
key={slot}
className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group"
title={`Invitar participante ${slot}`}
>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
))}
</div>
{/* Separador */}
<div className="w-px h-8 bg-gray-600"></div>
{/* Botón Presentar o Invitar */}
<button className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-sm">Presentar o invitar</span>
</button>
{/* Separador */}
<div className="w-px h-8 bg-gray-600"></div>
{/* Herramientas adicionales */}
<div className="flex items-center gap-2">
{/* Botón editar */}
<button className="w-10 h-10 rounded-lg bg-blue-600 hover:bg-blue-700 flex items-center justify-center transition-colors group" title="Editar">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
{/* Botón añadir */}
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Añadir elemento">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Botón configuración */}
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Configuración">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
<div
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showRightPanel ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ right: '80px' }}
>
<StudioRightPanel
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
activeTab={activeRightTab}
onChangeTab={(t) => setActiveRightTab(t)}
/>
</div>
{/* Tabs Column externo (modo demo): permanece visible incluso cuando el panel se oculta */}
<div
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
style={{ right: '0px' }}
>
<div className="h-full flex items-start">
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
</div>
</div>
{/* Botón toggle derecho (se mueve con el panel) */}
<button
onClick={() => setShowRightPanel(!showRightPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ right: showRightPanel ? '400px' : '80px' }}
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path
d={showRightPanel ? "M10 44L6 50.5L10 57" : "M6 44L10 50.5L6 57"}
stroke="#1B1F29"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-300"
/>
</svg>
</button>
</div>
{/* Controles inferiores */}
<StudioControls
onOpenPresentation={handleOpenPresentationPanel}
onShareScreen={handleShareScreen}
sharedPresentation={sharedPresentation}
onClearPresentation={() => setSharedPresentation(null)}
layout={layout}
mode={mode}
onChangeLayout={(l: 'grid' | 'focus') => setLayout(l)}
onChangeMode={(m: 'video' | 'audio') => setMode(m)}
/>
{/* Presentation Panel modal */}
{presentationOpen && (
<div className="fixed right-6 bottom-24 z-50">
<PresentationPanel onClose={handleClosePresentationPanel} onShareScreen={handleShareScreen} onShareFile={handleShareFile} />
</div>
)}
{/* Shared presentation overlay */}
{sharedPresentation && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black bg-opacity-80">
<div className="max-w-6xl max-h-[80vh] w-full p-4">
<div className="bg-gray-900 p-2 rounded">
{sharedPresentation.type === 'file' ? (
// Try to guess type by URL extension
(sharedPresentation.url && sharedPresentation.url.endsWith('.pdf')) ? (
<iframe src={sharedPresentation.url} className="w-full h-[70vh] border-0" />
) : (sharedPresentation.url && sharedPresentation.url.match(/\.(mp4|webm|ogg)$/i)) ? (
<video src={sharedPresentation.url} controls className="w-full h-[70vh] bg-black" />
) : (
<img src={sharedPresentation.url || ''} alt="presentation" className="w-full h-[70vh] object-contain" />
)
) : (
// screen share is a blob URL
<video src={sharedPresentation.url} autoPlay playsInline controls className="w-full h-[70vh] bg-black" />
)}
<div className="mt-2 text-right">
<button onClick={() => setSharedPresentation(null)} className="px-4 py-2 bg-red-600 text-white rounded">Cerrar presentación</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// Renderizar con LiveKit (modo normal)
return (
<LiveKitRoom
video={true}
audio={true}
token={token}
serverUrl={serverUrl}
onDisconnected={() => console.log('[LiveKit] Desconectado.')}
onError={(e) => console.error('[LiveKit] Error de conexión:', e)}
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-1 overflow-hidden relative">
{/* Sidebar izquierdo - Escenas (posición absoluta izquierda) */}
<div
className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showLeftPanel ? 'translate-x-0' : '-translate-x-full'
}`}
>
<StudioLeftSidebar />
</div>
{/* Botón toggle izquierdo (en el borde derecho del panel izquierdo) */}
<button
onClick={() => setShowLeftPanel(!showLeftPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ left: showLeftPanel ? '256px' : '0px' }}
title={showLeftPanel ? 'Ocultar panel izquierdo' : 'Mostrar panel izquierdo'}
>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(-1)' }}>
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path
d={showLeftPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"}
stroke="#FFF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-300"
/>
</svg>
</button>
{/* Contenedor central - Área entre paneles laterales */}
<div
className="absolute top-0 bottom-0 transition-all duration-300 ease-in-out"
style={{
left: showLeftPanel ? '256px' : '0px',
right: showRightPanel ? '400px' : '80px'
}}
>
{/* Wrapper interno con padding y espacio para UI adicional */}
<div className="w-full h-full flex flex-col p-4 gap-3">
{/* Área superior - Controles de layout y calidad */}
<div className="flex-none flex items-center justify-between gap-4">
{/* Indicador de calidad */}
<div className="flex items-center gap-2 bg-gray-800 px-3 py-1.5 rounded-lg">
<span className="text-white text-sm font-semibold">720p</span>
</div>
{/* Controles de layout (Grid/Focus) */}
<div className="flex items-center gap-2 bg-gray-800 rounded-lg p-1">
<button
onClick={() => setLayout('grid')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
layout === 'grid'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="Vista de cuadrícula"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="14" y="3" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="3" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
<rect x="14" y="14" width="7" height="7" strokeWidth="2" rx="1"/>
</svg>
</button>
<button
onClick={() => setLayout('focus')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
layout === 'focus'
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
title="Vista enfocada"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="12" strokeWidth="2" rx="1"/>
<rect x="4" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
<rect x="10" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
<rect x="16" y="17" width="4" height="4" strokeWidth="2" rx="1"/>
</svg>
</button>
</div>
{/* Branding - Logo AvanzaCast */}
<div className="flex items-center gap-2 text-gray-400 text-xs ml-auto">
<span>Producido con</span>
<div className="flex items-center gap-1">
<div className="w-6 h-6 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
<span className="text-white text-xs font-bold">A</span>
</div>
<span className="text-white font-semibold">AvanzaCast</span>
</div>
</div>
</div>
{/* StudioVideoArea - Área principal de video (flexible) */}
<div className="flex-1 min-h-0 bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg overflow-hidden relative">
<StudioVideoArea isDemoMode={false} />
</div>
{/* Área inferior - Barra de herramientas estilo StreamYard */}
<div className="flex-none flex items-center justify-center gap-3 bg-gray-800/50 backdrop-blur-sm px-4 py-3 rounded-lg">
{/* Botones de participantes */}
<div className="flex items-center gap-1">
{/* Avatar del presentador */}
<div className="relative group">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center cursor-pointer hover:scale-105 transition-transform">
<span className="text-white text-sm font-bold">{userName?.charAt(0)?.toUpperCase() || 'U'}</span>
</div>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-red-500 rounded-full border-2 border-gray-800"></div>
</div>
{/* Slots de invitados (5 espacios) */}
{[1, 2, 3, 4, 5].map((slot) => (
<button
key={slot}
className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group"
title={`Invitar participante ${slot}`}
>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
))}
</div>
{/* Separador */}
<div className="w-px h-8 bg-gray-600"></div>
{/* Botón Presentar o Invitar */}
<button className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-sm">Presentar o invitar</span>
</button>
{/* Separador */}
<div className="w-px h-8 bg-gray-600"></div>
{/* Herramientas adicionales */}
<div className="flex items-center gap-2">
{/* Botón editar */}
<button className="w-10 h-10 rounded-lg bg-blue-600 hover:bg-blue-700 flex items-center justify-center transition-colors group" title="Editar">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
{/* Botón añadir */}
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Añadir elemento">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{/* Botón configuración */}
<button className="w-10 h-10 rounded-lg bg-gray-700 hover:bg-gray-600 flex items-center justify-center transition-colors group" title="Configuración">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
<div
className={`absolute top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showRightPanel ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ right: '80px' }}
>
<StudioRightPanel
roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string}
activeTab={activeRightTab}
onChangeTab={(t) => setActiveRightTab(t)}
/>
</div>
{/* Tabs Column externo: permanece visible incluso cuando el panel se oculta */}
<div
className="absolute top-0 bottom-0 z-30 pointer-events-auto"
style={{ right: '0px' }}
>
<div className="h-full flex items-start">
<TabsColumn activeTab={activeRightTab} onChangeTab={(t) => setActiveRightTab(t)} />
</div>
</div>
{/* Botón toggle derecho (se mueve con el panel) */}
<button
onClick={() => setShowRightPanel(!showRightPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ right: showRightPanel ? '400px' : '80px' }}
title={showRightPanel ? 'Ocultar panel derecho' : 'Mostrar panel derecho'}
>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" xmlns="http://www.w3.org/2000/svg" className="hover:opacity-80 transition-opacity" style={{ transform: 'scaleX(1)' }}>
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<path d="M0.5 12C0.5 5.64873 5.64873 0.5 12 0.5H15.5V100.5H12C5.64873 100.5 0.5 95.3513 0.5 89V12Z" stroke="#1B1F291A"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path
d={showRightPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"}
stroke="#FFF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-300"
/>
</svg>
</button>
</div>
{/* Controles inferiores */}
<StudioControls
onOpenPresentation={handleOpenPresentationPanel}
onShareScreen={handleShareScreen}
sharedPresentation={sharedPresentation}
onClearPresentation={() => setSharedPresentation(null)}
/>
</div>
{/* Renderizador de audio de la sala */}
<RoomAudioRenderer />
</LiveKitRoom>
)
}
export default Studio

View File

@ -0,0 +1,167 @@
import { useState, useEffect } from 'react'
import { LiveKitRoom } from '@livekit/components-react'
import '@livekit/components-styles'
import StudioHeader from './StudioHeader'
import StudioLeftSidebar from './StudioLeftSidebar'
import StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel'
import { SceneProvider } from '../context/SceneContext'
import StreamView from './broadcast/StreamView'
import ControlPanel from './broadcast/ControlPanel'
function Studio() {
const [token, setToken] = useState<string>('')
const [serverUrl, setServerUrl] = useState<string>('wss://avanzacast-test-0kl2kzjr.livekit.cloud')
const [roomName, setRoomName] = useState<string>('')
const [userName, setUserName] = useState<string>('')
const [showLeftPanel, setShowLeftPanel] = useState(true)
const [showRightPanel, setShowRightPanel] = useState(true)
const [activeRightTab, setActiveRightTab] = useState<TabType>('chat')
const [needsUserName, setNeedsUserName] = useState(false)
const [inputUserName, setInputUserName] = useState('')
const [isConnecting, setIsConnecting] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
const urlRoom = params.get('room')
const urlUser = params.get('user')
const savedToken = urlToken || localStorage.getItem('avanzacast_studio_token') || ''
const savedRoom = urlRoom || localStorage.getItem('avanzacast_studio_room') || ''
const savedServerUrl = localStorage.getItem('avanzacast_studio_serverUrl') || 'wss://avanzacast-test-0kl2kzjr.livekit.cloud'
const savedUserName = urlUser || localStorage.getItem('avanzacast_studio_userName') || ''
setToken(savedToken)
setRoomName(savedRoom)
setServerUrl(savedServerUrl)
if (savedUserName) {
setUserName(savedUserName)
setNeedsUserName(false)
} else if (savedToken && savedRoom) {
setNeedsUserName(true)
}
if (savedToken) localStorage.setItem('avanzacast_studio_token', savedToken)
if (savedRoom) localStorage.setItem('avanzacast_studio_room', savedRoom)
if (savedServerUrl) localStorage.setItem('avanzacast_studio_serverUrl', savedServerUrl)
}, [])
const handleSubmitUserName = (e: React.FormEvent) => {
e.preventDefault()
if (inputUserName.trim()) {
const finalUserName = inputUserName.trim()
setUserName(finalUserName)
localStorage.setItem('avanzacast_studio_userName', finalUserName)
setNeedsUserName(false)
}
}
if (needsUserName) {
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900">
<div className="w-full max-w-md mx-4">
<div className="bg-gray-800/50 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-700/50 p-8">
<div className="flex justify-center mb-6">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-gradient-to-br from-pink-500 to-purple-600 rounded-lg flex items-center justify-center shadow-lg">
<span className="text-white text-xl font-bold">A</span>
</div>
<span className="text-2xl font-bold text-white">AvanzaCast</span>
</div>
</div>
<h2 className="text-xl font-semibold text-white text-center mb-2">Bienvenido al Estudio</h2>
<p className="text-gray-400 text-center mb-6">Ingresa tu nombre para unirte a la transmisión</p>
<form onSubmit={handleSubmitUserName} className="space-y-4">
<div>
<label htmlFor="userName" className="block text-sm font-medium text-gray-300 mb-2">Nombre de usuario</label>
<input id="userName" type="text" value={inputUserName} onChange={(e) => setInputUserName(e.target.value)} placeholder="Tu nombre" className="w-full px-4 py-3 bg-gray-900/50 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all" autoFocus required />
</div>
<button type="submit" className="w-full py-3 bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-200">Entrar al Estudio</button>
</form>
</div>
</div>
</div>
)
}
if (isConnecting) {
return (
<div className="flex items-center justify-center h-screen bg-gray-900">
<div className="text-center">
<div className="mb-4"><div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto"></div></div>
<div className="text-white text-xl">Conectando al estudio...</div>
</div>
</div>
)
}
if (!token || !roomName) {
return (
<div className="flex items-center justify-center h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900">
<div className="text-center max-w-md mx-4">
<div className="mb-6">
<div className="w-16 h-16 bg-gradient-to-br from-pink-500 to-purple-600 rounded-2xl flex items-center justify-center shadow-lg mx-auto mb-4">
<span className="text-white text-2xl font-bold">A</span>
</div>
<h1 className="text-3xl font-bold text-white mb-2">AvanzaCast Studio</h1>
</div>
<p className="text-gray-400 text-lg mb-4">No hay datos de conexión disponibles.</p>
<p className="text-gray-500 text-sm">Para acceder al estudio, debes iniciar una transmisión desde el panel de broadcast.</p>
</div>
</div>
)
}
return (
<LiveKitRoom token={token} serverUrl={serverUrl} onDisconnected={() => console.log('[LiveKit] Desconectado.')} onError={(e) => console.error('[LiveKit] Error:', e)} data-lk-theme="default" className="studio-container">
<SceneProvider>
<div className="flex flex-col h-screen bg-gray-900 overflow-hidden">
<div className="flex-none"><StudioHeader roomName={roomName} userName={userName} /></div>
<div className="flex-1 overflow-hidden relative min-h-0">
<div className={`absolute left-0 top-0 bottom-0 z-20 transition-transform duration-300 ${showLeftPanel ? 'translate-x-0' : '-translate-x-full'}`}>
<StudioLeftSidebar />
</div>
<button onClick={() => setShowLeftPanel(!showLeftPanel)} className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300" style={{ left: showLeftPanel ? '256px' : '0px' }}>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" className="hover:opacity-80">
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path d={showLeftPanel ? "M6 44L10 50.5L6 57" : "M10 44L6 50.5L10 57"} stroke="#FFF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<div className="absolute top-0 bottom-0 transition-all duration-300 flex flex-col overflow-hidden" style={{ left: showLeftPanel ? '256px' : '0px', right: showRightPanel ? '400px' : '80px' }}>
<div className="flex-1 flex items-center justify-center px-4 pt-4 overflow-hidden min-h-0">
<div className="w-full h-full max-w-6xl flex items-center justify-center">
<div className="w-full relative">
<div className="absolute top-3 right-3 z-10 flex items-center gap-2 text-gray-400 text-xs bg-gray-900/80 backdrop-blur-sm px-3 py-1.5 rounded-lg">
<span>Producido con</span>
<div className="flex items-center gap-1">
<div className="w-5 h-5 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
<span className="text-white text-xs font-bold">A</span>
</div>
<span className="font-semibold text-white">AvanzaCast</span>
</div>
</div>
<StreamView />
</div>
</div>
</div>
<div className="flex-none px-4 pb-4"><ControlPanel /></div>
</div>
<div className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ${showRightPanel ? 'translate-x-0' : 'translate-x-[320px]'}`}>
<div className="flex h-full">
<TabsColumn activeTab={activeRightTab} onChangeTab={(t: TabType) => setActiveRightTab(t)} />
<StudioRightPanel activeTab={activeRightTab} onChangeTab={(t: TabType) => setActiveRightTab(t)} />
</div>
</div>
<button onClick={() => setShowRightPanel(!showRightPanel)} className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300" style={{ right: showRightPanel ? '400px' : '0px' }}>
<svg width="16" height="101" viewBox="0 0 16 101" fill="none" className="hover:opacity-80 rotate-180">
<path d="M0 12C0 5.37258 5.37258 0 12 0H16V101H12C5.37258 101 0 95.6274 0 89V12Z" fill="#1F2937"/>
<rect x="13" y="42" width="3" height="17" rx="1.5" fill="white"/>
<path d={showRightPanel ? "M10 44L6 50.5L10 57" : "M6 44L10 50.5L10 57"} stroke="#FFF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
</div>
</SceneProvider>
</LiveKitRoom>
)
}
export default Studio

View File

@ -1,217 +0,0 @@
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'
interface Props {
onOpenPresentation?: () => void
onShareScreen?: () => void
sharedPresentation?: { type: 'screen' | 'file', url: string } | null
onClearPresentation?: () => void
layout?: 'grid' | 'focus'
mode?: 'video' | 'audio'
onChangeLayout?: (layout: 'grid'|'focus') => void
onChangeMode?: (mode: 'video'|'audio') => void
}
const StudioControls: React.FC<Props> = ({ onOpenPresentation, onShareScreen, sharedPresentation, onClearPresentation, layout, mode, onChangeLayout, onChangeMode }) => {
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={() => {
// Preferimos delegar a un handler externo (que abrirá el panel o iniciará screen share)
if (onOpenPresentation) return onOpenPresentation()
if (onShareScreen) return onShareScreen()
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>
{/* Botón rápido para abrir el panel de presentación (subir archivos) */}
<button
onClick={() => onOpenPresentation && onOpenPresentation()}
className="control-button bg-green-600 hover:bg-green-700 p-3 rounded-lg transition-colors"
title="Compartir presentación"
>
<MdPeople size={20} />
</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>
{/* Layout selector */}
<div className="ml-2 flex items-center gap-2">
<button
onClick={() => onChangeLayout && onChangeLayout('grid')}
className={`px-2 py-1 rounded ${layout === 'grid' ? 'bg-pink-600' : 'bg-gray-700'}`}
title="Grid layout"
>
Grid
</button>
<button
onClick={() => onChangeLayout && onChangeLayout('focus')}
className={`px-2 py-1 rounded ${layout === 'focus' ? 'bg-pink-600' : 'bg-gray-700'}`}
title="Focus layout"
>
Focus
</button>
<button onClick={() => onChangeMode && onChangeMode('video')} className={`px-2 py-1 rounded ${mode === 'video' ? 'bg-pink-600' : 'bg-gray-700'}`}>Video</button>
<button onClick={() => onChangeMode && onChangeMode('audio')} className={`px-2 py-1 rounded ${mode === 'audio' ? 'bg-pink-600' : 'bg-gray-700'}`}>Audio</button>
</div>
{/* 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 - Presentación / Salir */}
<div>
{sharedPresentation ? (
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-green-300">Presentación activa</span>
<button onClick={() => onClearPresentation && onClearPresentation()} className="px-3 py-1 bg-gray-700 text-white rounded">Detener</button>
</div>
) : null}
<button
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
onClick={() => {
if (confirm('¿Estás seguro de que quieres salir del estudio?')) {
window.location.href = '/'
}
}}
>
Salir
</button>
</div>
</div>
</div>
)
}
export default StudioControls

View File

@ -1,103 +1,36 @@
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)
function StudioHeader({ roomName, userName }: StudioHeaderProps) {
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="h-14 bg-gray-800 border-b border-gray-700 flex items-center justify-between px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded flex items-center justify-center">
<span className="text-white text-sm font-bold">A</span>
</div>
<div>
<h1 className="text-white font-semibold text-sm">AvanzaCast Studio</h1>
{roomName && <p className="text-gray-400 text-xs">Sala: {roomName}</p>}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-white text-sm font-medium">{userName}</p>
<p className="text-gray-400 text-xs">Transmitiendo</p>
</div>
<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 className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded transition-colors">
Configuración
</button>
<button className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded transition-colors">
Salir
</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>
</div>
)
}
export default StudioHeader

View File

@ -1,176 +1,43 @@
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)
}
function StudioLeftSidebar() {
return (
<div className="w-64 h-full 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 className="w-64 h-full bg-gray-800 border-r border-gray-700 overflow-y-auto">
<div className="p-4">
<h3 className="text-white font-semibold mb-4">Participantes</h3>
<div className="space-y-2">
<div className="p-3 bg-gray-700 rounded hover:bg-gray-600 transition-colors">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-purple-600 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
</div>
<div className="flex-1">
<p className="text-white text-sm font-medium"> (Anfitrión)</p>
<p className="text-gray-400 text-xs">En vivo</p>
</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>
</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 className="p-4 border-t border-gray-700">
<h3 className="text-white font-semibold mb-4">Fuentes</h3>
<div className="space-y-2">
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 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>
<span className="text-white text-sm">Cámara</span>
</div>
</button>
<button className="w-full p-3 bg-gray-700 hover:bg-gray-600 rounded text-left transition-colors">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span className="text-white text-sm">Pantalla</span>
</div>
</button>
</div>
</div>
</div>
)

View File

@ -1,417 +1,516 @@
import { useState } from 'react'
import {
MdBrush,
MdImage,
MdMusicNote,
MdVideoLibrary,
MdQrCode,
MdTimer,
MdSettings,
MdPeople,
MdChat,
MdComment,
} from 'react-icons/md'
import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo'
import ParticipantsPanel from './ParticipantsPanel'
export type TabType = 'comments' | 'banners' | 'brand' | 'style' | 'notes' | 'participants' | 'chat'
// tabs shared entre el panel y el elemento externo (TabsColumn)
const tabs: { id: TabType; icon: any; label: string; badge: number }[] = [
{ id: 'comments', icon: MdComment, label: 'Comentarios', badge: 0 },
{ id: 'banners', icon: MdImage, label: 'Banners', badge: 0 },
{ id: 'brand', icon: MdBrush, label: 'Activos multimedia', badge: 0 },
{ id: 'style', icon: MdBrush, label: 'Estilo', badge: 0 },
{ id: 'notes', icon: MdTimer, label: 'Notas', badge: 0 },
{ id: 'participants', icon: MdPeople, label: 'Personas', badge: 0 },
{ id: 'chat', icon: MdChat, label: 'Chat privado', badge: 0 },
]
// Componente reutilizable para renderizar la columna de tabs (puede usarse fuera del panel)
export const TabsColumn: React.FC<{ activeTab: TabType; onChangeTab: (t: TabType) => void }> = ({ activeTab, onChangeTab }) => {
return (
<div role="tablist" className="w-20 bg-gray-800 border-l border-gray-700 flex flex-col">
{tabs.map((tab) => {
const Icon = tab.icon
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
aria-controls={`right-panel-content-${tab.id}`}
id={`right-panel-tab-${tab.id}`}
onClick={() => onChangeTab(tab.id)}
className={`relative flex flex-col items-center gap-1 py-4 px-2 border-r-2 transition-all ${
isActive
? 'bg-gray-700 border-pink-500 text-pink-500'
: 'border-transparent text-gray-400 hover:text-white hover:bg-gray-750'
}`}
>
<div className="relative">
<Icon size={24} />
{tab.badge > 0 && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-pink-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">{tab.badge}</span>
</div>
)}
</div>
<span className="text-xs font-medium text-center leading-tight">{tab.label}</span>
</button>
)
})}
</div>
)
interface TabsColumnProps {
activeTab: TabType
onChangeTab: (tab: TabType) => void
onTogglePanel?: () => void
isCollapsed?: boolean
}
const StudioRightPanel = ({ roomName, activeTab: activeTabProp, onChangeTab }: { roomName?: string; activeTab?: TabType; onChangeTab?: (t: TabType) => void }) => {
const [internalActiveTab, setInternalActiveTab] = useState<TabType>('brand')
const activeTab = activeTabProp ?? internalActiveTab
const setActiveTab = onChangeTab ?? setInternalActiveTab
export function TabsColumn({ activeTab, onChangeTab, onTogglePanel, isCollapsed }: TabsColumnProps) {
const tabs: { id: TabType; icon: JSX.Element; label: string }[] = [
{
id: 'comments',
label: 'Comentarios',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
},
{
id: 'banners',
label: 'Banners',
icon: (
<svg className="w-6 h-6" 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>
),
},
{
id: 'brand',
label: 'Activos multimedia',
icon: (
<svg className="w-6 h-6" 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>
),
},
{
id: 'style',
label: 'Estilo',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
},
{
id: 'notes',
label: 'Notas',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
id: 'participants',
label: 'Personas',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
},
]
return (
<div className="flex h-full">
{/* Content Panel - Aside como en Streamyard */}
<aside className="flex-1 bg-gray-800 flex flex-col" style={{ width: '320px' }}>
<div
id="right-panel-content"
role="tabpanel"
aria-labelledby={`right-panel-tab-${activeTab}`}
className="flex-1 flex flex-col h-full"
<div className="w-20 h-full bg-gray-900 flex flex-col items-center py-2 gap-1 border-l-2 border-gray-700">
{/* Botón de colapso en la parte superior */}
{onTogglePanel && (
<button
onClick={onTogglePanel}
className="flex flex-col items-center justify-center w-16 h-12 rounded-lg transition-all text-gray-400 hover:bg-gray-800 hover:text-white mb-2"
title={isCollapsed ? "Expandir panel" : "Colapsar panel"}
>
{activeTab === 'comments' && <CommentsTab />}
{activeTab === 'banners' && <BannersTab />}
{activeTab === 'brand' && <BrandTab />}
{activeTab === 'style' && <StyleTab />}
{activeTab === 'notes' && <NotesTab />}
{activeTab === 'participants' && <ParticipantsPanel roomName={roomName} />}
{activeTab === 'chat' && <ChatTab />}
</div>
</aside>
</div>
)
}
// Tab de Comentarios (Streamyard style)
const CommentsTab = () => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm mb-2">Comentarios</h3>
<p className="text-gray-400 text-xs">
Puedes destacar comentarios importantes para que recuerdes comentarlos durante el programa.
</p>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<MdComment size={48} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-400 text-sm">No hay comentarios destacados</p>
<button className="mt-3 text-pink-500 text-xs hover:underline">
Agrega un destino para publicar comentarios
</button>
</div>
</div>
{/* Input area */}
<div className="p-4 border-t border-gray-700">
<div className="bg-gray-700 rounded-lg p-3">
<textarea
placeholder="Escribe un comentario..."
disabled
className="w-full bg-transparent text-gray-500 text-sm resize-none outline-none"
rows={2}
/>
<div className="flex justify-end mt-2">
<button
disabled
className="px-3 py-1.5 bg-gray-600 text-gray-400 rounded text-xs font-medium cursor-not-allowed"
>
Enviar
</button>
</div>
</div>
</div>
</div>
)
}
// Tab de Banners (Streamyard style)
const BannersTab = () => {
const [banners] = useState([
{ id: '1', text: 'Este es un ejemplo de banner. Haz clic en un banner para mostrarlo en la pantalla.' },
{ id: '2', text: 'Utiliza banners para resumir los temas de los que estás hablando y mostrar llamadas a la acción' },
{ id: '3', text: 'Banner' },
])
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<h3 className="text-white font-semibold text-sm">Banners de ejemplo</h3>
<button className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors">
<MdSettings size={18} />
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={isCollapsed ? "M15 19l-7-7 7-7" : "M9 5l7 7-7 7"}
/>
</svg>
</button>
</div>
)}
{/* Tabs */}
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChangeTab(tab.id)}
className={`flex flex-col items-center justify-center w-16 h-16 rounded-lg transition-all ${
activeTab === tab.id
? 'bg-blue-600 text-white shadow-lg'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
title={tab.label}
>
{tab.icon}
<span className="text-[10px] mt-1 leading-tight text-center">{tab.label.split(' ')[0]}</span>
</button>
))}
</div>
)
}
{/* Banners list */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{banners.map((banner) => (
<div
key={banner.id}
className="bg-gray-700 rounded-lg p-3 hover:bg-gray-600 transition-colors cursor-pointer group relative"
>
<div className="flex items-start gap-3">
<button className="flex-shrink-0 px-3 py-1 bg-pink-500 hover:bg-pink-600 text-white text-xs rounded font-medium transition-colors">
Mostrar
interface StudioRightPanelProps {
activeTab: TabType
onChangeTab: (tab: TabType) => void
}
function StudioRightPanel({ activeTab }: StudioRightPanelProps) {
return (
<div className="w-96 bg-gray-900 h-full overflow-y-auto border-r-2 border-gray-700">
<div className="p-6">
{activeTab === 'comments' && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-white font-semibold text-lg">Comentarios</h3>
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
<p className="flex-1 text-gray-300 text-sm">{banner.text}</p>
</div>
{/* Hover actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-1.5 bg-gray-800 rounded hover:bg-gray-700 text-gray-400 hover:text-white">
<MdBrush size={14} />
<div className="bg-gray-800 rounded-lg p-4 mb-4">
<div className="flex items-start gap-2 mb-3">
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">S</span>
</div>
<div className="flex-1">
<p className="text-white text-sm font-medium mb-1">Streamford</p>
<p className="text-gray-400 text-sm">Personaliza las características en vivo como este es un ejemplo. Haz clic en un comando para mostrarlo en la pantalla.</p>
</div>
</div>
<button className="flex items-center justify-center gap-2 w-full py-2 bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors">
<svg className="w-4 h-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-white text-sm font-medium">Mostrar</span>
</button>
<button className="p-1.5 bg-gray-800 rounded hover:bg-gray-700 text-red-400 hover:text-red-300">
<MdTimer size={14} />
</div>
<div className="border-t border-gray-800 pt-4">
<div className="relative">
<input
type="text"
placeholder="Agrega un destino para publicar comentarios."
className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-blue-500 pr-10"
/>
<button className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-blue-500">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
)}
{activeTab === 'banners' && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 className="text-white font-semibold text-lg">Banners de ejemplo</h3>
</div>
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
<div className="space-y-3 mb-6">
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer">
<p className="text-white text-sm mb-2">Este es un ejemplo de banner. Haz clic en un banner para mostrarlo en la pantalla.</p>
<p className="text-gray-400 text-xs">Utiliza banners para resumir los temas de los que estás hablando y mostrar llamadas a la acción</p>
</div>
<div className="bg-gray-800 rounded-lg p-4 hover:bg-gray-750 transition-colors cursor-pointer">
<p className="text-white text-sm font-medium">Banner</p>
</div>
</div>
<button className="flex items-center gap-2 text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<span className="text-sm">Crear un banner</span>
</button>
</div>
)}
{activeTab === 'brand' && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">M</span>
</div>
<h3 className="text-white font-semibold text-lg">Marca 1</h3>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* Logo */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Logo</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Superposición */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Superposición</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Código QR */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Código QR</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Clips de video */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Clips de video</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Fondo */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Fondo</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Sonidos */}
<div className="border-b border-gray-800 pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Sonidos</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Música de fondo */}
<div className="pb-4">
<button className="flex items-center justify-between w-full text-left group">
<span className="text-white font-medium">Música de fondo</span>
<svg className="w-5 h-5 text-gray-400 group-hover:text-white transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
)}
{activeTab === 'style' && (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-gradient-to-br from-pink-500 to-purple-600 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">M</span>
</div>
<h3 className="text-white font-semibold text-lg">Marca 1</h3>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
{/* Ajustes preestablecidos */}
<div className="mb-6">
<button className="flex items-center justify-between w-full text-left mb-3">
<span className="text-white font-medium">Ajustes preestablecidos</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="flex gap-3 overflow-x-auto pb-2">
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-blue-500 flex items-center justify-center cursor-pointer">
<div className="text-center">
<div className="w-8 h-8 bg-gray-700 rounded-full mx-auto mb-1"></div>
<div className="text-xs text-gray-400">Preset 1</div>
</div>
</div>
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-transparent hover:border-gray-600 flex items-center justify-center cursor-pointer">
<div className="text-center">
<div className="w-8 h-8 bg-yellow-500 rounded-full mx-auto mb-1"></div>
<div className="text-xs text-gray-400">Preset 2</div>
</div>
</div>
<div className="flex-shrink-0 w-24 h-24 bg-gray-800 rounded-lg border-2 border-transparent hover:border-gray-600 flex items-center justify-center cursor-pointer">
<div className="text-center">
<div className="w-8 h-8 bg-red-500 rounded-full mx-auto mb-1"></div>
<div className="text-xs text-gray-400">Preset 3</div>
</div>
</div>
</div>
</div>
{/* Color de la marca */}
<div className="mb-6">
<button className="flex items-center justify-between w-full text-left mb-3">
<span className="text-white font-medium">Color de la marca</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-yellow-500 rounded-lg border-2 border-gray-700"></div>
<input
type="text"
value="#ffc42c"
className="flex-1 px-3 py-2 bg-gray-800 text-white rounded border border-gray-700 focus:outline-none focus:border-blue-500"
readOnly
/>
</div>
</div>
{/* Tema */}
<div className="mb-6">
<button className="flex items-center justify-between w-full text-left mb-3">
<span className="text-white font-medium">Tema</span>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="grid grid-cols-2 gap-3">
<button className="p-3 bg-yellow-500 text-gray-900 rounded-lg font-medium hover:bg-yellow-400 transition-colors">
Bubble
</button>
<button className="p-3 bg-gray-800 text-white rounded-lg font-medium hover:bg-gray-700 transition-colors border border-yellow-500">
Classic
</button>
<button className="p-3 bg-gray-800 text-white rounded-lg font-medium hover:bg-gray-700 transition-colors">
Minimal
</button>
<button className="p-3 bg-gray-900 text-white rounded-lg font-medium hover:bg-gray-800 transition-colors border border-yellow-500">
Block
</button>
</div>
</div>
{/* Mostrar nombres */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-white font-medium">Mostrar nombres</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-blue-600">
<span className="translate-x-6 inline-block h-4 w-4 transform rounded-full bg-white transition"></span>
</button>
</div>
</div>
))}
</div>
)}
{/* Add banner button */}
<div className="p-3 border-t border-gray-700">
<button className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
<MdImage size={18} />
Crear un banner
</button>
</div>
</div>
)
}
// Tab de Notas
const NotesTab = () => {
const [notes, setNotes] = useState('')
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Notas</h3>
</div>
{/* Notes editor */}
<div className="flex-1 p-4">
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Escribe tus notas aquí..."
className="w-full h-full bg-gray-700 text-white p-3 rounded-lg text-sm resize-none outline-none border border-gray-600 focus:border-pink-500"
/>
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700">
<div className="flex items-center justify-between text-xs text-gray-400">
<span>{notes.length} caracteres</span>
<button
onClick={() => setNotes('')}
className="text-pink-500 hover:underline"
>
Limpiar
</button>
</div>
</div>
</div>
)
}
// Tab de Chat Privado
const ChatTab = () => {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Chat Privado</h3>
<p className="text-gray-400 text-xs mt-1">
Chatea con los participantes sin que se vea en el stream
</p>
</div>
{/* Empty state */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<MdChat size={48} className="mx-auto text-gray-600 mb-3" />
<p className="text-gray-400 text-sm">No hay mensajes</p>
<p className="text-gray-500 text-xs mt-2">
Los mensajes aparecerán aquí cuando haya participantes
</p>
</div>
</div>
{/* Input area */}
<div className="p-4 border-t border-gray-700">
<div className="bg-gray-700 rounded-lg p-3">
<input
type="text"
placeholder="Escribe un mensaje..."
disabled
className="w-full bg-transparent text-gray-500 text-sm outline-none"
/>
</div>
</div>
</div>
)
}
// Tab de Estilo (nuevo - reemplaza parte de Brand)
const StyleTab = () => {
const [selectedTheme, setSelectedTheme] = useState(COLOR_THEMES[0])
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700">
<h3 className="text-white font-semibold text-sm">Estilo</h3>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-3">Tema de Color</h4>
<div className="grid grid-cols-2 gap-2">
{COLOR_THEMES.map((theme) => (
<button
key={theme.id}
onClick={() => setSelectedTheme(theme)}
className={`p-3 rounded-lg border-2 transition-all ${
selectedTheme.id === theme.id
? 'border-pink-500 bg-gray-700'
: 'border-gray-700 hover:border-gray-500'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded-full"
style={{ backgroundColor: theme.primary }}
/>
<span className="text-white text-xs font-medium">{theme.name}</span>
</div>
</button>
))}
</div>
</div>
</div>
</div>
)
}
// Tab de Marca/Multimedia (Brand) - Activos multimedia
const BrandTab = () => {
const [logoUrl, setLogoUrl] = useState<string | null>(null)
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)
}
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div>
<h3 className="text-white font-semibold text-sm">Marca 1</h3>
<p className="text-gray-400 text-xs mt-0.5">Activos multimedia</p>
</div>
<button className="p-1 rounded hover:bg-gray-700 text-gray-400 hover:text-white transition-colors">
<MdSettings size={18} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Logo section */}
<div>
<h4 className="text-white text-sm font-semibold mb-3">Logo</h4>
<div className="grid grid-cols-2 gap-2">
{logoUrl && (
<div className="aspect-square bg-gray-700 rounded-lg p-2 flex items-center justify-center border-2 border-pink-500">
<img src={logoUrl} alt="Logo" className="max-w-full max-h-full object-contain" />
{activeTab === 'notes' && (
<div>
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-semibold text-lg">Notas</h3>
<div className="flex items-center gap-2">
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded font-bold">
<span>B</span>
</button>
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded italic">
<span>I</span>
</button>
<button className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-800 rounded underline">
<span>U</span>
</button>
</div>
)}
<label className="aspect-square border-2 border-dashed border-gray-700 rounded-lg flex flex-col items-center justify-center gap-1 hover:border-pink-500 transition cursor-pointer">
<input
type="file"
accept="image/*"
onChange={handleLogoUpload}
className="hidden"
/>
<MdImage size={24} className="text-gray-500" />
<span className="text-gray-500 text-xs">Subir logo</span>
</label>
</div>
</div>
</div>
{/* Fondos section */}
<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 className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-white font-medium text-sm">Teleprompter</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg className="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
<div className="ml-auto">
<button className="relative inline-flex h-6 w-11 items-center rounded-full bg-gray-700">
<span className="translate-x-1 inline-block h-4 w-4 transform rounded-full bg-white transition"></span>
</button>
</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={20} className="text-gray-500" />
<span className="text-gray-500 text-xs">Subir</span>
</button>
</div>
</div>
</div>
</div>
{/* Overlays section */}
<div>
<h4 className="text-white text-sm font-semibold mb-3">Overlays</h4>
<div className="space-y-2">
{DEMO_OVERLAYS.slice(0, 3).map((overlay) => (
<button
key={overlay.id}
className="w-full p-3 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors text-left"
>
<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>
<textarea
className="w-full h-64 px-4 py-3 bg-gray-800 text-white rounded-lg border border-gray-700 focus:outline-none focus:border-blue-500 resize-none"
placeholder="Agrega tus notas aquí..."
></textarea>
<div className="mt-2 text-xs text-gray-500">
0 palabras 0 caracteres
</div>
</div>
)}
{activeTab === 'participants' && (
<div>
<div className="mb-6">
<h3 className="text-white font-semibold text-lg mb-4">Comparte este enlace para invitar a los invitados en vivo</h3>
<button className="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className="font-medium">Copiar enlace de invitación</span>
</button>
</div>
<div className="border-t border-gray-800 pt-4">
<button className="flex items-center justify-between w-full text-left mb-4">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<span className="text-white font-medium">Escenario 1 persona</span>
</div>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-white font-bold">CM</span>
</div>
<div className="flex-1">
<p className="text-white font-medium">Cesar Mendivil</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-blue-400">Anfitrión</span>
<span className="text-xs text-gray-500">480p</span>
</div>
</div>
<div className="flex items-center gap-2">
<button className="p-1.5 text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
</svg>
</button>
<button className="text-gray-400 hover:text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
<MdImage className="text-pink-500" size={20} />
</div>
</button>
))}
<div className="mt-3 flex items-center gap-2">
<div className="flex-1 h-1 bg-gray-700 rounded-full overflow-hidden">
<div className="h-full w-2/3 bg-green-500"></div>
</div>
<label className="flex items-center gap-2 text-xs text-gray-400">
<input type="checkbox" className="rounded" defaultChecked />
<span>Auto</span>
</label>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)

View File

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

View File

@ -1,45 +0,0 @@
import React from 'react'
import { DEMO_PARTICIPANTS } from '../config/demo'
import VideoConference from '../prefabs/VideoConference'
import AudioConference from '../prefabs/AudioConference'
interface StudioVideoAreaProps {
isDemoMode?: boolean
layout?: 'grid' | 'focus'
mode?: 'video' | 'audio'
}
const DemoModeView: React.FC = () => {
return (
<div className="flex-1 flex flex-col bg-gray-950">
<div className="flex-1 p-4 grid grid-cols-2 gap-2">
{DEMO_PARTICIPANTS.map((p) => (
<div key={p.id} className="bg-gray-800 rounded-lg p-3 flex flex-col items-center justify-center">
<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-2xl font-bold mb-2">
{p.name.charAt(0).toUpperCase()}
</div>
<div className="text-white text-sm font-medium">{p.name}</div>
<div className="text-gray-400 text-xs">{p.isMicrophoneEnabled ? 'Mic encendido' : 'Mic apagado'}</div>
</div>
))}
</div>
<div className="bg-gray-900 border-t border-gray-800 p-4">
<div className="flex items-center gap-3">
<h4 className="text-white text-sm font-medium">Participantes en Studio</h4>
<span className="text-gray-400 text-xs">({DEMO_PARTICIPANTS.length})</span>
</div>
</div>
</div>
)
}
const LiveKitModeView: React.FC<{ layout: 'grid' | 'focus'; mode: 'video' | 'audio' }> = ({ layout, mode }) => {
if (mode === 'audio') return <AudioConference />
return <VideoConference layout={layout} />
}
const StudioVideoArea: React.FC<StudioVideoAreaProps> = ({ isDemoMode = false, layout = 'grid', mode = 'video' }) => {
return isDemoMode ? <DemoModeView /> : <LiveKitModeView layout={layout} mode={mode} />
}
export default StudioVideoArea

View File

@ -0,0 +1,32 @@
import React from 'react'
import { SceneProvider } from '../../context/SceneContext'
import StreamView from './StreamView'
import ControlPanel from './ControlPanel'
/**
* BroadcastStudio - Contenedor Principal de la UI del Estudio
*
* Estructura:
* - MainAppContainer: Contenedor centrado y alineado
* - StreamViewContainer: Área de visualización 16:9
* - ControlPanelWrapper: Barra de control (mismo ancho que StreamView)
*/
const BroadcastStudio: React.FC = () => {
return (
<SceneProvider>
<div className="main-app-container flex flex-col items-center min-h-screen bg-gray-950 p-6">
{/* StreamView Container - Salida de video 16:9 */}
<div className="stream-view-container w-full max-w-6xl mb-4">
<StreamView />
</div>
{/* Control Panel - Mismo ancho que StreamView */}
<div className="control-panel-wrapper w-full max-w-6xl">
<ControlPanel />
</div>
</div>
</SceneProvider>
)
}
export default BroadcastStudio

View File

@ -0,0 +1,151 @@
import React from 'react'
import { useLocalParticipant, useParticipants } from '@livekit/components-react'
import { useScene, PRESET_LAYOUTS } from '../../context/SceneContext'
/**
* LocalControls - Sección Izquierda del Panel de Control (estilo StreamYard)
* Vista previa local compacta + botón "Presentar o invitar"
*/
const LocalControls: React.FC = () => {
const { localParticipant } = useLocalParticipant()
const participants = useParticipants()
return (
<div className="local-controls flex-shrink-0 flex items-center gap-3">
{/* Preview local en esquina inferior izquierda (estilo StreamYard) */}
<div className="relative bg-gray-800 rounded-lg overflow-hidden w-32 h-24 border-2 border-gray-700">
{/* Avatar o video local */}
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-900 to-gray-800">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white text-lg font-bold">
{localParticipant?.identity?.charAt(0).toUpperCase() || 'U'}
</div>
</div>
{/* Nombre en overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-2 py-1">
<p className="text-white text-xs font-medium truncate">
{localParticipant?.identity || 'Tú'}
</p>
</div>
{/* Puntos de menú */}
<button className="absolute top-1 right-1 w-5 h-5 bg-black/40 hover:bg-black/60 rounded flex items-center justify-center transition-colors">
<span className="text-white text-xs"></span>
</button>
</div>
{/* Botón "Presentar o invitar" (estilo StreamYard) */}
<button className="bg-gray-800 hover:bg-gray-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-2 border border-gray-700">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
<span>Presentar o invitar</span>
</button>
</div>
)
}
/**
* ScrollableLayoutsContainer - Sección Central con Scroll Horizontal (estilo StreamYard)
* Botones de layout más compactos y agrupados
*/
const ScrollableLayoutsContainer: React.FC = () => {
const { sceneConfig, applyPreset } = useScene()
const layoutButtons = [
{ id: 'SINGLE_SPEAKER', label: '1 Persona', icon: '👤' },
{ id: 'SIDE_BY_SIDE', label: '2 Personas', icon: '👥' },
{ id: 'GRID_4', label: '2×2', icon: '⊞' },
{ id: 'GRID_6', label: '3×2', icon: '⊠' },
{ id: 'FOCUS_SIDE', label: 'Foco', icon: '◧' },
{ id: 'PRESENTATION', label: 'Pantalla', icon: '🖥️' },
]
return (
<div className="scrollable-layouts-container flex-grow overflow-x-auto overflow-y-hidden">
<div className="layout-buttons-row flex flex-nowrap gap-2 h-full items-center">
{layoutButtons.map((btn) => {
const isActive = sceneConfig.participantLayout === PRESET_LAYOUTS[btn.id as keyof typeof PRESET_LAYOUTS]?.participantLayout
return (
<button
key={btn.id}
onClick={() => applyPreset(btn.id as keyof typeof PRESET_LAYOUTS)}
className={`
flex-shrink-0 h-16 w-16 rounded-lg border transition-all flex flex-col items-center justify-center gap-1
${isActive
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-500/50'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600 hover:bg-gray-750'
}
`}
title={btn.label}
>
<div className="text-xl leading-none">{btn.icon}</div>
<div className="text-[10px] font-medium leading-none">{btn.label}</div>
</button>
)
})}
</div>
</div>
)
}
/**
* ActionControls - Sección Derecha del Panel de Control (estilo StreamYard)
* Botones de acción compactos
*/
const ActionControls: React.FC = () => {
return (
<div className="action-controls flex-shrink-0 flex gap-2 items-center">
<button
className="bg-blue-600 hover:bg-blue-700 text-white h-16 px-4 rounded-lg transition-colors flex items-center gap-2 font-medium shadow-lg"
title="Editor de escenas"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span className="text-sm">Editar</span>
</button>
<button
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white h-16 w-16 rounded-lg transition-colors flex items-center justify-center"
title="Añadir recursos"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 text-white h-16 w-16 rounded-lg transition-colors flex items-center justify-center"
title="Configuración"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
)
}
/**
* ControlPanel - Panel Interactivo Adaptable (3 secciones) - Estilo StreamYard
*
* Estructura horizontal compacta: LocalControls | ScrollableLayouts | ActionControls
*/
const ControlPanel: React.FC = () => {
return (
<div className="control-panel-wrapper w-full max-w-6xl mx-auto">
<div className="bg-gray-900 border border-gray-800 rounded-xl p-3 shadow-2xl">
<div className="flex items-center gap-3 h-20">
<LocalControls />
<ScrollableLayoutsContainer />
<ActionControls />
</div>
</div>
</div>
)
}
export default ControlPanel

View File

@ -0,0 +1,52 @@
import React from 'react'
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react'
import '@livekit/components-styles'
import BroadcastStudio from './BroadcastStudio'
interface LiveKitBroadcastWrapperProps {
token: string
serverUrl: string
userName: string
roomName: string
onDisconnect?: () => void
}
/**
* LiveKitBroadcastWrapper - Envuelve el BroadcastStudio con LiveKitRoom
*
* Proporciona el contexto de LiveKit (participantes, tracks, etc.)
* al BroadcastStudio y sus componentes hijos.
*/
const LiveKitBroadcastWrapper: React.FC<LiveKitBroadcastWrapperProps> = ({
token,
serverUrl,
userName,
roomName,
onDisconnect,
}) => {
return (
<LiveKitRoom
token={token}
serverUrl={serverUrl}
connect={true}
audio={true}
video={true}
onDisconnected={() => {
console.log('[LiveKitBroadcast] Desconectado de la sala')
onDisconnect?.()
}}
onError={(error) => {
console.error('[LiveKitBroadcast] Error en LiveKit:', error)
}}
className="h-full"
>
{/* Audio renderer para escuchar a los participantes */}
<RoomAudioRenderer />
{/* Interfaz del estudio */}
<BroadcastStudio />
</LiveKitRoom>
)
}
export default LiveKitBroadcastWrapper

View File

@ -0,0 +1,178 @@
import React from 'react'
import { useParticipants, useTracks, VideoTrack } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useScene } from '../../context/SceneContext'
/**
* StreamView - La Plantilla de Visualización (Salida de Video 16:9) - Estilo StreamYard
*
* Renderiza el resultado final con overlays y branding
*/
const StreamView: React.FC = () => {
const { sceneConfig } = useScene()
const participants = useParticipants()
const videoTracks = useTracks([Track.Source.Camera], { onlySubscribed: false })
// Renderizar overlays (logos, lower thirds) según configuración
const renderOverlays = () => {
const { overlays } = sceneConfig
return (
<>
{/* Logo superior izquierdo */}
{overlays.logo && (
<div className="absolute top-4 left-4 z-10">
<img src={overlays.logo.url} alt="Logo" className="h-12 drop-shadow-lg" />
</div>
)}
{/* Lower third - Nombre del presentador (estilo StreamYard) */}
{overlays.lowerThird && (
<div className="absolute bottom-8 left-6 z-10 bg-gradient-to-r from-purple-600 to-pink-600 px-4 py-2 rounded-lg shadow-xl">
<p className="text-white font-bold text-lg">{overlays.lowerThird.name}</p>
{overlays.lowerThird.title && (
<p className="text-white/90 text-sm">{overlays.lowerThird.title}</p>
)}
</div>
)}
{/* Overlays de engagement (LIKE, SUBSCRIBE) - estilo StreamYard */}
<div className="absolute bottom-32 left-6 flex flex-col gap-2 z-10">
<div className="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-full flex items-center gap-2 shadow-lg cursor-pointer transform hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.56 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
</svg>
<span className="text-white font-bold text-sm">LIKE</span>
</div>
<div className="bg-red-600 hover:bg-red-700 px-4 py-2 rounded-full flex items-center gap-2 shadow-lg cursor-pointer transform hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
<span className="text-white font-bold text-sm">SUBSCRIBE</span>
</div>
</div>
{/* Indicador de calidad 720p (superior izquierdo) */}
<div className="absolute top-4 left-4 z-10 bg-black/60 backdrop-blur-sm px-3 py-1 rounded-md">
<span className="text-white text-xs font-bold">720p</span>
</div>
</>
)
}
// Renderizar layout según configuración
const renderLayout = () => {
const { participantLayout, mediaSource } = sceneConfig
switch (participantLayout) {
case 'grid_4':
return (
<div className="grid grid-cols-2 grid-rows-2 gap-2 w-full h-full p-4">
{videoTracks.slice(0, 4).map((track, idx) => (
<div key={idx} className="bg-gray-900 rounded-lg overflow-hidden">
<VideoTrack trackRef={track} />
</div>
))}
</div>
)
case 'grid_6':
return (
<div className="grid grid-cols-3 grid-rows-2 gap-2 w-full h-full p-4">
{videoTracks.slice(0, 6).map((track, idx) => (
<div key={idx} className="bg-gray-900 rounded-lg overflow-hidden">
<VideoTrack trackRef={track} />
</div>
))}
</div>
)
case 'focus_side':
return (
<div className="flex gap-2 w-full h-full p-4">
{/* Main speaker */}
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
</div>
{/* Sidebar con otros participantes */}
<div className="w-48 flex flex-col gap-2">
{videoTracks.slice(1, 4).map((track, idx) => (
<div key={idx} className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
<VideoTrack trackRef={track} />
</div>
))}
</div>
</div>
)
case 'side_by_side':
return (
<div className="flex gap-4 w-full h-full p-4">
{videoTracks.slice(0, 2).map((track, idx) => (
<div key={idx} className="flex-1 bg-gray-900 rounded-lg overflow-hidden">
<VideoTrack trackRef={track} />
</div>
))}
</div>
)
case 'presentation':
return (
<div className="relative w-full h-full p-4">
{/* Área de presentación */}
<div className="w-full h-full bg-gray-900 rounded-lg overflow-hidden flex items-center justify-center">
{mediaSource?.type === 'screen' && mediaSource.stream && (
<video
ref={(el) => {
if (el && mediaSource.stream) {
el.srcObject = mediaSource.stream
el.play()
}
}}
className="w-full h-full object-contain"
autoPlay
playsInline
/>
)}
{mediaSource?.type === 'file' && mediaSource.url && (
<img src={mediaSource.url} alt="Presentation" className="w-full h-full object-contain" />
)}
{!mediaSource && (
<div className="text-gray-500 text-xl">Esperando contenido...</div>
)}
</div>
{/* Speaker pequeño en esquina */}
<div className="absolute bottom-8 right-8 w-48 h-32 bg-gray-900 rounded-lg overflow-hidden shadow-2xl">
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
</div>
</div>
)
case 'single_speaker':
return (
<div className="w-full h-full p-4">
<div className="w-full h-full bg-gray-900 rounded-lg overflow-hidden">
{videoTracks[0] && <VideoTrack trackRef={videoTracks[0]} />}
</div>
</div>
)
default:
return (
<div className="flex items-center justify-center w-full h-full text-gray-500">
Layout no configurado
</div>
)
}
}
return (
<div className="stream-view-container relative w-full bg-black rounded-lg overflow-hidden shadow-2xl" style={{ aspectRatio: '16 / 9' }}>
{renderLayout()}
{renderOverlays()}
</div>
)
}
export default StreamView

View File

@ -0,0 +1,5 @@
export { default as BroadcastStudio } from './BroadcastStudio'
export { default as StreamView } from './StreamView'
export { default as ControlPanel } from './ControlPanel'
export { default as LiveKitBroadcastWrapper } from './LiveKitBroadcastWrapper'
export * from '../../context/SceneContext'

View File

@ -0,0 +1,114 @@
import React, { createContext, useContext, useState, ReactNode } from 'react'
// Tipos de layouts disponibles
export type ParticipantLayoutType =
| 'grid_4' // Grid 2x2
| 'grid_6' // Grid 3x2
| 'focus_side' // Foco principal + sidebar
| 'side_by_side' // Lado a lado
| 'presentation' // Presentación con speaker pequeño
| 'single_speaker' // Un solo participante
export type MediaSourceType = {
type: 'screen' | 'file' | 'image' | 'pdf' | 'video' | null
url?: string
stream?: MediaStream
}
export type OverlayConfig = {
showLowerThird?: boolean
lowerThirdText?: string
showLogo?: boolean
logoPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}
export interface SceneConfig {
participantLayout: ParticipantLayoutType
mediaSource: MediaSourceType | null
overlays: OverlayConfig
}
// Presets de layouts predefinidos
export const PRESET_LAYOUTS: Record<string, SceneConfig> = {
GRID_4: {
participantLayout: 'grid_4',
mediaSource: null,
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'top-right' },
},
GRID_6: {
participantLayout: 'grid_6',
mediaSource: null,
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'top-right' },
},
FOCUS_SIDE: {
participantLayout: 'focus_side',
mediaSource: null,
overlays: { showLowerThird: true, showLogo: true, logoPosition: 'top-right' },
},
SIDE_BY_SIDE: {
participantLayout: 'side_by_side',
mediaSource: null,
overlays: { showLowerThird: false, showLogo: true, logoPosition: 'bottom-right' },
},
PRESENTATION: {
participantLayout: 'presentation',
mediaSource: null,
overlays: { showLowerThird: false, showLogo: false },
},
SINGLE_SPEAKER: {
participantLayout: 'single_speaker',
mediaSource: null,
overlays: { showLowerThird: true, showLogo: true, logoPosition: 'top-left' },
},
}
interface SceneContextType {
sceneConfig: SceneConfig
setSceneConfig: (config: SceneConfig) => void
applyPreset: (presetKey: keyof typeof PRESET_LAYOUTS) => void
updateMediaSource: (source: MediaSourceType | null) => void
updateOverlays: (overlays: Partial<OverlayConfig>) => void
}
const SceneContext = createContext<SceneContextType | undefined>(undefined)
export const SceneProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [sceneConfig, setSceneConfig] = useState<SceneConfig>(PRESET_LAYOUTS.GRID_4)
const applyPreset = (presetKey: keyof typeof PRESET_LAYOUTS) => {
setSceneConfig(PRESET_LAYOUTS[presetKey])
}
const updateMediaSource = (source: MediaSourceType | null) => {
setSceneConfig((prev) => ({ ...prev, mediaSource: source }))
}
const updateOverlays = (overlays: Partial<OverlayConfig>) => {
setSceneConfig((prev) => ({
...prev,
overlays: { ...prev.overlays, ...overlays },
}))
}
return (
<SceneContext.Provider
value={{
sceneConfig,
setSceneConfig,
applyPreset,
updateMediaSource,
updateOverlays,
}}
>
{children}
</SceneContext.Provider>
)
}
export const useScene = () => {
const context = useContext(SceneContext)
if (!context) {
throw new Error('useScene must be used within SceneProvider')
}
return context
}