Cesar Mendivil 8b458a3ddf feat: add initial LiveKit Meet integration with utility scripts, configs, and core components
- Add Next.js app structure with base configs, linting, and formatting
- Implement LiveKit Meet page, types, and utility functions
- Add Docker, Compose, and deployment scripts for backend and token server
- Provide E2E and smoke test scaffolding with Puppeteer and Playwright helpers
- Include CSS modules and global styles for UI
- Add postMessage and studio integration utilities
- Update package.json with dependencies and scripts for development and testing
2025-11-20 12:50:38 -07:00

335 lines
16 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import { MdMoreVert, MdVideocam, MdPersonAdd, MdEdit, MdOpenInNew, MdDelete } from 'react-icons/md'
import { Dropdown } from './Dropdown'
import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from 'react-icons/fa'
import { SkeletonTable } from './Skeleton'
import styles from './TransmissionsTable.module.css'
import InviteGuestsModal from './InviteGuestsModal'
import { NewTransmissionModal } from '@shared/components'
import type { Transmission } from '@shared/types'
import useStudioLauncher from '../hooks/useStudioLauncher'
import useStudioMessageListener from '../hooks/useStudioMessageListener'
import StudioPortal from '../features/studio/StudioPortal'
interface Props {
transmissions: Transmission[]
onDelete: (id: string) => void
onUpdate: (t: Transmission) => void
isLoading?: boolean
}
const platformIcons: Record<string, React.ReactNode> = {
'YouTube': <FaYoutube size={16} color="#FF0000" />,
'Facebook': <FaFacebook size={16} color="#1877F2" />,
'Twitch': <FaTwitch size={16} color="#9146FF" />,
'LinkedIn': <FaLinkedin size={16} color="#0A66C2" />,
'Generico': <MdVideocam size={16} color="#5f6368" />, // Logo genérico para transmisiones sin destino
}
const TransmissionsTable: React.FC<Props> = (props) => {
const { transmissions, onDelete, onUpdate, isLoading } = props
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming')
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined)
const [editOpen, setEditOpen] = useState(false)
const [editTransmission, setEditTransmission] = useState<Transmission | undefined>(undefined)
const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher()
const [loadingId, setLoadingId] = useState<string | null>(null)
const [studioSession, setStudioSession] = useState<{ serverUrl?: string; token?: string; room?: string } | null>(null)
const [validating, setValidating] = useState<boolean>(false)
const [connectError, setConnectError] = useState<string | null>(null)
const [currentAttempt, setCurrentAttempt] = useState<Transmission | null>(null)
// Listen for external postMessage events carrying a LIVEKIT_TOKEN payload.
useStudioMessageListener((msg) => {
try {
if (msg && msg.token) {
const serverUrl = msg.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''
// start validating token and open StudioPortal overlay
setValidating(true)
setConnectError(null)
setStudioSession({ serverUrl, token: msg.token, room: msg.room || 'external' })
}
} catch (e) { /* ignore */ }
})
// Auto-open studio if token is present in URL (INCLUDE_TOKEN_IN_REDIRECT flow)
useEffect(() => {
try {
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
const tokenParam = params.get('token')
if (tokenParam) {
const serverParam = params.get('serverUrl') || params.get('url') || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''
const roomParam = params.get('room') || 'external'
setConnectError(null)
setValidating(true)
setStudioSession({ serverUrl: serverParam, token: tokenParam, room: roomParam })
}
} catch (e) { /* ignore */ }
}, [])
const handleEdit = (t: Transmission) => {
setEditTransmission(t)
setEditOpen(true)
}
// Filtrado por fechas
const filtered = transmissions.filter((t: Transmission) => {
// Si es "Próximamente" o no tiene fecha programada, siempre va a "upcoming"
if (!t.scheduled || t.scheduled === 'Próximamente') return activeTab === 'upcoming'
const scheduledDate = new Date(t.scheduled)
const now = new Date()
if (activeTab === 'upcoming') {
return scheduledDate >= now
} else {
return scheduledDate < now
}
})
const openStudioForTransmission = async (t: Transmission) => {
if (loadingId || launcherLoadingId) return
setLoadingId(t.id)
setCurrentAttempt(t)
setValidating(true)
try {
const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User'
const user = (userRaw)
const room = (t.id || 'avanzacast-studio')
const result = await openStudio({ room, username: user })
if (!result) {
throw new Error('No se pudo abrir el estudio')
}
const resAny: any = result as any
// If backend returned a session id, persist it and navigate to broadcastPanel/:id so the Studio route picks it
if (resAny && resAny.id) {
try {
const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session'
sessionStorage.setItem(storeKey, JSON.stringify(resAny))
try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: resAny })) } catch (e) { /* ignore */ }
} catch (e) { /* ignore storage errors */ }
const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '')
const target = `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(resAny.id)}`
try {
window.location.href = target
return
} catch (e) {
try { window.location.assign(target) } catch (e2) { /* ignore */ }
}
}
// If app is configured as integrated, ensure we open StudioPortal overlay immediately
const INTEGRATED = (import.meta.env.VITE_STUDIO_INTEGRATED === 'true' || import.meta.env.VITE_STUDIO_INTEGRATED === '1') || false
if (INTEGRATED && resAny && resAny.token) {
const serverUrl = resAny.url || resAny.studioUrl || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''
setStudioSession({ serverUrl, token: resAny.token, room: resAny.room || room })
setLoadingId(null)
return
}
const serverUrl = resAny.url || resAny.studioUrl || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''
if (resAny.token) {
setStudioSession({ serverUrl, token: resAny.token, room: resAny.room || room })
} else {
setValidating(false)
}
setLoadingId(null)
} catch (err: any) {
console.error('[BroadcastPanel] Error entrando al estudio:', err)
setConnectError(err?.message || 'No fue posible entrar al estudio. Revisa el servidor de tokens.')
setValidating(false)
setLoadingId(null)
}
}
const closeStudio = () => {
try { setStudioSession(null) } catch(e){}
}
if (isLoading) {
return (
<div className={styles.transmissionsSection}>
<div className={styles.tabContainer}>
<button className={`${styles.tabButton} ${styles.activeTab}`}>
Próximamente
</button>
<button className={styles.tabButton}>
Anteriores
</button>
</div>
<SkeletonTable rows={5} />
</div>
)
}
return (
<div className={styles.transmissionsSection}>
<div className={styles.tabContainer}>
<button
onClick={() => setActiveTab('upcoming')}
className={`${styles.tabButton} ${activeTab === 'upcoming' ? styles.activeTab : ''}`}
>
Próximamente
</button>
<button
onClick={() => setActiveTab('past')}
className={`${styles.tabButton} ${activeTab === 'past' ? styles.activeTab : ''}`}
>
Anteriores
</button>
</div>
{!filtered || filtered.length === 0 ? (
<div className={styles.tableWrapper}>
<div className={styles.noDataCell}>
{activeTab === 'upcoming'
? 'No hay transmisiones programadas todavía.'
: 'No hay transmisiones anteriores.'
}
</div>
</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.transmissionsTable}>
<thead>
<tr>
<th className={styles.tableHeader}>Título</th>
<th className={styles.tableHeader}>Creado</th>
<th className={styles.tableHeader}>Programado</th>
<th className={styles.tableHeader} style={{ textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{filtered.map((t: Transmission) => (
<tr key={t.id} className={styles.tableRow}>
<td className={styles.tableCell}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className={styles.platformAvatar}>
<div className={styles.platformIcon}>{platformIcons[t.platform] || platformIcons['YouTube']}</div>
</div>
<div>
<div className={styles.transmissionTitle}>{t.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{t.platform === 'Generico' ? 'Solo grabación' : (t.platform || 'YouTube')}
</div>
</div>
</div>
</td>
<td className={styles.tableCell}>
<span style={{ fontSize: 14, color: 'var(--text-primary)' }}>
{t.createdAt || '---'}
</span>
</td>
<td className={styles.tableCell}>
<span style={{ fontSize: 14, color: 'var(--text-primary)' }}>
{(t.scheduled && t.scheduled !== 'Próximamente') ? t.scheduled : '---'}
</span>
</td>
<td className={styles.tableCell} style={{ textAlign: 'right' }}>
<div className={styles.actionsCell}>
<button
aria-label={`Entrar al estudio ${t.title}`}
className={styles.enterStudioButton}
disabled={loadingId !== null || launcherLoadingId !== null}
onClick={() => openStudioForTransmission(t)}
>
{ (loadingId === t.id || launcherLoadingId === t.id) ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 50 50" style={{ animation: 'spin 1s linear infinite' }}>
<circle cx="25" cy="25" r="20" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4" />
</svg>
Entrando...
</span>
) : (
'Entrar al estudio'
)}
</button>
{launcherError && (
// Mostrar modal claro si el hook de launcher reporta un error
<div style={{ position: 'fixed', inset: 0, zIndex: 12500, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#fff', color: '#111827', padding: 20, borderRadius: 8, maxWidth: 600 }}>
<h3>Error al iniciar el estudio</h3>
<p>{launcherError}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { /* cerrar el error del launcher */ window.location.reload(); }} className="btn">Cerrar</button>
</div>
</div>
</div>
)}
<Dropdown
trigger={<button className={styles.moreOptionsButton} aria-label={`Más opciones ${t.title}`}><MdMoreVert size={20} /></button>}
items={[
{ label: 'Agregar invitados', icon: <MdPersonAdd size={16} />, onClick: () => { setInviteLink(`https://streamyard.com/${t.id}`); setInviteOpen(true) } },
{ label: 'Editar', icon: <MdEdit size={16} />, onClick: () => handleEdit(t) },
{ divider: true, label: '', disabled: false },
{ label: 'Ver en YouTube', icon: <MdOpenInNew size={16} />, onClick: () => {/* abrir */} },
{ divider: true, label: '', disabled: false },
{ label: 'Eliminar transmisión', icon: <MdDelete size={16} />, onClick: () => onDelete(t.id), containerProps: { className: styles.deleteItem }, labelProps: { className: styles.dangerLabel } }
]}
/>
<InviteGuestsModal open={inviteOpen} onClose={() => setInviteOpen(false)} link={inviteLink || ''} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<NewTransmissionModal
open={editOpen}
onClose={() => { setEditOpen(false); setEditTransmission(undefined) }}
onCreate={() => {}}
onUpdate={onUpdate}
transmission={editTransmission}
/>
{studioSession && (
<div className={styles.studioOverlay} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: '95%', maxWidth: 1200, height: '90%', background: 'var(--studio-bg-primary)', borderRadius: 8, overflow: 'hidden', position: 'relative' }}>
<button onClick={() => { setValidating(false); closeStudio(); }} style={{ position: 'absolute', right: 12, top: 12, zIndex: 10100, padding: '8px 12px', borderRadius: 6 }}>Cerrar</button>
<StudioPortal
serverUrl={studioSession.serverUrl || ''}
token={studioSession.token || ''}
roomName={studioSession.room || ''}
onRoomConnected={() => { setValidating(false); /* keep portal open */ }}
onRoomDisconnected={() => { closeStudio(); }}
onRoomConnectError={(err) => { setValidating(false); setConnectError(String(err?.message || err || 'Error al conectar')); }}
/>
</div>
</div>
)}
{validating && (
<div className={styles.validationOverlay} style={{ position: 'fixed', inset: 0, zIndex: 11000, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ background: 'rgba(0,0,0,0.6)', color: '#fff', padding: 16, borderRadius: 8, pointerEvents: 'auto' }}>
Validando token, por favor espera...
</div>
</div>
)}
{connectError && (
<div className={styles.errorModal} style={{ position: 'fixed', inset: 0, zIndex: 12000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#fff', color: '#111827', padding: 20, borderRadius: 8, maxWidth: 600 }}>
<h3>Error al conectar al estudio</h3>
<p>{connectError}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setConnectError(null); setStudioSession(null); setCurrentAttempt(null); }} className="btn">Cerrar</button>
<button onClick={() => { if (currentAttempt) { setConnectError(null); openStudioForTransmission(currentAttempt); } }} className="btn btn-primary">Reintentar</button>
</div>
</div>
</div>
)}
</div>
)
}
export default TransmissionsTable