feat: Enhance StudioControls with presentation and layout features

- Added props for handling presentation and screen sharing actions.
- Implemented buttons for opening presentation panel and changing layouts (grid/focus) and modes (video/audio).
- Updated UI to reflect active presentations and added functionality to clear presentations.

fix: Adjust StudioLeftSidebar and StudioRightPanel for full height

- Modified styles to ensure both sidebars occupy full height of the container.

feat: Introduce ParticipantsPanel for managing participants in the conference

- Created ParticipantsPanel component to display connected and invited participants.
- Integrated API calls to fetch invited participants and handle connection status.

feat: Implement PresentationPanel for sharing presentations

- Developed PresentationPanel component to handle file uploads and screen sharing.

refactor: Update StudioVideoArea to support layout and mode changes

- Refactored StudioVideoArea to accept layout and mode props, rendering appropriate conference views.

feat: Add AudioConference and VideoConference prefabs for audio and video handling

- Created AudioConference and VideoConference components to manage respective media streams.

feat: Introduce Chat component for real-time messaging

- Developed Chat component to facilitate messaging between participants.

feat: Implement ControlBar for user controls in the conference

- Created ControlBar component for managing participant actions like leaving the conference and toggling audio/video.

feat: Add PreJoin component for pre-conference setup

- Developed PreJoin component to allow users to preview video before joining the conference.

chore: Update Vite configuration for better module resolution

- Enhanced Vite config to include path aliases for easier imports across the project.

chore: Add TypeScript definitions for environment variables

- Created env.d.ts to define types for environment variables used in the project.
This commit is contained in:
Cesar Mendivil 2025-11-07 14:29:14 -07:00
parent 543d6bc6af
commit f57ce90c11
27 changed files with 945 additions and 101 deletions

View File

@ -10,8 +10,15 @@ const PORT = process.env.PORT || 4000;
// Middleware
app.use(helmet());
const allowedOrigins = process.env.FRONTEND_URLS?.split(',') || ['http://localhost:3000'];
// Always allow our local dev studio ports to avoid CORS blockers during development
if (process.env.NODE_ENV !== 'production') {
if (!allowedOrigins.includes('http://localhost:3020')) allowedOrigins.push('http://localhost:3020')
if (!allowedOrigins.includes('http://localhost:3021')) allowedOrigins.push('http://localhost:3021')
}
app.use(cors({
origin: process.env.FRONTEND_URLS?.split(',') || ['http://localhost:3000'],
origin: allowedOrigins,
credentials: true,
}));
app.use(express.json());
@ -37,6 +44,19 @@ app.get('/api/v1', (req, res) => {
});
});
// Minimal LiveKit-related endpoints (placeholder implementation)
app.get('/api/v1/livekit/rooms', (req, res) => {
const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined;
// If no roomName provided, return a list of rooms (empty list for now)
if (!roomName) {
return res.json({ rooms: [] });
}
// Placeholder: return empty participants list for the requested room
return res.json({ room: { name: roomName, participants: [] } });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });

View File

@ -1,6 +1,6 @@
# LiveKit Configuration
VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=http://localhost:3010
VITE_TOKEN_SERVER_URL=https://avanzacast-studio.bfzqqk.easypanel.host
# Application Configuration
VITE_APP_NAME=AvanzaCast Broadcast Panel

View File

@ -124,7 +124,7 @@ LIVEKIT_URL=wss://your-project.livekit.cloud
## Componentes de LiveKit Utilizados
- `LiveKitRoom`: Contenedor principal para la sala de video
- ` `: Contenedor principal para la sala de video
- `VideoConference`: Componente de conferencia de video todo-en-uno
- `ParticipantTile`: Miniatura de video de participante individual
- `ControlBar`: Barra de controles personalizable

View File

