From 78e83b46dda73272b5fe6477a89ccd92dfaeeaac Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Fri, 7 Nov 2025 23:22:35 -0700 Subject: [PATCH] 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 --- .../studio-panel/docs/BROADCAST_STUDIO_UI.md | 234 ++++ .../studio-panel/docs/INTEGRATION_EXAMPLE.tsx | 36 + .../studio-panel/src/components/Studio.tsx | 1046 +++++------------ .../src/components/Studio.tsx.backup | 789 +++++++++++++ .../components/Studio.tsx.backup-1762577713 | 167 +++ .../src/components/StudioControls.tsx | 217 ---- .../src/components/StudioHeader.tsx | 109 +- .../src/components/StudioLeftSidebar.tsx | 201 +--- .../src/components/StudioRightPanel.tsx | 879 ++++++++------ .../src/components/StudioSidebar.tsx | 186 --- .../src/components/StudioVideoArea.tsx | 45 - .../components/broadcast/BroadcastStudio.tsx | 32 + .../src/components/broadcast/ControlPanel.tsx | 151 +++ .../broadcast/LiveKitBroadcastWrapper.tsx | 52 + .../src/components/broadcast/StreamView.tsx | 178 +++ .../src/components/broadcast/index.ts | 5 + .../studio-panel/src/context/SceneContext.tsx | 114 ++ 17 files changed, 2568 insertions(+), 1873 deletions(-) create mode 100644 packages/studio-panel/docs/BROADCAST_STUDIO_UI.md create mode 100644 packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx create mode 100644 packages/studio-panel/src/components/Studio.tsx.backup create mode 100644 packages/studio-panel/src/components/Studio.tsx.backup-1762577713 delete mode 100644 packages/studio-panel/src/components/StudioControls.tsx delete mode 100644 packages/studio-panel/src/components/StudioSidebar.tsx delete mode 100644 packages/studio-panel/src/components/StudioVideoArea.tsx create mode 100644 packages/studio-panel/src/components/broadcast/BroadcastStudio.tsx create mode 100644 packages/studio-panel/src/components/broadcast/ControlPanel.tsx create mode 100644 packages/studio-panel/src/components/broadcast/LiveKitBroadcastWrapper.tsx create mode 100644 packages/studio-panel/src/components/broadcast/StreamView.tsx create mode 100644 packages/studio-panel/src/components/broadcast/index.ts create mode 100644 packages/studio-panel/src/context/SceneContext.tsx diff --git a/packages/studio-panel/docs/BROADCAST_STUDIO_UI.md b/packages/studio-panel/docs/BROADCAST_STUDIO_UI.md new file mode 100644 index 0000000..628f9e2 --- /dev/null +++ b/packages/studio-panel/docs/BROADCAST_STUDIO_UI.md @@ -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 ( + console.log('Desconectado')} + /> + ) +} +``` + +### Uso Standalone (sin LiveKit) + +```tsx +import { BroadcastStudio } from './components/broadcast' +import { LiveKitRoom } from '@livekit/components-react' + +function App() { + return ( + + + + ) +} +``` + +### 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 + +``` + +## 📝 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 diff --git a/packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx b/packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx new file mode 100644 index 0000000..550ef9b --- /dev/null +++ b/packages/studio-panel/docs/INTEGRATION_EXAMPLE.tsx @@ -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 && ( + { + console.log('Desconectado del estudio') + setIsDemoMode(true) + }} + /> +)} + +// Opción 2: Usar solo dentro del LiveKitRoom existente + + + + +// Opción 3: Modo híbrido - Toggle entre vista clásica y BroadcastStudio +const [useBroadcastUI, setUseBroadcastUI] = useState(false) + +{useBroadcastUI ? ( + +) : ( + <> + + + +)} diff --git a/packages/studio-panel/src/components/Studio.tsx b/packages/studio-panel/src/components/Studio.tsx index 827f567..ae7ba3d 100644 --- a/packages/studio-panel/src/components/Studio.tsx +++ b/packages/studio-panel/src/components/Studio.tsx @@ -1,817 +1,303 @@ -import { useState, useEffect } from 'react' -import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react' -import '@livekit/components-styles' -import StudioHeader from './StudioHeader' -import StudioLeftSidebar from './StudioLeftSidebar' -import StudioVideoArea from './StudioVideoArea' -import StudioRightPanel, { TabsColumn, TabType } from './StudioRightPanel' -import StudioControls from './StudioControls' -import PresentationPanel from './PresentationPanel' -import { DEMO_TOKEN } from '../config/demo' +import { useState, useEffect } from "react"; +import { + LiveKitRoom, + ControlBar, + useTracks, + useLocalParticipant +} 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"; -interface StudioProps { - userName: string - roomName: string -} +function Studio() { + const [token, setToken] = useState(""); + const [serverUrl, setServerUrl] = useState( + "wss://avanzacast-test-0kl2kzjr.livekit.cloud", + ); + const [roomName, setRoomName] = useState(""); + const [userName, setUserName] = useState(""); + const [showLeftPanel, setShowLeftPanel] = useState(true); + const [showRightPanel, setShowRightPanel] = useState(true); + const [activeRightTab, setActiveRightTab] = useState("comments"); + const [needsUserName, setNeedsUserName] = useState(false); + const [inputUserName, setInputUserName] = useState(""); + const [isConnecting, setIsConnecting] = useState(false); -const Studio: React.FC = ({ userName, roomName }) => { - const [token, setToken] = useState(DEMO_TOKEN) - const [serverUrl, setServerUrl] = useState('') - const [isConnecting, setIsConnecting] = useState(true) - const [isDemoMode, setIsDemoMode] = useState(true) // Iniciar en modo demo por defecto - const [presentationOpen, setPresentationOpen] = useState(false) - const [sharedPresentation, setSharedPresentation] = useState<{ type: 'screen' | 'file', url?: string, stream?: MediaStream } | null>(null) - const [layout, setLayout] = useState<'grid' | 'focus'>('grid') - const [mode, setMode] = useState<'video' | 'audio'>('video') - const [showLeftPanel, setShowLeftPanel] = useState(true) - const [showRightPanel, setShowRightPanel] = useState(true) - const [activeRightTab, setActiveRightTab] = useState('brand') + 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); + }, []); - // 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 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 ( +
+
+
+
+
+
+ A +
+ + AvanzaCast + +
+
+

