- 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
335 lines
16 KiB
TypeScript
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
|