@ -21,7 +21,7 @@ services:
- NODE_ENV=development
- DOCKER_ENV=true
- VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
- VITE_TOKEN_SERVER_URL=http://localhost:3010
- VITE_TOKEN_SERVER_URL=${VITE_TOKEN_SERVER_URL:-https://avanzacast-studio.bfzqqk.easypanel.host}
networks:
- avanzacast-network
restart: unless-stopped

View File

@ -19,7 +19,6 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate })
const navItems = [
{ id: 'inicio', label: 'Inicio', icon: <MdHome size={20} /> },
{ id: 'studio', label: 'Studio', icon: <MdVideocam size={20} /> },
{ id: 'biblioteca', label: 'Biblioteca', icon: <MdVideoLibrary size={20} /> },
{ id: 'destinos', label: 'Destinos', icon: <MdLink size={20} /> },
{ id: 'miembros', label: 'Miembros', icon: <MdPeople size={20} /> },

View File

@ -58,21 +58,37 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User'
const user = encodeURIComponent(userRaw)
const room = encodeURIComponent(t.id || 'avanzacast-studio')
const tokenRes = await fetch(`http://localhost:3010/api/token?room=${room}&username=${user}`)
console.log('[BroadcastPanel] Solicitando token:', { room: decodeURIComponent(room), user: decodeURIComponent(user) })
const TOKEN_SERVER = import.meta.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host'
const tokenUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/token?room=${room}&username=${user}`
const tokenRes = await fetch(tokenUrl)
if (!tokenRes.ok) throw new Error('No se pudo obtener token')
const tokenData = await tokenRes.json()
// Guardar token, serverUrl, room, user en sessionStorage
sessionStorage.setItem('avanzacast_studio_token', tokenData.token)
sessionStorage.setItem('avanzacast_studio_serverUrl', tokenData.serverUrl)
sessionStorage.setItem('avanzacast_studio_room', decodeURIComponent(room))
sessionStorage.setItem('avanzacast_studio_user', decodeURIComponent(user))
console.log('[BroadcastPanel] Token recibido:', {
tokenLength: tokenData.token?.length || 0,
serverUrl: tokenData.serverUrl,
hasToken: !!tokenData.token,
hasServerUrl: !!tokenData.serverUrl
})
// Redirigir a studio-panel en la misma pestaña
const shortId = Math.random().toString(36).slice(2, 10)
window.location.href = `http://localhost:3020/${shortId}`
// Pasar solo token, room y user por URL (serverUrl se lee del .env en studio-panel)
const params = new URLSearchParams({
token: tokenData.token,
room: decodeURIComponent(room),
user: decodeURIComponent(user)
})
console.log('[BroadcastPanel] Redirigiendo con parámetros en URL...')
// Redirigir a studio-panel en la misma pestaña con los datos en la URL
const STUDIO_URL = import.meta.env.VITE_STUDIO_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host'
const shortId = Math.random().toString(36).slice(2, 10)
window.location.href = `${STUDIO_URL.replace(/\/$/, '')}/${shortId}?${params.toString()}`
} catch (err) {
console.error('Error entrando al estudio', err)
console.error('[BroadcastPanel] Error entrando al estudio:', err)
alert('No fue posible entrar al estudio. Revisa el servidor de tokens.')
setLoadingId(null)
}

View File

