diff --git a/packages/studio-panel/src/App.tsx b/packages/studio-panel/src/App.tsx index 60b0632..5c48a5c 100644 --- a/packages/studio-panel/src/App.tsx +++ b/packages/studio-panel/src/App.tsx @@ -1,42 +1,15 @@ -import { useEffect, useState } from 'react' -import Studio from './components/Studio' +import React from 'react' +import StudioLayout from './layouts/StudioLayout' -function App() { - const [userName, setUserName] = useState('') - const [roomName, setRoomName] = useState('avanzacast-studio') - const [loading, setLoading] = useState(true) - - useEffect(() => { - // Obtener información del usuario desde localStorage o URL params - // Esta información será establecida desde broadcast-panel - const params = new URLSearchParams(window.location.search) - const userFromParams = params.get('user') - const roomFromParams = params.get('room') - - const userFromStorage = localStorage.getItem('avanzacast_user') - const roomFromStorage = localStorage.getItem('avanzacast_room') - - setUserName(userFromParams || userFromStorage || 'Demo User') - setRoomName(roomFromParams || roomFromStorage || 'avanzacast-studio') - - // Dar un pequeño delay para mostrar el loading - setTimeout(() => setLoading(false), 500) - }, []) - - // Mostrar pantalla de carga mientras se obtiene la información - if (loading) { - return ( -
-
-
-

Cargando Studio...

-

Conectando con AvanzaCast

-
+const App: React.FC = () => { + return ( + +
+

AvanzaCast - Studio

+

Panel de pruebas del estudio.

- ) - } - - return +
+ ) } export default App diff --git a/packages/studio-panel/src/components/ParticipantsPanel.tsx b/packages/studio-panel/src/components/ParticipantsPanel.tsx deleted file mode 100644 index 267aa08..0000000 --- a/packages/studio-panel/src/components/ParticipantsPanel.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { useParticipants } from '@livekit/components-react' -import apiClient from '@avanzacast/shared-utils/api' - -interface InvitedParticipant { - identity: string - id?: string - metadata?: any -} - -interface ParticipantsPanelProps { - roomName?: string -} - -const ParticipantsPanel: React.FC = ({ roomName }) => { - const liveParticipants = useParticipants() - const [invited, setInvited] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - useEffect(() => { - if (!roomName) return - let mounted = true - const fetchInvited = async () => { - setLoading(true) - setError(null) - try { - const result = await apiClient.get(`/livekit/rooms?roomName=${encodeURIComponent(roomName as string)}`) - if (result.success) { - const json: any = result.data || {} - const list = (json?.room?.participants) || [] - if (mounted) setInvited(list) - } else { - // Fallback: try Next.js app running on localhost:3000 (some environments expose the route there) - try { - const nextUrl = `http://localhost:3000/api/livekit/rooms?roomName=${encodeURIComponent(roomName as string)}` - const resp = await fetch(nextUrl, { headers: { 'Content-Type': 'application/json' } }) - const data = await resp.json() - const list = (data?.room?.participants) || [] - if (mounted) setInvited(list) - } catch (nfErr) { - throw new Error(result.error?.message || 'API error') - } - } - } catch (e: any) { - console.warn('[ParticipantsPanel] error fetching invited', e) - if (mounted) setError(String(e?.message || e)) - } finally { - if (mounted) setLoading(false) - } - } - - fetchInvited() - return () => { mounted = false } - }, [roomName]) - - const isConnected = (identity: string) => liveParticipants.some((p: any) => p.identity === identity) - - return ( -
- {/* Header */} -
-

Participantes

-

- Gestiona quién está en el stream -

-
- - {/* Content */} -
-
-

Conectados

- {liveParticipants.length === 0 ? ( -

No hay participantes conectados.

- ) : ( -
- {liveParticipants.map((p: any) => ( -
-
-
- {p.identity?.charAt(0)?.toUpperCase()} -
-
-

{p.identity}

-

{p.isLocal ? 'Tú (presentador)' : 'Invitado'}

-
-
-
- - -
-
- ))} -
- )} -
- -
-

Invitados

- {loading ? ( -

Cargando...

- ) : error ? ( -

Error: {error}

- ) : invited.length === 0 ? ( -

No hay invitados registrados.

- ) : ( -
- {invited.map((inv) => ( -
-
-
- {inv.identity?.charAt(0)?.toUpperCase()} -
-
-

{inv.identity}

-

{isConnected(inv.identity) ? 'Conectado' : 'Esperando'}

-
-
-
- -
-
- ))} -
- )} -
-
-
- ) -} - -export default ParticipantsPanel diff --git a/packages/studio-panel/src/components/PresentationPanel.tsx b/packages/studio-panel/src/components/PresentationPanel.tsx deleted file mode 100644 index 381e7f2..0000000 --- a/packages/studio-panel/src/components/PresentationPanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useRef, useState } from 'react' - -interface PresentationPanelProps { - onClose: () => void - onShareScreen: () => void - onShareFile: (file: File) => void -} - -const PresentationPanel: React.FC = ({ onClose, onShareScreen, onShareFile }) => { - const inputRef = useRef(null) - const [uploading, setUploading] = useState(false) - - const handleFileChange = async (e: React.ChangeEvent) => { - const f = e.target.files?.[0] - if (!f) return - setUploading(true) - try { - onShareFile(f) - } finally { - setUploading(false) - } - } - - return ( -
-
-

Compartir presentación

- -
- -
- - -
- - -
-
-
- ) -} - -export default PresentationPanel diff --git a/packages/studio-panel/src/components/Studio.tsx b/packages/studio-panel/src/components/Studio.tsx deleted file mode 100644 index b33247c..0000000 --- a/packages/studio-panel/src/components/Studio.tsx +++ /dev/null @@ -1,310 +0,0 @@ -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"; - -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); - - 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 ( -
-
-
-
-
-
- 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 - /> -
- -
-
-
-
- ); - } - - if (isConnecting) { - return ( -
-
-
-
-
-
Conectando al estudio...
-
-
- ); - } - - if (!token || !roomName) { - return ( -
-
-
-
- A -
-

- AvanzaCast Studio -

-
-

- No hay datos de conexión disponibles. -

-

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

-
-
- ); - } - - return ( - console.log("[LiveKit] Desconectado.")} - onError={(e) => console.error("[LiveKit] Error:", e)} - data-lk-theme="default" - className="studio-container" - > - -
- {/* Header */} -
- -
- - {/* Contenedor principal entre header y footer */} -
- {/* Panel izquierdo */} -
- -
- - {/* Botón toggle panel izquierdo */} - - {/* Contenedor del estudio (StreamView + ControlPanel) */} -
-
-
-
-
- Producido con -
-
- - A - -
- - AvanzaCast - -
-
- -
-
-
-
- -
-
- {/* Panel derecho: tabs siempre visibles en el extremo derecho, contenido se oculta */} - {/* Panel de contenido con animación de slide */} -
- setActiveRightTab(t)} - /> -
- {/* TabsColumn siempre visible en el extremo derecho */} -
- { - setActiveRightTab(t); - if (!showRightPanel) { - setShowRightPanel(true); - } - }} - onTogglePanel={() => setShowRightPanel(!showRightPanel)} - isCollapsed={!showRightPanel} - /> -
-
- - {/* ControlBar como footer fijo */} -
- -
-
-
-
- ); -} - -export default Studio; diff --git a/packages/studio-panel/src/components/Studio.tsx.backup b/packages/studio-panel/src/components/Studio.tsx.backup deleted file mode 100644 index cceaae8..0000000 --- a/packages/studio-panel/src/components/Studio.tsx.backup +++ /dev/null @@ -1,789 +0,0 @@ -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 = ({ 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 [showLeftPanel, setShowLeftPanel] = useState(true) - const [showRightPanel, setShowRightPanel] = useState(true) - const [activeRightTab, setActiveRightTab] = useState('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 ( -
-
-
-

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

-
- - -
-
-
- ) - } - - // 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')) ? ( -