+ Bienvenido al Estudio +

+

+ Ingresa tu nombre para unirte a la transmisión +

+
+
+ + 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 + /> +
+ +
+
+
+
+ ); } - 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]) - - // Presentation handlers - const handleOpenPresentationPanel = () => setPresentationOpen(true) - const handleClosePresentationPanel = () => setPresentationOpen(false) - - const handleShareScreen = async () => { - try { - // Solicitar MediaStream para screen sharing - // Note: this must be called after a user gesture - const stream = await (navigator.mediaDevices as any).getDisplayMedia({ video: true }) - // Guardar el objeto MediaStream en el estado para reproducirlo localmente - setSharedPresentation({ type: 'screen', stream }) - setPresentationOpen(false) - } catch (e) { - console.warn('[Studio] Error al compartir pantalla', e) - } - } - - const handleShareFile = async (file: File) => { - // Para MVP: crear URL local y mostrar en el área central - const url = URL.createObjectURL(file) - setSharedPresentation({ type: 'file', url }) - setPresentationOpen(false) - } - - 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 ( -
+
-
-

Conectando al estudio...

+
+
+
+
Conectando al estudio...
- ) + ); } - // 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 + if (!token || !roomName) { return ( -
- {/* Modal */} -
-
- - - -

Error de Conexión

-
-

No se pudo conectar al estudio porque no se obtuvo un token de autenticación válido.

-

Esto puede ocurrir si el servidor de tokens no está disponible o si el token ha expirado.

-
- - +
+
+
+
+ A +
+

+ AvanzaCast Studio +

+

+ No hay datos de conexión disponibles. +

+

+ Para acceder al estudio, debes iniciar una transmisión desde el + panel de broadcast. +

- ) + ); } - // Renderizar interfaz en modo demo (sin LiveKit) - if (isDemoMode) { - return ( -
- {/* Banner de modo demo */} -
- ⚠️ MODO DEMO - No se encontró un token en sessionStorage. Funcionalidad de streaming deshabilitada. -
- - {/* Header superior */} - - - {/* Contenido principal */} -
- {/* Sidebar izquierdo - Escenas (posición absoluta izquierda) */} -
- -
- - {/* Botón toggle izquierdo (en el borde derecho del panel izquierdo) */} - - - {/* Contenedor central - Área entre paneles laterales */} -
- {/* Wrapper interno con padding y espacio para UI adicional */} -
- {/* Área superior - Controles de layout y calidad */} -
- {/* Indicador de calidad */} -
- 720p -
- - {/* Controles de layout (Grid/Focus) */} -
- - -
- - {/* Branding - Logo StreamYard style */} -
- Producido con -
-
- A -
- AvanzaCast -
-
-
- - {/* StudioVideoArea - Área principal de video (flexible) */} -
- -
- - {/* Área inferior - Barra de herramientas estilo StreamYard */} -
- {/* Botones de participantes */} -
- {/* Avatar del presentador */} -
-
- {userName?.charAt(0)?.toUpperCase() || 'U'} -
-
-
- - {/* Slots de invitados (5 espacios) */} - {[1, 2, 3, 4, 5].map((slot) => ( - - ))} -
- - {/* Separador */} -
- - {/* Botón Presentar o Invitar */} - - - {/* Separador */} -
- - {/* Herramientas adicionales */} -
- {/* Botón editar */} - - - {/* Botón añadir */} - - - {/* Botón configuración */} - -
-
-
-
- - {/* Panel derecho - Ajustes (posición absoluta derecha) */} -
- setActiveRightTab(t)} - /> -
- - - {/* Tabs Column externo (modo demo): permanece visible incluso cuando el panel se oculta */} -
-
- setActiveRightTab(t)} /> -
-
- - {/* Botón toggle derecho (se mueve con el panel) */} - -
- - {/* Controles inferiores */} - setSharedPresentation(null)} - layout={layout} - mode={mode} - onChangeLayout={(l: 'grid' | 'focus') => setLayout(l)} - onChangeMode={(m: 'video' | 'audio') => setMode(m)} - /> - {/* Presentation Panel modal */} - {presentationOpen && ( -
- -
- )} - - {/* Shared presentation overlay */} - {sharedPresentation && ( -
-
-
- {sharedPresentation.type === 'file' ? ( - // Try to guess type by URL extension - (sharedPresentation.url && sharedPresentation.url.endsWith('.pdf')) ? ( -