@ -35,7 +35,7 @@ export default defineConfig({
}
},
server: {
port: 5173,
port: 5175,
host: true,
fs: {
// Allow serving files from the shared folder when mounted in Docker

View File

@ -0,0 +1,121 @@
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<ParticipantsPanelProps> = ({ roomName }) => {
const liveParticipants = useParticipants()
const [invited, setInvited] = useState<InvitedParticipant[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<div>
<h4 className="text-white text-sm font-semibold mb-2">Conectados</h4>
{liveParticipants.length === 0 ? (
<p className="text-gray-400 text-xs">No hay participantes conectados.</p>
) : (
<div className="space-y-2">
{liveParticipants.map((p: any) => (
<div key={p.sid} className="flex items-center justify-between bg-gray-700 p-2 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-pink-500 to-red-500 flex items-center justify-center text-white">
{p.identity?.charAt(0)?.toUpperCase()}
</div>
<div>
<p className="text-sm text-white truncate">{p.identity}</p>
<p className="text-xs text-gray-400">{p.isLocal ? 'Tú (presentador)' : 'Invitado'}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button className="text-xs px-2 py-1 bg-gray-600 hover:bg-gray-500 rounded text-white">Silenciar</button>
<button className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-white">Remover</button>
</div>
</div>
))}
</div>
)}
</div>
<div>
<h4 className="text-white text-sm font-semibold mb-2">Invitados</h4>
{loading ? (
<p className="text-gray-400 text-xs">Cargando...</p>
) : error ? (
<p className="text-red-400 text-xs">Error: {error}</p>
) : invited.length === 0 ? (
<p className="text-gray-400 text-xs">No hay invitados registrados.</p>
) : (
<div className="space-y-2">
{invited.map((inv) => (
<div key={inv.identity} className={`flex items-center justify-between bg-gray-700 p-2 rounded-lg ${isConnected(inv.identity) ? 'border-2 border-green-500' : ''}`}>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gray-600 flex items-center justify-center text-white">
{inv.identity?.charAt(0)?.toUpperCase()}
</div>
<div>
<p className="text-sm text-white truncate">{inv.identity}</p>
<p className="text-xs text-gray-400">{isConnected(inv.identity) ? 'Conectado' : 'Esperando'}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded text-white">Invitar</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default ParticipantsPanel

View File

@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react'
interface PresentationPanelProps {
onClose: () => void
onShareScreen: () => void
onShareFile: (file: File) => void
}
const PresentationPanel: React.FC<PresentationPanelProps> = ({ onClose, onShareScreen, onShareFile }) => {
const inputRef = useRef<HTMLInputElement | null>(null)
const [uploading, setUploading] = useState(false)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
if (!f) return
setUploading(true)
try {
onShareFile(f)
} finally {
setUploading(false)
}
}
return (
<div className="bg-gray-800 p-4 rounded shadow-md">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white text-sm font-semibold">Compartir presentación</h3>
<button onClick={onClose} className="text-gray-300 text-sm">Cerrar</button>
</div>
<div className="space-y-3">
<button
onClick={onShareScreen}
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 rounded"
>
Compartir pantalla
</button>
<div>
<input
ref={inputRef}
type="file"
accept="image/*,video/*,application/pdf"
onChange={handleFileChange}
className="hidden"
id="presentation-file"
/>
<label htmlFor="presentation-file" className="w-full block text-center bg-green-600 hover:bg-green-700 text-white py-2 rounded cursor-pointer">
{uploading ? 'Subiendo...' : 'Subir archivo (imagen, video, pdf)'}
</label>
</div>
</div>
</div>
)
}
export default PresentationPanel

View File

@ -6,6 +6,7 @@ import StudioLeftSidebar from './StudioLeftSidebar'
import StudioVideoArea from './StudioVideoArea'
import StudioRightPanel from './StudioRightPanel'
import StudioControls from './StudioControls'
import PresentationPanel from './PresentationPanel'
import { DEMO_TOKEN } from '../config/demo'
interface StudioProps {
@ -15,8 +16,15 @@ interface StudioProps {
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 [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)
// Utility: heurística ligera para validar token en cliente
const isTokenLikelyValid = (t?: string) => {
@ -51,26 +59,83 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
}
}, [token])
useEffect(() => {
// Leer token y datos desde sessionStorage (guardado por broadcast-panel)
// Presentation handlers
const handleOpenPresentationPanel = () => setPresentationOpen(true)
const handleClosePresentationPanel = () => setPresentationOpen(false)
const handleShareScreen = async () => {
try {
const storedToken = sessionStorage.getItem('avanzacast_studio_token')
const storedServerUrl = sessionStorage.getItem('avanzacast_studio_serverUrl')
const storedRoom = sessionStorage.getItem('avanzacast_studio_room')
const storedUser = sessionStorage.getItem('avanzacast_studio_user')
// 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)
}
}
console.log('[Studio] sessionStorage:', { storedToken: storedToken ? `${storedToken.length} chars` : 'none', storedServerUrl, storedRoom, storedUser })
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)
}
if (storedToken && storedServerUrl) {
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 {
console.log('⚠️ No se encontró token en sessionStorage. Usando modo DEMO...')
setToken(DEMO_TOKEN)
setIsDemoMode(true)
// 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 leyendo sessionStorage:', err)
console.error('Error crítico leyendo datos:', err)
setToken(DEMO_TOKEN)
setIsDemoMode(true)
} finally {
@ -85,18 +150,19 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
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] open', { url, type: (ev && (ev as any).type) || 'open' }))
sock.addEventListener('close', (ev: any) => console.log('[WS Monitor] close', { url, code: ev.code, reason: ev.reason, wasClean: ev.wasClean }))
sock.addEventListener('error', (ev: any) => console.error('[WS Monitor] error', { url, ev }))
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 : '[binary]'
const data = typeof ev.data === 'string' ? ev.data : '[payload binario]'
// limit length
console.log('[WS Monitor] message', { url, data: data && data.slice ? data.slice(0, 200) : data })
console.log('[WS Monitor] Evento: message', { url, data: data && data.slice ? data.slice(0, 250) : data })
} catch (e) {
// ignore
}
@ -109,7 +175,7 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
WrappedWebSocket.prototype = OriginalWS.prototype
w.WebSocket = WrappedWebSocket
w.__AVZ_WS_WRAPPED = true
console.log('[Studio] WebSocket wrapped for debug: will log open/close/error/messages')
console.log('[Studio] WebSocket envuelto para depuración: se registrarán todos los eventos de WS.')
}
} catch (e) {
// ignore failures in exotic environments
@ -118,8 +184,8 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
// Global error capture to help debugging connection failures
useEffect(() => {
const onErr = (ev: ErrorEvent) => console.error('[Studio][global error]', ev.message, ev.error)
const onReject = (ev: PromiseRejectionEvent) => console.error('[Studio][unhandledrejection]', ev.reason)
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 () => {
@ -132,8 +198,8 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
// NOTE: development WebSocket monitor removed to keep hooks stable during HMR
useEffect(() => {
console.log('[Studio] rendering LiveKitRoom, token_len=', token ? token.length : 0)
return () => console.log('[Studio] unmount LiveKitRoom')
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) {
@ -148,58 +214,65 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
}
// Heurística para no montar LiveKitRoom con tokens de demo o cortos
const serverUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
const hasValidToken = isTokenLikelyValid(token)
const hasValidServerUrl = serverUrl && serverUrl.startsWith('wss://')
if (!hasValidToken) {
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)}` : 'none'
console.log('[Studio] token not valid, will not mount LiveKitRoom. preview=', preview, ' len=', token ? token.length : 0)
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 sessionStorage
// Limpiar localStorage (por si acaso quedaron datos)
try {
sessionStorage.removeItem('avanzacast_studio_token')
sessionStorage.removeItem('avanzacast_studio_serverUrl')
sessionStorage.removeItem('avanzacast_studio_room')
sessionStorage.removeItem('avanzacast_studio_user')
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 flex items-center justify-center">
<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">
<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" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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">No se pudo conectar al estudio</h3>
<h3 className="text-xl text-white font-semibold">Error de Conexión</h3>
</div>
<p className="text-gray-300 mb-3">No se obtuvo un token válido para autenticarse con el servidor de LiveKit.</p>
<p className="text-gray-400 text-sm mb-6">Token obtenido: {token ? `${token.length} chars` : 'ninguno'}</p>
<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"
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"
className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white rounded-lg font-medium transition-colors"
>
Volver al Broadcast
Volver al Panel
</button>
</div>
</div>
@ -213,26 +286,125 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
<div className="flex flex-col h-screen bg-gray-900">
{/* Banner de modo demo */}
<div className="bg-yellow-600 text-black px-4 py-2 text-center text-sm font-semibold">
MODO DEMO - Servidor de tokens no disponible. Funcionalidad limitada.
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 flex-1 overflow-hidden">
{/* Sidebar izquierdo - Escenas */}
<StudioLeftSidebar />
<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>
{/* Área central de video */}
<StudioVideoArea isDemoMode={true} />
<div className="w-full h-full overflow-hidden">
<StudioVideoArea isDemoMode={true} layout={layout} mode={mode} />
</div>
{/* Panel derecho - Ajustes */}
<StudioRightPanel />
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
<div
className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showRightPanel ? 'translate-x-0' : 'translate-x-full'
}`}
>
<StudioRightPanel roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string} />
</div>
{/* Botón toggle derecho (en el borde izquierdo del panel derecho) */}
<button
onClick={() => setShowRightPanel(!showRightPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ right: showRightPanel ? '320px' : '0px' }}
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 />
{/* 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>
)
}
@ -246,6 +418,8 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
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"
>
@ -254,19 +428,82 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
<StudioHeader roomName={roomName} userName={userName} />
{/* Contenido principal */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar izquierdo - Escenas */}
<StudioLeftSidebar />
<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>
{/* Área central de video */}
<StudioVideoArea isDemoMode={false} />
<div className="w-full h-full overflow-hidden">
<StudioVideoArea isDemoMode={false} />
</div>
{/* Panel derecho - Ajustes */}
<StudioRightPanel />
{/* Panel derecho - Ajustes (posición absoluta derecha) */}
<div
className={`absolute right-0 top-0 bottom-0 z-20 transition-transform duration-300 ease-in-out ${
showRightPanel ? 'translate-x-0' : 'translate-x-full'
}`}
>
<StudioRightPanel roomName={(localStorage.getItem('avanzacast_studio_room') || roomName) as string} />
</div>
{/* Botón toggle derecho (en el borde izquierdo del panel derecho) */}
<button
onClick={() => setShowRightPanel(!showRightPanel)}
className="absolute top-1/2 -translate-y-1/2 z-30 transition-all duration-300"
style={{ right: showRightPanel ? '320px' : '0px' }}
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 />
<StudioControls
onOpenPresentation={handleOpenPresentationPanel}
onShareScreen={handleShareScreen}
sharedPresentation={sharedPresentation}
onClearPresentation={() => setSharedPresentation(null)}
/>
</div>
{/* Renderizador de audio de la sala */}

View File

@ -15,7 +15,18 @@ import {
MdStop,
} from 'react-icons/md'
const StudioControls: React.FC = () => {
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)
@ -103,7 +114,12 @@ const StudioControls: React.FC = () => {
{/* Compartir pantalla */}
<button
onClick={toggleScreenShare}
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`}
@ -112,6 +128,15 @@ const StudioControls: React.FC = () => {
{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 */}
@ -122,6 +147,27 @@ const StudioControls: React.FC = () => {
<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"
@ -144,8 +190,14 @@ const StudioControls: React.FC = () => {
</button>
</div>
{/* Derecha - Salir */}
{/* 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={() => {

View File

@ -53,7 +53,7 @@ const StudioLeftSidebar = () => {
}
return (
<div className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
<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">

View File

@ -7,19 +7,22 @@ import {
MdQrCode,
MdTimer,
MdSettings,
MdClose
MdClose,
MdPeople,
} from 'react-icons/md'
import { COLOR_THEMES, DEMO_OVERLAYS, DEMO_BACKGROUNDS, DEMO_SOUNDS } from '../config/demo'
import ParticipantsPanel from './ParticipantsPanel'
type TabType = 'brand' | 'multimedia' | 'sounds' | 'video' | 'qr' | 'countdown' | 'settings'
type TabType = 'brand' | 'multimedia' | 'participants' | 'sounds' | 'video' | 'qr' | 'countdown' | 'settings'
const StudioRightPanel = () => {
const StudioRightPanel = ({ roomName }: { roomName?: string }) => {
const [activeTab, setActiveTab] = useState<TabType>('brand')
const [isCollapsed, setIsCollapsed] = useState(false)
const tabs = [
{ id: 'brand' as TabType, icon: MdBrush, label: 'Marca' },
{ id: 'multimedia' as TabType, icon: MdImage, label: 'Multimedia' },
{ id: 'participants' as TabType, icon: MdPeople, label: 'Personas' },
{ id: 'sounds' as TabType, icon: MdMusicNote, label: 'Sonidos' },
{ id: 'video' as TabType, icon: MdVideoLibrary, label: 'Videos' },
{ id: 'qr' as TabType, icon: MdQrCode, label: 'QR' },
@ -51,7 +54,7 @@ const StudioRightPanel = () => {
}
return (
<div className="w-80 bg-gray-800 border-l border-gray-700 flex flex-col">
<div className="w-80 h-full bg-gray-800 border-l border-gray-700 flex flex-col">
{/* Header con tabs */}
<div className="border-b border-gray-700">
<div className="flex items-center justify-between px-3 py-2">
@ -90,6 +93,7 @@ const StudioRightPanel = () => {
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'brand' && <BrandTab />}
{activeTab === 'multimedia' && <MultimediaTab />}
{activeTab === 'participants' && <ParticipantsPanel roomName={roomName} />}
{activeTab === 'sounds' && <SoundsTab />}
{activeTab === 'video' && <VideoTab />}
{activeTab === 'qr' && <QRTab />}

View File

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

View File

@ -0,0 +1,36 @@
import React, { useState } from 'react'
import { useChat } from '@livekit/components-react'
interface ChatProps {
className?: string
}
const Chat: React.FC<ChatProps> = ({ className }) => {
const { chatMessages, send, isSending } = useChat()
const [text, setText] = useState('')
const sendMessage = async () => {
if (!text) return
await send(text)
setText('')
}
return (
<div className={`flex flex-col h-full ${className || ''}`}>
<div className="flex-1 overflow-auto p-2">
{chatMessages.map((m) => (
<div key={m.timestamp} className="mb-2">
<div className="text-xs text-gray-400">{m.from?.identity || 'anon'}</div>
<div className="text-sm text-white">{m.message}</div>
</div>
))}
</div>
<div className="p-2 border-t border-gray-700 flex gap-2">
<input className="flex-1 bg-gray-900 text-white p-2 rounded" value={text} onChange={(e) => setText(e.target.value)} />
<button className="bg-pink-500 text-white px-3 py-2 rounded" onClick={sendMessage} disabled={isSending}>Enviar</button>
</div>
</div>
)
}
export default Chat

View File

@ -0,0 +1,33 @@
import React from 'react'
import { useLocalParticipant, TrackToggle, StartAudio } from '@livekit/components-react'
import { Track } from 'livekit-client'
interface ControlBarProps {
onLeave?: () => void
className?: string
}
const ControlBar: React.FC<ControlBarProps> = ({ onLeave, className }) => {
const { localParticipant } = useLocalParticipant()
return (
<div className={`flex items-center gap-3 p-3 bg-gray-800 ${className || ''}`}>
<button
className="bg-red-600 text-white px-3 py-2 rounded"
onClick={() => {
if (onLeave) onLeave()
}}
>
Salir
</button>
<div className="flex gap-2 items-center">
<StartAudio label="Permitir audio" />
<TrackToggle source={Track.Source.Camera} />
<TrackToggle source={Track.Source.Microphone} />
</div>
<div className="ml-auto text-sm text-gray-300">{localParticipant?.identity}</div>
</div>
)
}
export default ControlBar

View File

@ -0,0 +1,34 @@
import React from 'react'
import { useStartVideo, useMediaDevices } from '@livekit/components-react'
interface PreJoinProps {
onJoin?: () => void
}
const PreJoin: React.FC<PreJoinProps> = ({ onJoin }) => {
const videoDevices = useMediaDevices({ kind: 'videoinput' })
const { mergedProps, canPlayVideo } = useStartVideo({ props: { className: 'bg-gray-700 text-white px-3 py-1 rounded' } })
return (
<div className="p-6 bg-gray-900 rounded-lg">
<h3 className="text-white text-lg mb-4">Antes de entrar</h3>
<div className="mb-4">
<div className="w-full h-48 bg-black rounded-md overflow-hidden flex items-center justify-center text-gray-400">
<div>
<div className="mb-2">Vista previa no activa</div>
<div className="text-xs text-gray-400">Pulsa "Iniciar cámara" para ver la previsualización</div>
<div className="mt-2 flex gap-2">
<button {...mergedProps} disabled={!canPlayVideo}>Iniciar cámara</button>
</div>
</div>
</div>
</div>
<div className="mb-4 text-sm text-gray-300">Dispositivos de video detectados: {videoDevices.length}</div>
<div className="flex gap-2">
<button className="bg-pink-500 text-white px-4 py-2 rounded" onClick={onJoin}>Entrar</button>
</div>
</div>
)
}
export default PreJoin

View File

@ -0,0 +1,36 @@
import React from 'react'
import { Track } from 'livekit-client'
import { GridLayout, FocusLayout, ParticipantTile, TrackLoop, useTracks } from '@livekit/components-react'
interface VideoConferenceProps {
layout?: 'grid' | 'focus'
className?: string
}
const VideoConference: React.FC<VideoConferenceProps> = ({ layout = 'grid', className }) => {
// request camera tracks (with placeholders for participants without camera)
const cameraTracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: true }])
if (layout === 'focus') {
// FocusLayout accepts a single trackRef (we can pass the first track as default)
return (
<div className={className}>
<FocusLayout trackRef={cameraTracks[0] as any}>
{/* children are not required here; FocusLayout will render the passed trackRef */}
</FocusLayout>
</div>
)
}
return (
<div className={className}>
<GridLayout tracks={cameraTracks as any}>
<TrackLoop tracks={cameraTracks as any}>
<ParticipantTile />
</TrackLoop>
</GridLayout>
</div>
)
}
export default VideoConference

View File

@ -0,0 +1,5 @@
export { default as VideoConference } from './VideoConference'
export { default as AudioConference } from './AudioConference'
export { default as PreJoin } from './PreJoin'
export { default as ControlBar } from './ControlBar'
export { default as Chat } from './Chat'

View File

@ -23,3 +23,12 @@ nohup: no se tendrá en cuenta la entrada
11:06:22 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:07:13 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:11:12 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:18:06 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:23:16 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:23:38 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:24:03 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:25:07 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:25:59 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:30:54 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:31:36 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:35:59 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css

View File

@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig(({ mode }) => {
// Cargar variables de entorno del directorio raíz
@ -7,9 +8,27 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../shared'),
'@avanzacast/shared-utils': path.resolve(__dirname, '../../shared/utils'),
'@avanzacast/shared-types': path.resolve(__dirname, '../../shared/types'),
'@avanzacast/shared-hooks': path.resolve(__dirname, '../../shared/hooks'),
}
},
server: {
port: 3020,
host: true,
fs: {
// Allow serving files from the shared folder (monorepo)
allow: [
path.resolve(__dirname),
path.resolve(__dirname, '../../shared')
],
// Disable strict fs checking so imports from outside project root work
strict: false
},
// Usar HTTP en dev para pruebas E2E locales (localhost permite getUserMedia sin HTTPS en muchos navegadores)
watch: { usePolling: true }
},

View File

@ -0,0 +1,25 @@
// vite.config.ts
import { defineConfig, loadEnv } from "file:///home/xesar/Documentos/Nextream/AvanzaCast/node_modules/vite/dist/node/index.js";
import react from "file:///home/xesar/Documentos/Nextream/AvanzaCast/node_modules/@vitejs/plugin-react/dist/index.mjs";
var vite_config_default = defineConfig(({ mode }) => {
const env = loadEnv(mode, "../../", "");
return {
plugins: [react()],
server: {
port: 3020,
host: true,
// Usar HTTP en dev para pruebas E2E locales (localhost permite getUserMedia sin HTTPS en muchos navegadores)
watch: { usePolling: true }
},
define: {
// Exponer variables de entorno al cliente
"import.meta.env.VITE_LIVEKIT_URL": JSON.stringify(env.LIVEKIT_URL),
"import.meta.env.VITE_LIVEKIT_API_KEY": JSON.stringify(env.LIVEKIT_API_KEY),
"import.meta.env.VITE_LIVEKIT_API_SECRET": JSON.stringify(env.LIVEKIT_API_SECRET)
}
};
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS94ZXNhci9Eb2N1bWVudG9zL05leHRyZWFtL0F2YW56YUNhc3QvcGFja2FnZXMvc3R1ZGlvLXBhbmVsXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvaG9tZS94ZXNhci9Eb2N1bWVudG9zL05leHRyZWFtL0F2YW56YUNhc3QvcGFja2FnZXMvc3R1ZGlvLXBhbmVsL3ZpdGUuY29uZmlnLnRzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9ob21lL3hlc2FyL0RvY3VtZW50b3MvTmV4dHJlYW0vQXZhbnphQ2FzdC9wYWNrYWdlcy9zdHVkaW8tcGFuZWwvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcsIGxvYWRFbnYgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0J1xuXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoKHsgbW9kZSB9KSA9PiB7XG4gIC8vIENhcmdhciB2YXJpYWJsZXMgZGUgZW50b3JubyBkZWwgZGlyZWN0b3JpbyByYVx1MDBFRHpcbiAgY29uc3QgZW52ID0gbG9hZEVudihtb2RlLCAnLi4vLi4vJywgJycpXG4gIFxuICByZXR1cm4ge1xuICAgIHBsdWdpbnM6IFtyZWFjdCgpXSxcbiAgICBzZXJ2ZXI6IHtcbiAgICAgIHBvcnQ6IDMwMjAsXG4gICAgICBob3N0OiB0cnVlLFxuICAgICAgLy8gVXNhciBIVFRQIGVuIGRldiBwYXJhIHBydWViYXMgRTJFIGxvY2FsZXMgKGxvY2FsaG9zdCBwZXJtaXRlIGdldFVzZXJNZWRpYSBzaW4gSFRUUFMgZW4gbXVjaG9zIG5hdmVnYWRvcmVzKVxuICAgICAgd2F0Y2g6IHsgdXNlUG9sbGluZzogdHJ1ZSB9XG4gICAgfSxcbiAgICBkZWZpbmU6IHtcbiAgICAgIC8vIEV4cG9uZXIgdmFyaWFibGVzIGRlIGVudG9ybm8gYWwgY2xpZW50ZVxuICAgICAgJ2ltcG9ydC5tZXRhLmVudi5WSVRFX0xJVkVLSVRfVVJMJzogSlNPTi5zdHJpbmdpZnkoZW52LkxJVkVLSVRfVVJMKSxcbiAgICAgICdpbXBvcnQubWV0YS5lbnYuVklURV9MSVZFS0lUX0FQSV9LRVknOiBKU09OLnN0cmluZ2lmeShlbnYuTElWRUtJVF9BUElfS0VZKSxcbiAgICAgICdpbXBvcnQubWV0YS5lbnYuVklURV9MSVZFS0lUX0FQSV9TRUNSRVQnOiBKU09OLnN0cmluZ2lmeShlbnYuTElWRUtJVF9BUElfU0VDUkVUKSxcbiAgICB9LFxuICB9XG59KVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFrWCxTQUFTLGNBQWMsZUFBZTtBQUN4WixPQUFPLFdBQVc7QUFFbEIsSUFBTyxzQkFBUSxhQUFhLENBQUMsRUFBRSxLQUFLLE1BQU07QUFFeEMsUUFBTSxNQUFNLFFBQVEsTUFBTSxVQUFVLEVBQUU7QUFFdEMsU0FBTztBQUFBLElBQ0wsU0FBUyxDQUFDLE1BQU0sQ0FBQztBQUFBLElBQ2pCLFFBQVE7QUFBQSxNQUNOLE1BQU07QUFBQSxNQUNOLE1BQU07QUFBQTtBQUFBLE1BRU4sT0FBTyxFQUFFLFlBQVksS0FBSztBQUFBLElBQzVCO0FBQUEsSUFDQSxRQUFRO0FBQUE7QUFBQSxNQUVOLG9DQUFvQyxLQUFLLFVBQVUsSUFBSSxXQUFXO0FBQUEsTUFDbEUsd0NBQXdDLEtBQUssVUFBVSxJQUFJLGVBQWU7QUFBQSxNQUMxRSwyQ0FBMkMsS0FBSyxVQUFVLElBQUksa0JBQWtCO0FBQUEsSUFDbEY7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K

View File

@ -16,5 +16,5 @@
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "packages/*/src"]
"include": ["src", "packages/*/src", "types"]
}

13
types/env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
interface ImportMetaEnv {
readonly VITE_TOKEN_SERVER_URL?: string
readonly VITE_LIVEKIT_URL?: string
readonly VITE_STUDIO_URL?: string
readonly VITE_BROADCAST_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module '*.module.css'
declare module '*.css'