diff --git a/app/api/session/README.md b/app/api/session/README.md new file mode 100644 index 0000000..3b8de18 --- /dev/null +++ b/app/api/session/README.md @@ -0,0 +1,110 @@ +# API /api/session + +Este README documenta la API de sesión incluida en `app/api/session` del proyecto. + +Objetivo +- Proveer endpoints simples para crear y recuperar sesiones E2E que devuelven un token LiveKit y una `studioUrl` construida con la convención `/rooms/`. + +Endpoints + +1) POST /api/session +- Uso: crea una sesión (genera token JWT con `livekit-server-sdk`) y guarda una entrada en el store en memoria. +- Request body (JSON): + - room (string) — nombre de la sala (opcional, por defecto `e2e-room`). + - username (string) — identidad del participante (opcional). + - ttl (number) — tiempo de vida en segundos del token (opcional, por defecto 300). +- Response (200): + ```json + { "id": "sess__", "token": "", "studioUrl": "https://mi-dominio/rooms/" } + ``` + +2) GET /api/session/:id +- Uso: recuperar la sesión previamente creada por `POST /api/session`. +- Response (200): retorna el objeto de sesión guardado en memoria, por ejemplo: + ```json + { + "id": "sess_...", + "token": "...", + "room": "mi-room", + "username": "visual-runner", + "studioUrl": "https://mi-dominio/rooms/mi-room", + "createdAt": 169... + } + ``` + +Variables de entorno relevantes +- LIVEKIT_API_KEY (required): clave pública para LiveKit. +- LIVEKIT_API_SECRET (required): secreto para firmar tokens. +- STUDIO_BASE (optional): base URL del frontend; si está definida, `studioUrl` se construye como `${STUDIO_BASE}/rooms/${room}`. + - Alternativas atendidas: `STUDIO_URL` o `BROADCAST_URL` si `STUDIO_BASE` no existe. + +Notas técnicas +- El store es un Map en memoria (`app/api/session/store.ts`). Es volátil: si el servidor se reinicia, las sesiones se perderán. Para entornos reales usa Redis o una DB. +- El token se genera con `AccessToken` del paquete `livekit-server-sdk` y contiene los grants para `roomJoin`, `canPublish` y `canSubscribe`. + +Comandos de ejemplo + +1) Crear sesión (POST): + +```bash +curl -sS -X POST http://localhost:3000/api/session \ + -H 'Content-Type: application/json' \ + -d '{"room":"mi-room-e2e","username":"tester","ttl":300}' | jq . +``` + +2) Recuperar sesión (GET): + +```bash +curl -sS http://localhost:3000/api/session/sess_169... | jq . +``` + +3) Decodificar el token (inspección del payload JWT): + +```bash +TOKEN="eyJhbGciOiJ..." +echo $TOKEN | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq . +``` + +4) Probar manualmente en el frontend (DevTools) +- Abre: `https://mi-dominio/rooms/mi-room-e2e`. +- En la consola ejecuta: + +```js +window.postMessage({ type: 'LIVEKIT_TOKEN', token: 'TOKEN_AQUI', room: '' }, window.location.origin); +``` + +El frontend debe escuchar `message` y conectar usando el token. + +Probar con el E2E script +- Arrancar Chrome con remote-debugging (usa `packages/broadcast-panel/e2e/start-chrome-remote.sh`). +- Ejecutar el script: + +```bash +REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 \ +TOKEN_SERVER="http://localhost:3000" \ +ROOM="mi-room-e2e" \ +BROADCAST_BASE="https://mi-dominio" \ +node packages/broadcast-panel/e2e/generate_visual_baseline.js +``` + +Artefactos generados por el E2E script +- Logs: `packages/broadcast-panel/e2e/e2e/out/generate_visual_baseline.log` (o `e2e/out/...` en la raíz del repo) +- Captura: `packages/broadcast-panel/e2e/e2e/out/visual_/studio.png` +- Baseline: `packages/broadcast-panel/e2e/e2e/baseline/studio.png` + +Depuración rápida +- Si `POST /api/session` devuelve 500: revisa `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` en el entorno del backend. +- Si `studioUrl` es vacío: exporta `STUDIO_BASE` antes de hacer POST. +- Si la página no reacciona al `postMessage`: revisa el listener `window.addEventListener('message', ...)` en el frontend. + +Siguientes pasos recomendados +- Reemplazar el store en memoria por Redis para persistencia en entornos multi-instanacia. +- Añadir autenticación y control de acceso para la creación/consulta de sesiones. + +Si quieres, puedo: +- Añadir un mock token server con `tools/mock-token-server.js` para pruebas locales sin credenciales reales. +- Añadir un endpoint para listar sesiones (solo para debugging). + +--- +**Fin del README de `app/api/session`** + diff --git a/app/api/session/[id]/route.ts b/app/api/session/[id]/route.ts new file mode 100644 index 0000000..c78b7eb --- /dev/null +++ b/app/api/session/[id]/route.ts @@ -0,0 +1,18 @@ +import { getSession } from '../store'; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const parts = url.pathname.split('/'); + const id = parts[parts.length - 1] || ''; + if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } }); + + const sess = getSession(id); + if (!sess) return new Response(JSON.stringify({ error: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } }); + + return new Response(JSON.stringify(sess), { status: 200, headers: { 'content-type': 'application/json' } }); + } catch (err) { + console.error('Error in GET /api/session/:id', err); + return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +} diff --git a/app/api/session/proxy/[id]/route.ts b/app/api/session/proxy/[id]/route.ts new file mode 100644 index 0000000..09c9478 --- /dev/null +++ b/app/api/session/proxy/[id]/route.ts @@ -0,0 +1,22 @@ +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const parts = url.pathname.split('/'); + const id = parts[parts.length - 1] || ''; + if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } }); + + const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || ''; + if (!backend) { + return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } + + const resp = await fetch(`${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(id)}`); + const text = await resp.text(); + const headers = { 'content-type': 'application/json' }; + return new Response(text, { status: resp.status, headers }); + } catch (err) { + console.error('proxy GET /api/session/:id error', err); + return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +} + diff --git a/app/api/session/proxy/route.ts b/app/api/session/proxy/route.ts new file mode 100644 index 0000000..0652929 --- /dev/null +++ b/app/api/session/proxy/route.ts @@ -0,0 +1,19 @@ +export async function POST(req: Request) { + try { + const body = await req.json(); + const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || ''; + if (!backend) { + return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } + + const url = `${backend.replace(/\/$/, '')}/api/session`; + const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const text = await resp.text(); + const headers = { 'content-type': 'application/json' }; + return new Response(text, { status: resp.status, headers }); + } catch (err) { + console.error('proxy /api/session error', err); + return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +} + diff --git a/app/api/session/route.ts b/app/api/session/route.ts new file mode 100644 index 0000000..287979c --- /dev/null +++ b/app/api/session/route.ts @@ -0,0 +1,44 @@ +import { AccessToken } from 'livekit-server-sdk'; +import { saveSession } from './store'; + +// API Route (POST) que crea una sesión y devuelve token + studioUrl + id +// Env required: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, optionally STUDIO_URL or STUDIO_BASE_URL + +export async function POST(req: Request) { + try { + const body = await req.json(); + const room = body.room || 'e2e-room'; + const username = body.username || 'visual-runner'; + const ttl = body.ttl || 300; // segundos + + const apiKey = process.env.LIVEKIT_API_KEY; + const apiSecret = process.env.LIVEKIT_API_SECRET; + if (!apiKey || !apiSecret) { + return new Response(JSON.stringify({ error: 'LIVEKIT_API_KEY and LIVEKIT_API_SECRET required' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } + + const at = new AccessToken(apiKey, apiSecret, { identity: username, name: username, ttl: `${ttl}s` }); + at.addGrant({ + roomJoin: true, + room: room, + canPublish: true, + canSubscribe: true, + canPublishSources: ['camera', 'microphone', 'screen'], + } as any); + + const token = at.toJwt(); + const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2,8)}`; + + // Construir studioUrl usando la estructura rooms/[roomName] por defecto + const studioBase = (process.env.STUDIO_BASE_URL || process.env.STUDIO_URL || process.env.BROADCAST_URL || '').trim(); + const studioUrl = studioBase ? `${studioBase.replace(/\/$/, '')}/rooms/${encodeURIComponent(room)}` : ''; + + // Guardar sesión en store en memoria + saveSession(id, { token, room, username, studioUrl }); + + return new Response(JSON.stringify({ id, token, studioUrl }), { status: 200, headers: { 'content-type': 'application/json' } }); + } catch (err) { + console.error('Error in /api/session', err); + return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } }); + } +} diff --git a/app/api/session/store.ts b/app/api/session/store.ts new file mode 100644 index 0000000..d87f135 --- /dev/null +++ b/app/api/session/store.ts @@ -0,0 +1,36 @@ +export type SessionData = { + id: string; + token?: string | null; + room?: string | null; + username?: string | null; + studioUrl?: string | null; + createdAt: number; +}; + +const sessionStore = new Map(); + +export function saveSession(id: string, data: Omit, 'id'>) { + const payload: SessionData = { + id, + token: data.token ?? null, + room: data.room ?? null, + username: data.username ?? null, + studioUrl: data.studioUrl ?? null, + createdAt: Date.now(), + }; + sessionStore.set(id, payload); + return payload; +} + +export function getSession(id: string): SessionData | null { + return sessionStore.get(id) || null; +} + +export function deleteSession(id: string): boolean { + return sessionStore.delete(id); +} + +export function listSessions() { + return Array.from(sessionStore.values()); +} + diff --git a/app/rooms/[roomName]/AutoRequestAndInject.tsx b/app/rooms/[roomName]/AutoRequestAndInject.tsx new file mode 100644 index 0000000..0d68a73 --- /dev/null +++ b/app/rooms/[roomName]/AutoRequestAndInject.tsx @@ -0,0 +1,97 @@ +'use client'; +import { useEffect, useState } from 'react'; + +type SessionResp = { id?: string; token?: string; studioUrl?: string; username?: string; [k: string]: any }; + +async function resolveIdentity(): Promise { + // 1) try NextAuth standard endpoint + try { + const r = await fetch('/api/auth/session'); + if (r.ok) { + const j = await r.json(); + // NextAuth returns { user: { name, email }, expires } + if (j && j.user) { + return j.user.name || j.user.email || null; + } + } + } catch (e) { /* ignore */ } + + // 2) try a global set by the app + try { + // @ts-ignore + if (typeof window !== 'undefined' && (window as any).__AVANZACAST_USER) { + // @ts-ignore + const u = (window as any).__AVANZACAST_USER; + if (typeof u === 'string') return u; + if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email; + } + } catch (e) { /* ignore */ } + + // 3) fallback: prompt the user (only in dev/testing) + try { + const answer = window.prompt('Identifícate (nombre de usuario) para ligar con la room', 'visual-runner'); + if (answer && answer.trim()) return answer.trim(); + } catch (e) { /* ignore */ } + + return null; +} + +export default function AutoRequestAndInject({ roomName }: { roomName: string }) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [username, setUsername] = useState(null); + + useEffect(() => { + let mounted = true; + async function run() { + setLoading(true); + setError(null); + try { + const identity = await resolveIdentity(); + if (!identity) throw new Error('No identity resolved; please login or set window.__AVANZACAST_USER'); + if (mounted) setUsername(identity); + + const resp = await fetch('/api/session/proxy', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: roomName, username: identity, ttl: 300 }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const json: SessionResp = await resp.json(); + + // Try to get token if not in POST response + if (!json.token) { + if (json.id) { + const getResp = await fetch(`/api/session/proxy/${encodeURIComponent(json.id)}`); + if (getResp.ok) { + const getJson = await getResp.json(); + if (getJson && getJson.token) { + json.token = getJson.token; + } + } + } + } + + if (!json.token) throw new Error('No token returned from session proxy'); + + // Post message with token to same-origin listeners + window.postMessage({ type: 'LIVEKIT_TOKEN', token: json.token, room: roomName, username: identity }, window.location.origin); + console.log('AutoRequestAndInject: posted LIVEKIT_TOKEN to window', { room: roomName, username: identity }); + } catch (err: any) { + console.error('AutoRequestAndInject error', err); + if (mounted) setError(String(err)); + } finally { + if (mounted) setLoading(false); + } + } + run(); + return () => { mounted = false; }; + }, [roomName]); + + if (loading) return
Solicitando token de sesión para room "{roomName}"...
; + if (error) return
Error solicitando token: {error}
; + return ( +
+ {username ?
Identidad: {username}
: null} +
+ ); +} diff --git a/app/rooms/[roomName]/page.tsx b/app/rooms/[roomName]/page.tsx new file mode 100644 index 0000000..c857f4d --- /dev/null +++ b/app/rooms/[roomName]/page.tsx @@ -0,0 +1,18 @@ +import AutoRequestAndInject from './AutoRequestAndInject'; + +export default function RoomPage({ params }: { params: { roomName: string } }) { + const { roomName } = params; + + return ( +
+

Room: {roomName}

+ + {/* Aquí va el componente del estudio real (VideoConference) */} +
+ {/* El estudio debe estar preparado para escuchar postMessage LIVEKIT_TOKEN */} +

Esperando token para conectar al studio...

+
+
+ ); +} + diff --git a/docs/img_3.png b/docs/img_3.png new file mode 100644 index 0000000..5f252e4 Binary files /dev/null and b/docs/img_3.png differ diff --git a/docs/img_4.png b/docs/img_4.png new file mode 100644 index 0000000..252e2c2 Binary files /dev/null and b/docs/img_4.png differ diff --git a/docs/portal_livekit.md b/docs/portal_livekit.md new file mode 100644 index 0000000..bbee395 --- /dev/null +++ b/docs/portal_livekit.md @@ -0,0 +1,215 @@ +## 🛠️ Integración de LiveKit en Next.js con Componentes y Estilo Similar a StreamYard + +Para lograr una interfaz similar a la captura de StreamYard en tu proyecto **Next.js** con **Vite** (aunque Next.js ya tiene su propio *bundler*, si usas Vite para otras partes o pruebas, los componentes de LiveKit para React son la clave), la mejor estrategia es utilizar la librería oficial de **LiveKit React Components** y luego aplicar estilos para simular la disposición. + +### 1\. ⚙️ Configuración e Instalación + +Aunque Next.js no usa Vite para el *bundling* principal, la integración de las librerías sigue los mismos pasos que cualquier aplicación React: + +* **Instala el SDK y los Componentes de React:** + + ```bash + npm install livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk + # O usa yarn/pnpm: + # yarn add livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk + ``` + + * `livekit-client`: El SDK principal para la conectividad WebRTC. + * `@livekit/components-react`: Componentes de UI preconstruidos (la base para la interfaz). + * `@livekit/components-styles`: Estilos base para los componentes. + * `livekit-server-sdk`: Necesario para generar los **tokens de acceso** en tu API de Next.js. + +* **Generación de Tokens (Backend):** + Crea una **API Route** en Next.js (por ejemplo, en `/app/api/token/route.ts` para Next.js 13+) que use `livekit-server-sdk` para generar un token de acceso (JWT). Este token es necesario para que el cliente se conecte a la sala. + + > **Nota:** Necesitarás tu `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` y `LIVEKIT_URL` configurados en tu archivo `.env.local`. + +### 2\. 🏗️ Estructura de Componentes de LiveKit + +La interfaz de StreamYard que muestras se puede replicar usando una combinación de componentes de LiveKit y **estilos CSS/Tailwind** para el diseño de la cuadrícula. + +1. **Contenedor Principal de la Sala:** + Envuelve tu aplicación de video en el componente ``. + + ```tsx + import '@livekit/components-styles'; + import { LiveKitRoom, VideoConference } from '@livekit/components-react'; + + export default function MiSala({ token, serverUrl }) { + return ( + + {/* Aquí van tus componentes personalizados */} + + + ); + } + ``` + +2. **Paneles y Diseño (Simulación de la Interfaz):** + + La clave para simular la interfaz es usar CSS Grid o Flexbox en tu página de Next.js para colocar los componentes en las áreas que se ven en la captura: + + | Área en StreamYard (Captura) | Componente de LiveKit o Custom | Descripción | + | :--- | :--- | :--- | + | **Escenas/Mis Escenas (Izquierda)** | **Custom React Component** | Un componente personalizado de Next.js para gestionar escenas, **fuera** de LiveKitRoom, pero que interactúa con el estado de la sala (por ejemplo, publicando/despublicando pistas o cambiando el *layout*). | + | **Vídeo Central (720p)** | **GridLayout / FocusLayout** | Usa componentes de *Layout* de LiveKit, como `` o `` para renderizar la vista principal de los participantes. | + | **Controles (Micrófono, Cámara, etc.)** | **ControlBar / TrackToggle** | El componente `` de LiveKit es ideal para la barra de control inferior, ya que incluye botones listos para Mute/Cámara/Pantalla Compartida. | + | **Participantes (Cesar Mendivil)** | **ParticipantTile** | Puedes usar `` para iterar sobre los participantes y renderizar un `` para cada uno en la vista inferior o principal. | + | **Comentarios (Derecha)** | **Chat** | El componente `` de LiveKit proporciona una funcionalidad de chat lista para usar que se ajusta a la sección de "Comentarios". | + +### 3\. ✨ Estilizado y Componentes Personalizados + +* **Estilos:** Importa los estilos base de LiveKit (`import '@livekit/components-styles';`) y utiliza **CSS Modules** o **Tailwind CSS** (muy común en Next.js) para crear el diseño de cuadrícula principal y los estilos específicos (bordes, fondos, etc.) que se asemejan a StreamYard. Utiliza *slots* o envoltorios personalizados alrededor de los componentes de LiveKit si necesitas un estilo muy específico que no se puede lograr con la personalización de LiveKit. +* **Vite:** Si estás usando Vite en paralelo para el frontend, la instalación y el uso de los componentes de React son idénticos. + +Aquí tienes un video que muestra cómo hacer una aplicación de videollamadas con Next.js y LiveKit. +[Video Call Application with Next js and LiveKit | 2024](https://www.youtube.com/watch?v=-dfLbXOAoa8) es un tutorial que te guiará a través de la creación de una aplicación de videollamadas con Next.js y LiveKit. + +http://googleusercontent.com/youtube_content/0 + + +## 🔑 Generación de Tokens con `livekit-server-sdk` + +Para generar un token de acceso con `livekit-server-sdk` en tu backend (como en una API Route de Next.js), necesitas seguir tres pasos clave: configurar el SDK, definir la identidad del usuario y generar el token con los permisos (roles) adecuados. + +Aquí te muestro un ejemplo completo en TypeScript/JavaScript: + +### 1\. 🛠️ Instalación y Configuración + +Asegúrate de tener el SDK instalado: + +```bash +npm install livekit-server-sdk +``` + +Y que tus credenciales estén configuradas (usualmente en variables de entorno): + +* **`LIVEKIT_API_KEY`** +* **`LIVEKIT_API_SECRET`** +* **`LIVEKIT_URL`** (la URL de tu servidor LiveKit, e.g., `wss://misuperapp.livekit.cloud`) + +----- + +### 2\. 📝 Ejemplo de Código para Generar un Token + +Utiliza la clase **`AccessToken`** para crear un nuevo token, incluyendo los permisos para la sala. + +```typescript +import { AccessToken, RoomServiceClient } from 'livekit-server-sdk'; +import type { VideoGrant } from 'livekit-server-sdk'; + +// ---------------------------------------------------- +// 1. Obtén las credenciales de tus variables de entorno +// ---------------------------------------------------- +const apiKey = process.env.LIVEKIT_API_KEY; +const apiSecret = process.env.LIVEKIT_API_SECRET; +// const livekitUrl = process.env.LIVEKIT_URL; // Solo es necesaria si usas RoomServiceClient + +if (!apiKey || !apiSecret) { + throw new Error('LIVEKIT_API_KEY y LIVEKIT_API_SECRET deben estar configuradas.'); +} + +/** + * Genera un token de acceso para un usuario específico en una sala. + * @param roomName El nombre de la sala a la que se conectará el usuario. + * @param identity La identidad única del usuario (e.g., ID de usuario o nombre de usuario). + * @param canPublish Permiso para publicar video/audio/pantalla (típico de un participante). + * @param canSubscribe Permiso para ver/escuchar a otros participantes (siempre debe ser true). + * @param roomJoin Permiso para unirse a la sala. + * @returns El token JWT generado (string). + */ +export function generateLiveKitToken( + roomName: string, + identity: string, + canPublish: boolean, + isModerator: boolean = false, +): string { + // ---------------------------------------------------- + // 2. Crea una instancia de AccessToken + // ---------------------------------------------------- + const at = new AccessToken(apiKey!, apiSecret!, { + identity: identity, + // (Opcional) Nombre del usuario que se mostrará en la UI + name: identity, + // (Opcional) Define un tiempo de expiración + ttl: '10m', + }); + + // ---------------------------------------------------- + // 3. Define los permisos (VideoGrant) + // ---------------------------------------------------- + const grant: VideoGrant = { + roomJoin: true, // Permitir unirse a la sala + room: roomName, // El nombre de la sala específica + + // Permisos de publicación/suscripción + canPublish: canPublish, // Permite publicar audio/video/pantalla + canSubscribe: true, // Permite recibir pistas de otros + + // (Opcional) Permisos avanzados para moderadores + canUpdate: isModerator, // Permite actualizar la sala (mutear a otros, etc.) + roomAdmin: isModerator, // Permisos de administrador de sala + canPublishSources: ['camera', 'microphone', 'screen'], // Fuentes permitidas + }; + + at.addGrant(grant); + + // ---------------------------------------------------- + // 4. To string: genera el token JWT + // ---------------------------------------------------- + const token = at.toJwt(); + + console.log(`Token generado para ${identity} en sala ${roomName}:`, token); + + return token; +} +``` + +----- + +### 3\. 🌐 Uso Típico en Next.js (API Route) + +En una API Route de Next.js (`/api/livekit/token`): + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { generateLiveKitToken } from './utils'; // Asume que el código anterior está en 'utils' + +export async function POST(req: NextRequest) { + try { + const { roomName, identity, isPublisher } = await req.json(); + + if (!roomName || !identity) { + return NextResponse.json({ error: 'Faltan parámetros: roomName e identity' }, { status: 400 }); + } + + // Un publicador tiene 'canPublish: true', un espectador tendría 'canPublish: false' + const token = generateLiveKitToken(roomName, identity, isPublisher); + + return NextResponse.json({ token }, { status: 200 }); + + } catch (error) { + console.error('Error generando token:', error); + return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 }); + } +} +``` + +### Variables de entorno útiles + +Además de las variables necesarias para la generación de tokens (`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`), el paquete del Broadcast Panel expone una variable específica para controlar un "preflight" que provoca el prompt de permisos de cámara/micrófono antes de intentar conectar al estudio. + +- `VITE_STUDIO_PREFLIGHT` (opcional): controla si se ejecuta un preflight que llama a `navigator.mediaDevices.getUserMedia` para forzar el prompt de permisos antes de la negociación WebRTC. + - Valores válidos: `1` / `true` (habilitado por defecto), `0` / `false` (deshabilitado). + - Uso típico: en desarrollo puedes dejarlo habilitado para asegurar que el navegador solicite permisos al entrar al estudio; en entornos de pruebas automáticas o cuando no quieres el prompt automático, pon `VITE_STUDIO_PREFLIGHT=0`. + +Hemos añadido un ejemplo de archivo de entorno en: + +- `packages/broadcast-panel/.env.example` + +que incluye `VITE_STUDIO_PREFLIGHT=1` y otras variables de ejemplo que el paquete puede leer (p. ej. `VITE_BACKEND_API_URL`, `VITE_BACKEND_TOKENS_URL`, `VITE_BROADCASTPANEL_URL`, `VITE_LIVEKIT_WS_URL`). diff --git a/docs/sequence_livekit.png b/docs/sequence_livekit.png new file mode 100644 index 0000000..96d5782 Binary files /dev/null and b/docs/sequence_livekit.png differ diff --git a/e2e/README.md b/e2e/README.md index b75c02c..4a45029 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -76,6 +76,35 @@ node validate-flow-remote-chrome.js ``` Salida: `e2e/validate-flow-remote-chrome-result.json` + screenshot +## Ejecutar contra Chrome Remoto (remote debugging port 9222) + +Si tienes un Chrome con `--remote-debugging-port=9222` (local o remoto), puedes ejecutar el validador que se conecta a ese navegador. + +1) Si conoces la websocket URL completa (ej: `ws://1.2.3.4:9222/devtools/browser/xxxx`): + +```bash +export CHROME_WS="ws://1.2.3.4:9222/devtools/browser/xxxx" +export BROADCAST_URL="https://your-broadcast.example.com" +export STUDIO_URL="https://your-studio.example.com" +export TOKEN="optional-token-if-you-want" +node validate-flow-remote-chrome.js +``` + +2) Si sólo conoces el host (ej: `1.2.3.4` o `1.2.3.4:9222`) usa el helper: + +```bash +# desde la raíz del repo +CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \ + bash run-remote-chrome.sh +``` + +3) Shortcut npm desde la raíz del monorepo (usa el helper creado `e2e/run-remote-chrome.sh`): + +```bash +CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \ + npm run e2e:remote-chrome +``` + ### Validar flujo `/:id` (session id stored in DB -> broadcast panel) Hemos añadido un test E2E específico que valida el flujo en el que el token-server crea una sesión y el Broadcast Panel la consume a través de la ruta `/:id`. diff --git a/e2e/_launch_chrome_puppeteer.js b/e2e/_launch_chrome_puppeteer.js new file mode 100644 index 0000000..542dca2 --- /dev/null +++ b/e2e/_launch_chrome_puppeteer.js @@ -0,0 +1,26 @@ +// Launch a Chromium instance via puppeteer-core and print the WebSocket endpoint to stdout +// Also write the launcher PID to /tmp/puppeteer_chrome_launcher.pid +const fs = require('fs'); +(async () => { + try { + const puppeteer = require('puppeteer-core'); + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }); + const ws = browser.wsEndpoint(); + const pidFile = '/tmp/puppeteer_chrome_launcher.pid'; + try { fs.writeFileSync(pidFile, String(process.pid)); } catch(e) { } + console.log('PUPPETEER_WS=' + ws); + console.log('LAUNCHER_PID=' + process.pid); + // keep process alive until killed + process.on('SIGINT', async () => { try { await browser.close(); } catch(e){}; process.exit(0); }); + process.on('SIGTERM', async () => { try { await browser.close(); } catch(e){}; process.exit(0); }); + // prevent exit + await new Promise(() => {}); + } catch (err) { + console.error('LAUNCH_ERROR', String(err)); + process.exit(2); + } +})(); + diff --git a/e2e/run-remote-chrome.sh b/e2e/run-remote-chrome.sh new file mode 100644 index 0000000..6bbe09f --- /dev/null +++ b/e2e/run-remote-chrome.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# e2e/run-remote-chrome.sh +# Resolve Chrome websocket endpoint from CHROME_HOST (host[:port]) or use CHROME_WS if provided. +# Then run e2e/validate-flow-remote-chrome.js with the resolved CHROME_WS env var. +set -eu +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" || exit 1 +if [ -n "${CHROME_WS:-}" ]; then + echo "Using provided CHROME_WS" + export CHROME_WS="$CHROME_WS" +else + if [ -z "${CHROME_HOST:-}" ]; then + echo "Error: set CHROME_HOST (host[:port]) or CHROME_WS (websocket url)" >&2 + exit 2 + fi + HOST="$CHROME_HOST" + # If port not present, default to 9222 + if [[ "$HOST" != *":"* ]]; then + HOST="$HOST:9222" + fi + URL="http://${HOST}/json/version" + echo "Fetching websocket endpoint from $URL" + WS="$(curl -sS "$URL" | node -e "const s=fs.readFileSync(0,'utf8'); try{const j=JSON.parse(s); console.log(j.webSocketDebuggerUrl||'')}catch(e){console.error('failed parse json',e); process.exit(2)}")" || true + if [ -z "$WS" ]; then + echo "Failed to get webSocketDebuggerUrl from $URL" >&2 + curl -sS "$URL" || true + exit 3 + fi + export CHROME_WS="$WS" + echo "Resolved CHROME_WS=$CHROME_WS" +fi +# Run the validator +node e2e/validate-flow-remote-chrome.js + diff --git a/e2e/run-smoke-local.sh b/e2e/run-smoke-local.sh new file mode 100644 index 0000000..3b5efaf --- /dev/null +++ b/e2e/run-smoke-local.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# e2e/run-smoke-local.sh +# Smoke test helper: arranca mock server, arranca Chrome (opcional) o fallback via puppeteer launcher, +# ejecuta `validate-flow-remote-chrome.js` y limpia los procesos. +# Usage: +# CHROME_CMD="/usr/bin/google-chrome" ./run-smoke-local.sh +# or set CHROME_HOST/CHROME_WS to reuse an existing remote Chrome +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +LOGDIR="$ROOT_DIR/logs" +mkdir -p "$LOGDIR" + +MOCK_LOG="$LOGDIR/mock_server.log" +CHROME_LOG="$LOGDIR/chrome_launch.log" +VALIDATOR_LOG="$LOGDIR/validator.log" + +echo "[smoke] working dir: $ROOT_DIR" + +# Start mock server +echo "[smoke] starting mock server... (logs: $MOCK_LOG)" +node mock_server.js > "$MOCK_LOG" 2>&1 & +MOCK_PID=$! +sleep 0.6 +if ! kill -0 "$MOCK_PID" 2>/dev/null; then + echo "[smoke][error] failed to start mock server; see $MOCK_LOG" + tail -n 200 "$MOCK_LOG" || true + exit 2 +fi +echo "[smoke] mock server pid=$MOCK_PID" + +# Determine Chrome websocket endpoint +WS="" +if [ -n "${CHROME_WS:-}" ]; then + WS="$CHROME_WS" + echo "[smoke] using CHROME_WS from env" +fi + +# If user provided CHROME_HOST, resolve ws +if [ -z "$WS" ] && [ -n "${CHROME_HOST:-}" ]; then + echo "[smoke] resolving websocket endpoint from CHROME_HOST=$CHROME_HOST" + URL="http://${CHROME_HOST%:*}:${CHROME_HOST##*:}/json/version" + # if CHROME_HOST already has port include it directly + if [[ "$CHROME_HOST" == *":"* ]]; then URL="http://$CHROME_HOST/json/version"; fi + if ! curl -sS "$URL" > /dev/null 2>&1; then + echo "[smoke] warning: cannot reach $URL" + else + WS=$(curl -sS "$URL" | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true + echo "[smoke] resolved WS=$WS" + fi +fi + +# If still no WS, try to launch Chrome via CHROME_CMD if provided +LAUNCHED_CHROME_PID="" +if [ -z "$WS" ]; then + if [ -n "${CHROME_CMD:-}" ]; then + echo "[smoke] launching Chrome via CHROME_CMD=$CHROME_CMD" + "$CHROME_CMD" --remote-debugging-port=9222 --user-data-dir=/tmp/avz_smoke_profile --no-first-run --no-default-browser-check > "$CHROME_LOG" 2>&1 & + LAUNCHED_CHROME_PID=$! + sleep 1 + # try to fetch ws + for i in 1 2 3 4 5; do + if curl -sS http://localhost:9222/json/version >/dev/null 2>&1; then + WS=$(curl -sS http://localhost:9222/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true + break + fi + sleep 1 + done + echo "[smoke] chrome launched pid=$LAUNCHED_CHROME_PID ws=$WS" + else + # fallback: use puppeteer launcher script + echo "[smoke] no CHROME_CMD provided; launching Chromium via puppeteer (fallback)" + node _launch_chrome_puppeteer.js > "$CHROME_LOG" 2>&1 & + LAUNCHER_PID=$! + sleep 0.8 + # read ws from log + for i in 1 2 3 4 5; do + if grep -m1 PUPPETEER_WS= "$CHROME_LOG" >/dev/null 2>&1; then + WS_LINE=$(grep -m1 PUPPETEER_WS= "$CHROME_LOG" || true) + WS=${WS_LINE#PUPPETEER_WS=} + break + fi + sleep 1 + done + echo "[smoke] puppeteer-launched chrome pid=$LAUNCHER_PID ws=$WS" + LAUNCHED_CHROME_PID=${LAUNCHER_PID:-} + fi +fi + +if [ -z "$WS" ]; then + echo "[smoke][warn] could not resolve Chrome websocket endpoint; you can set CHROME_WS or CHROME_HOST to skip launch" + echo "[smoke] continuing to try validator using CHROME_HOST if set" +fi + +# Run validator using CHROME_WS if available else CHROME_HOST +echo "[smoke] running validator (logs: $VALIDATOR_LOG)" +export CHROME_WS="$WS" +export BROADCAST_URL="http://localhost:4001/broadcast" +export STUDIO_URL="http://localhost:4001/studio" +export TOKEN="smoketest-token-123" +node validate-flow-remote-chrome.js > "$VALIDATOR_LOG" 2>&1 || echo "[smoke] validator exited non-zero; see $VALIDATOR_LOG" + +# show result snippets +echo "[smoke] validator log (tail 200):" +tail -n 200 "$VALIDATOR_LOG" || true + +# cleanup +echo "[smoke] cleaning up..." +if [ -n "${MOCK_PID:-}" ]; then kill $MOCK_PID >/dev/null 2>&1 || true; fi +if [ -n "${LAUNCHED_CHROME_PID:-}" ]; then kill $LAUNCHED_CHROME_PID >/dev/null 2>&1 || true; fi +if [ -n "${LAUNCHER_PID:-}" ]; then kill $LAUNCHER_PID >/dev/null 2>&1 || true; fi + +echo "[smoke] logs stored in $LOGDIR" +exit 0 + diff --git a/e2e/run-tests.js b/e2e/run-tests.js new file mode 100644 index 0000000..3885609 --- /dev/null +++ b/e2e/run-tests.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// e2e/run-tests.js +// Lee e2e/tests.json y ejecuta validate-flow-remote-chrome.js para cada caso en serie. +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); + +const testsPath = path.resolve(__dirname, 'tests.json'); +if (!fs.existsSync(testsPath)) { + console.error('tests.json not found at', testsPath); + process.exit(2); +} + +const tests = JSON.parse(fs.readFileSync(testsPath, 'utf8')); + +async function runTest(t) { + return new Promise((resolve) => { + console.log('\n=== Running test:', t.name, '==='); + const env = Object.assign({}, process.env, t); + // Use the helper script if CHROME_HOST provided; else require CHROME_WS + const script = path.resolve(__dirname, 'run-remote-chrome.sh'); + const proc = spawn('bash', [script], { env, stdio: 'inherit' }); + proc.on('close', (code) => { + console.log(`Test ${t.name} finished with code ${code}`); + resolve(code); + }); + proc.on('error', (err) => { + console.error('Failed to spawn process for', t.name, err); + resolve(3); + }); + }); +} + +(async () => { + for (const t of tests) { + const code = await runTest(t); + if (code !== 0) { + console.warn('Test failed, stopping further tests. Last code:', code); + process.exit(code || 1); + } + } + console.log('\nAll tests passed (or completed).'); +})(); + diff --git a/e2e/tests.json b/e2e/tests.json new file mode 100644 index 0000000..a542ef9 --- /dev/null +++ b/e2e/tests.json @@ -0,0 +1,23 @@ +[ + { + "name": "basic-flow", + "BROADCAST_URL": "http://localhost:5176", + "STUDIO_URL": "http://localhost:5176/studio", + "TOKEN": "testtoken123", + "EXPECTED_ROOM": "avanzacast-studio", + "EXPECTED_USERNAME": "Demo User", + "MIN_TTL_SECONDS": 30, + "CHROME_HOST": "localhost:9222" + }, + { + "name": "mock-flow", + "BROADCAST_URL": "http://localhost:4001/broadcast", + "STUDIO_URL": "http://localhost:4001/studio", + "TOKEN": "mocktoken", + "EXPECTED_ROOM": "mock-room", + "EXPECTED_USERNAME": "mock-user", + "MIN_TTL_SECONDS": 10, + "CHROME_HOST": "localhost:9222" + } +] + diff --git a/e2e/validate-flow-browserless.js b/e2e/validate-flow-browserless.js index 44c2a35..defb6fe 100644 --- a/e2e/validate-flow-browserless.js +++ b/e2e/validate-flow-browserless.js @@ -11,6 +11,151 @@ const fs = require('fs'); const path = require('path'); const puppeteer = require('puppeteer-core'); +// Helper: evaluate sessionStorage and backend token assertions +async function evaluateSessionAssertions(broadcastPage, studioPage, results, BROADCAST_URL, TOKEN) { + results.assertions = results.assertions || []; + try { + const storeKey = 'avanzacast_studio_session'; + const stored = await broadcastPage.evaluate((k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } }, storeKey); + if (stored) { + try { + const parsed = JSON.parse(stored); + if (parsed && parsed.token) { + const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)'; + results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note }); + + try { + const parts = String(parsed.token).split('.'); + const isJwt = parts.length >= 3; + results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' }); + if (isJwt) { + const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4); + const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8'); + try { + const payloadObj = JSON.parse(decoded); + const nowSec = Math.floor(Date.now() / 1000); + if (payloadObj.exp && typeof payloadObj.exp === 'number') { + results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` }); + } else { + results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' }); + } + if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) }); + } catch (e) { + results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) }); + } + } + } catch (e) { + results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) }); + } + } else { + results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' }); + } + + if (parsed && parsed.room) { + results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) }); + } else { + results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' }); + } + if (parsed && (parsed.url || parsed.serverUrl)) { + const serverUrl = parsed.url || parsed.serverUrl; + const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://')); + results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' }); + } else { + results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' }); + } + + try { + const expectedRoom = process.env.EXPECTED_ROOM || null; + const expectedUsername = process.env.EXPECTED_USERNAME || null; + const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); + + if (expectedRoom) { + const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom); + results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` }); + } + + if (expectedUsername) { + const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); + const unameMatches = !!uname && String(uname) === String(expectedUsername); + results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` }); + } else { + const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); + results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' }); + } + + let ttlSeconds = null; + if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds; + else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl; + else if (parsed && parsed.expiresAt) { + const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt)); + if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000)); + } + if (ttlSeconds !== null) { + results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` }); + results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` }); + } else { + results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' }); + } + } catch (e) { + results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) }); + } + + if (parsed && (parsed.id || parsed.sessionId)) { + const sid = parsed.id || parsed.sessionId; + try { + const base = (BROADCAST_URL || '').replace(/\/$/, ''); + const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`; + let fetched = null; + try { + const fetchMod = await import('node-fetch'); + const r = await fetchMod.default(tokenEndpoint).catch(() => null); + if (r && r.ok) fetched = await r.json().catch(() => null); + } catch (fe) { fetched = null } + if (!fetched) { + const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null; + if (TOKEN_SERVER) { + const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`; + try { + const fetchMod2 = await import('node-fetch'); + const r2 = await fetchMod2.default(abs).catch(() => null); + if (r2 && r2.ok) fetched = await r2.json().catch(() => null); + } catch (fe2) { fetched = null } + } + } + if (fetched && fetched.token) { + results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' }); + try { + const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); + const bttl = fetched.ttlSeconds || fetched.ttl || null; + if (typeof bttl === 'number') { + results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` }); + results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` }); + } else { + results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' }); + } + } catch (e) { + results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) }); + } + } else { + results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' }); + } + } catch (e) { + results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) }); + } + } + + } catch (e) { + results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) }); + } + } else { + results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' }); + } + } catch (e) { + results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) }); + } +} + (async () => { const outDir = path.resolve(__dirname); const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; @@ -92,6 +237,8 @@ const puppeteer = require('puppeteer-core'); await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' }); results.navigations.push({ type: 'studio_opened', url: studioPage.url() }); await studioPage.waitForTimeout(2500); + // Run assertions (sessionStorage + backend checks) + await evaluateSessionAssertions(page, studioPage, results, BROADCAST_URL, TOKEN); const shot = path.join(outDir, 'studio_flow_browserless_result.png'); await studioPage.screenshot({ path: shot, fullPage: true }); results.screenshot = shot; @@ -125,6 +272,8 @@ const puppeteer = require('puppeteer-core'); const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`; await page.goto(directUrl, { waitUntil: 'networkidle2' }); await page.waitForTimeout(1500); + // Run assertions using current page as both broadcast and studio contexts + await evaluateSessionAssertions(page, page, results, BROADCAST_URL, TOKEN); const shot = path.join(outDir, 'studio_flow_browserless_result.png'); await page.screenshot({ path: shot, fullPage: true }); results.screenshot = shot; diff --git a/e2e/validate-flow-remote-chrome.js b/e2e/validate-flow-remote-chrome.js index a0c24c6..f81a12d 100644 --- a/e2e/validate-flow-remote-chrome.js +++ b/e2e/validate-flow-remote-chrome.js @@ -116,18 +116,149 @@ async function getWsEndpointFromHost(host) { try { const parsed = JSON.parse(stored); // Accept any token returned by backend; if TOKEN env is set we also note if it matches + // Basic presence checks if (parsed && parsed.token) { const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)'; results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note }); + + // Token format: JWT-like (3 parts) + try { + const parts = String(parsed.token).split('.'); + const isJwt = parts.length >= 3; + results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' }); + if (isJwt) { + // decode payload safely (base64url) + const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4); + const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8'); + try { + const payloadObj = JSON.parse(decoded); + const nowSec = Math.floor(Date.now() / 1000); + if (payloadObj.exp && typeof payloadObj.exp === 'number') { + results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` }); + } else { + results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' }); + } + if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) }); + } catch (e) { + results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) }); + } + } + } catch (e) { + results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) }); + } } else { results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' }); } - } catch(e) { - results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) }); - } - } else { - results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' }); - } + + // Check room and url fields if present + if (parsed && parsed.room) { + results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) }); + } else { + results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' }); + } + if (parsed && (parsed.url || parsed.serverUrl)) { + const serverUrl = parsed.url || parsed.serverUrl; + const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://')); + results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' }); + } else { + results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' }); + } + + // Additional optional assertions: username and TTL + try { + const expectedRoom = process.env.EXPECTED_ROOM || null; + const expectedUsername = process.env.EXPECTED_USERNAME || null; + const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); + + if (expectedRoom) { + const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom); + results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` }); + } + + if (expectedUsername) { + const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); + const unameMatches = !!uname && String(uname) === String(expectedUsername); + results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` }); + } else { + // still assert presence of username if not explicitly expected + const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName); + results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' }); + } + + // TTL checks: looks for ttlSeconds, ttl or expiresAt + let ttlSeconds = null; + if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds; + else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl; + else if (parsed && parsed.expiresAt) { + // expiresAt may be timestamp or Date string + const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt)); + if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000)); + } + if (ttlSeconds !== null) { + results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` }); + results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` }); + } else { + results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' }); + } + } catch (e) { + results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) }); + } + + // If there's an id/sessionId available, try to query backend token endpoint for extra validation + if (parsed && (parsed.id || parsed.sessionId)) { + const sid = parsed.id || parsed.sessionId; + try { + // attempt to fetch from broadcast origin first + const base = (BROADCAST_URL || '').replace(/\/$/, ''); + const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`; + let fetched = null; + try { + const r = await (await import('node-fetch'))(tokenEndpoint); + if (r && r.ok) fetched = await r.json().catch(() => null); + } catch (fe) { + // ignore - will try absolute later + fetched = null; + } + if (!fetched) { + // try absolute token server env if available + const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null; + if (TOKEN_SERVER) { + const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`; + try { + const r2 = await (await import('node-fetch'))(abs, { mode: 'cors' }); + if (r2 && r2.ok) fetched = await r2.json().catch(() => null); + } catch (fe2) { fetched = null } + } + } + if (fetched && fetched.token) { + results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' }); + // If backend returned ttlSeconds, validate against minTtl + try { + const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30); + const bttl = fetched.ttlSeconds || fetched.ttl || null; + if (typeof bttl === 'number') { + results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` }); + results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` }); + } else { + results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' }); + } + } catch (e) { + results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) }); + } + } else { + results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' }); + } + } catch (e) { + results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) }); + } + } + } catch(e) { + results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) }); + } + } else { + results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' }); + } } catch (e) { results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) }); } diff --git a/package-lock.json b/package-lock.json index 3ae006b..146d9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31279,7 +31279,8 @@ "puppeteer-core": "^20.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "ws": "^8.13.0" }, "devDependencies": { "@types/react": "^18.2.0", diff --git a/package.json b/package.json index b42f6de..e499b6b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ ], "scripts": { "dev": "concurrently \"npm:dev:*\"", + "e2e:remote-chrome": "bash e2e/run-remote-chrome.sh", "dev:landing": "npm run dev --workspace=packages/landing-page", "dev:api": "npm run dev --workspace=packages/backend-api", "dev:studio": "npm run dev --workspace=packages/broadcast-studio", diff --git a/packages/backend-api/.env.debug b/packages/backend-api/.env.debug new file mode 100644 index 0000000..ca0dc13 --- /dev/null +++ b/packages/backend-api/.env.debug @@ -0,0 +1,7 @@ +# Debug env for backend-api +# Enables decoded JWT header in responses and some extra debug verification info +ALLOW_DEBUG_TOKEN_HEADER=1 +NODE_ENV=development +# Optional: enable extra debug verify step using LIVEKIT_API_SECRET (if configured) +# LIVEKIT_API_SECRET=your_livekit_api_secret_here + diff --git a/packages/backend-api/README.DEBUG.md b/packages/backend-api/README.DEBUG.md new file mode 100644 index 0000000..acdd6cb --- /dev/null +++ b/packages/backend-api/README.DEBUG.md @@ -0,0 +1,52 @@ +# Debugging backend-api: enable token header debug + +This small guide shows how to enable JWT debug headers in `/api/session/:id/token` responses. + +What it does +- When `ALLOW_DEBUG_TOKEN_HEADER=1` or NODE_ENV=development, the backend will include `debugHeader` in the JSON response for `/api/session/:id/token`. +- This helps to inspect `alg`, `typ` and other header fields when LiveKit rejects tokens. + +How to enable locally +1) Copy the debug env file (optional): + +```bash +cd packages/backend-api +cp .env.debug .env +# Edit .env to set LIVEKIT_API_SECRET or LIVEKIT_API_KEY if required +``` + +2) Start the backend in development mode (or restart if it's already running): + +```bash +# if you run directly with node +npm install +npm run dev + +# or if you use ts-node / nodemon adjust accordingly +``` + +How to enable in docker-compose +- If `backend-api` runs in docker-compose, set environment in the compose file or use an env_file: + +```yaml +services: + backend-api: + image: ... + env_file: + - packages/backend-api/.env.debug +``` + +Then `docker compose up -d backend-api`. + +How to test +1) Create or obtain a session id from the token server or the UI. +2) Request token details with debug: + +```bash +curl -s "http://localhost:4000/api/session//token" | jq . +``` + +Look for `debugHeader` and `debugVerifyHS256` fields. + +If you prefer I can (A) add an endpoint `/debug/verify-token` that runs jwt verify using the configured secret (only when ALLOW_DEBUG_TOKEN_HEADER=1) or (B) prepare a docker-compose snippet that injects the debug env automatically. Reply A or B to proceed. + diff --git a/packages/backend-api/src/index.ts b/packages/backend-api/src/index.ts index 5c650e1..50103d5 100644 --- a/packages/backend-api/src/index.ts +++ b/packages/backend-api/src/index.ts @@ -305,6 +305,26 @@ async function createLivekitTokenFor(room: string, username: string) { try { h = JSON.parse(decoded); } catch (e) { /* keep raw */ } console.log('[LIVEKIT] token header:', h); + + // If SDK produced a token with non-HS256 alg, and we have LIVEKIT_API_SECRET, re-sign payload with HS256 + try { + const secretForResign = LIVEKIT_API_SECRET || process.env.LIVEKIT_API_SECRET; + if (secretForResign && typeof h === 'object' && h.alg && String(h.alg).toUpperCase() !== 'HS256') { + try { + const parts = (token as string).split('.'); + const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4); + const payloadRaw = parts[1] || ''; + const payloadJson = JSON.parse(Buffer.from(pad(payloadRaw).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); + // preserve iat/exp if present, stringify payload as-is and sign with HS256 + const jwtLib = require('jsonwebtoken'); + const newToken = jwtLib.sign(payloadJson, secretForResign, { algorithm: 'HS256' }); + console.log('[LIVEKIT] SDK token used alg=' + (h.alg || 'unknown') + ' — re-signed with HS256 fallback'); + return { token: newToken, url: LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880') }; + } catch (e) { + console.warn('[LIVEKIT] failed to re-sign token with HS256 fallback', String(e)); + } + } + } catch (e) { /* ignore fallback errors */ } } } catch (e) { console.warn('[LIVEKIT] failed to decode token header', String(e)); } // Prefer explicit websocket URL env, else derive from LIVEKIT_URL (http(s) -> ws(s)) @@ -349,32 +369,58 @@ app.get('/api/token', async (req, res) => { } }); +// New endpoint: connection-details +app.get('/api/connection-details', async (req, res) => { + try { + // Accept either 'room' or 'roomName' for compatibility with frontend + const room = (req.query.room && typeof req.query.room === 'string') ? req.query.room : undefined; + const roomName = (req.query.roomName && typeof req.query.roomName === 'string') ? req.query.roomName : undefined; + const participantName = (req.query.participantName && typeof req.query.participantName === 'string') ? req.query.participantName : undefined; + const finalRoom = room || roomName; + if (!finalRoom) return res.status(400).json({ error: 'room or roomName query param is required' }); + if (!participantName) return res.status(400).json({ error: 'participantName query param is required' }); + + const { token, url } = await createLivekitTokenFor(finalRoom, participantName); + + // create a session id to store the token for short-lived retrieval if needed + const sessionId = generateShortId(9); + await saveSession(sessionId, { token, url, room: finalRoom, username: participantName }, SESSION_TTL); + + // Return roomName for frontend compatibility + return res.json({ serverUrl: url, participantToken: token, sessionId, roomName: finalRoom }); + } catch (err) { + console.error('[backend-api] /api/connection-details failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +async function validateTokenWithLiveKit(token: string) { + try { + // derive http(s) origin from LIVEKIT_WS_URL or LIVEKIT_URL + const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'; + let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://'); + try { const u = new URL(httpUrl); httpUrl = `${u.protocol}//${u.host}`; } catch (e) {} + const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`; + const resp = await fetch(validateUrl, { method: 'GET' }); + const ct = resp.headers.get('content-type') || ''; + const text = await resp.text(); + if (ct.includes('application/json')) { + try { return { status: resp.status, ok: resp.ok, body: JSON.parse(text) }; } catch (e) { return { status: resp.status, ok: resp.ok, body: text }; } + } + return { status: resp.status, ok: resp.ok, body: text }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + // Debug/validate endpoint: validate a token against LiveKit from the backend app.all('/api/session/validate', async (req, res) => { try { const token = (req.method === 'GET' ? req.query.token : req.body?.token) || req.query.token || req.body?.token; if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' }); - // derive http(s) origin from LIVEKIT_WS_URL - const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'; - let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://'); - try { - const u = new URL(httpUrl); - httpUrl = `${u.protocol}//${u.host}`; - } catch (e) { - // keep as-is - } - - const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`; - console.log('[backend-api] proxy validate to', validateUrl.slice(0, 200)); - - const resp = await fetch(validateUrl, { method: 'GET' }); - const ct = resp.headers.get('content-type') || ''; - const text = await resp.text(); - if (ct.includes('application/json')) { - try { const json = JSON.parse(text); return res.status(resp.status).json({ ok: resp.ok, status: resp.status, body: json }); } catch (e) {} - } - return res.status(resp.status).send(text); + const result = await validateTokenWithLiveKit(token); + return res.status(result.status).json({ ok: result.ok, status: result.status, body: result.body }); } catch (err) { console.error('[backend-api] validate proxy failed', err); return res.status(500).json({ error: 'validate_failed', details: String(err) }); @@ -413,14 +459,55 @@ app.post('/api/session', async (req, res) => { ? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}` : `${studioBase}/?session=${encodeURIComponent(id)}`; - return res.json({ + // Prepare base response + const responsePayload: any = { id, - studioUrl: `${studioBase}/${id}`, + // Ruta amigable para el frontend: /studio/:id + studioUrl: `${studioBase}/studio/${id}`, redirectUrl, ttlSeconds: ttlSec, room, username, - }); + }; + + // In dev or when explicitly enabled, attach decoded header of generated token to help debugging + try { + if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') { + if (token && token.split('.').length >= 2) { + const header = token.split('.')[0]; + const padded = header + '='.repeat((4 - (header.length % 4)) % 4); + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + try { responsePayload.debugHeader = JSON.parse(decoded); } catch (e) { responsePayload.debugHeader = decoded } + + // Extra debug: attempt to verify HS256 signature locally using LIVEKIT_API_SECRET (only for debug) + try { + const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; + if (LIVEKIT_API_SECRET) { + // do a best-effort verify using jsonwebtoken + try { + const jwt = require('jsonwebtoken'); + let verified = null; + try { + verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] }); + responsePayload.debugVerifyHS256 = { ok: true, payload: verified }; + } catch (verifyErr) { + responsePayload.debugVerifyHS256 = { ok: false, error: (verifyErr && (verifyErr as any).message) || String(verifyErr) }; + } + } catch (e) { + responsePayload.debugVerifyHS256 = { ok: false, error: 'jsonwebtoken_not_available' }; + } + } else { + responsePayload.debugVerifyHS256 = { ok: false, error: 'no_livekit_api_secret_configured' }; + } + } catch (e) { + // swallow debug verification errors + responsePayload.debugVerifyHS256 = { ok: false, error: String(e) }; + } + } + } + } catch (e) { /* ignore debug attach errors */ } + + return res.json(responsePayload); } catch (err) { console.error('Failed to create session', err); return res.status(500).json({ error: String(err) }); @@ -450,7 +537,19 @@ app.get('/api/session/:id/token', async (req, res) => { const s = await getSession(id); if (!s) return res.status(404).json({ error: 'not_found' }); const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)); - return res.json({ token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url }); + const payload: any = { token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url }; + try { + if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') { + const token = s.token || ''; + if (typeof token === 'string' && token.split('.').length >= 2) { + const header = token.split('.')[0]; + const padded = header + '='.repeat((4 - (header.length % 4)) % 4); + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + try { payload.debugHeader = JSON.parse(decoded); } catch (e) { payload.debugHeader = decoded } + } + } + } catch (e) { /* ignore debug attach errors */ } + return res.json(payload); } catch (err) { console.error('GET /api/session/:id/token failed', err); return res.status(500).json({ error: 'failed', details: String(err) }); @@ -678,7 +777,7 @@ app.post('/api/broadcasts/:id/session', async (req, res) => { const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); const redirectUrl = `${studioBase}/?session=${encodeURIComponent(sid)}`; - return res.json({ id: sid, studioUrl: `${studioBase}/${sid}`, redirectUrl, ttlSeconds: ttlSec }); + return res.json({ id: sid, studioUrl: `${studioBase}/studio/${sid}`, redirectUrl, ttlSeconds: ttlSec }); } catch (err) { console.error('Failed to create broadcast session', err); return res.status(500).json({ error: 'failed', details: String(err) }); @@ -724,7 +823,7 @@ app.post('/api/internal/session', async (req, res) => { const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); const redirectUrl = `${studioBase}/?session=${encodeURIComponent(id)}`; - return res.json({ id, studioUrl: `${studioBase}/${id}`, redirectUrl, ttlSeconds: ttlSec, token }); + return res.json({ id, studioUrl: `${studioBase}/studio/${id}`, redirectUrl, ttlSeconds: ttlSec, token }); } catch (err) { console.error('Failed to create internal session', err); return res.status(500).json({ error: 'failed', details: String(err) }); @@ -782,3 +881,138 @@ app.get('/debug/session/:id/header', async (req, res) => { } }); +// Debug endpoint: verify a token locally (only enabled in development or when ALLOW_DEBUG_TOKEN_HEADER=1) +app.post('/debug/verify-token', async (req, res) => { + try { + if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') { + return res.status(403).json({ error: 'not_allowed' }); + } + const token = (req.body && req.body.token) || req.query.token || ''; + if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' }); + + const out: any = { tokenPreview: token.slice(0, 32) + '...' }; + // decode header and payload + try { + const parts = token.split('.'); + if (parts.length >= 2) { + const header = parts[0]; + const payload = parts[1]; + const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4); + try { out.header = JSON.parse(Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } + try { out.payload = JSON.parse(Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } + } else { + out.header = null; out.payload = null; + } + } catch (e) { + out.decodeError = String(e); + } + + // Local verify using configured secret (HS256) if available + try { + const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; + if (LIVEKIT_API_SECRET) { + try { + const jwt = require('jsonwebtoken'); + const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] }); + out.localVerify = { ok: true, payload: verified }; + } catch (e) { + out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) }; + } + } else { + out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' }; + } + } catch (e) { + out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) }; + } + + // Proxy to LiveKit validate endpoint to get server-side validation result + try { + const result = await validateTokenWithLiveKit(token); + out.livekit = { status: result.status, ok: result.ok, body: result.body }; + } catch (e) { + out.livekit = { ok: false, error: String(e) }; + } + + return res.json(out); + } catch (err) { + console.error('[DEBUG] /debug/verify-token failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +// Debug endpoint: verify and return full session info (token, decoded parts, local and LiveKit verification) +app.get('/debug/session/:id/full', async (req, res) => { + try { + // Only allow in development or when explicitly enabled + if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') { + return res.status(403).json({ error: 'not_allowed' }); + } + const id = String(req.params.id || ''); + if (!id) return res.status(400).json({ error: 'missing_id' }); + const s = await getSession(id); + if (!s) return res.status(404).json({ error: 'not_found' }); + + const token = s.token || ''; + const out: any = { + id, + room: s.room, + username: s.username, + url: s.url, + expiresAt: s.expiresAt, + ttlSeconds: Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)), + tokenPreview: typeof token === 'string' ? (token.length > 64 ? token.slice(0, 32) + '...' + token.slice(-16) : token) : token, + }; + + // decode header/payload if JWT-like + try { + if (typeof token === 'string' && token.split('.').length >= 2) { + const parts = token.split('.'); + const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4); + try { out.header = JSON.parse(Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } + try { out.payload = JSON.parse(Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } + } else { + out.header = null; out.payload = null; + } + } catch (e) { + out.decodeError = String(e); + } + + // Local verify using configured secret (HS256) if available + try { + const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; + if (LIVEKIT_API_SECRET) { + try { + const jwt = require('jsonwebtoken'); + const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] }); + out.localVerify = { ok: true, payload: verified }; + } catch (e) { + out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) }; + } + } else { + out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' }; + } + } catch (e) { + out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) }; + } + + // Proxy to LiveKit validate endpoint to get server-side validation result + try { + const result = await validateTokenWithLiveKit(token); + out.livekit = { status: result.status, ok: result.ok, body: result.body }; + } catch (e) { + out.livekit = { ok: false, error: String(e) }; + } + + // Attach raw token only in development to avoid leaking secrets in other envs + try { + if (process.env.NODE_ENV === 'development' || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') { + out.token = token; + } + } catch (e) {} + + return res.json(out); + } catch (err) { + console.error('[DEBUG] /debug/session/:id/full failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); diff --git a/packages/broadcast-panel/.env.example b/packages/broadcast-panel/.env.example index d3a2509..4ce0f71 100644 --- a/packages/broadcast-panel/.env.example +++ b/packages/broadcast-panel/.env.example @@ -1,7 +1,11 @@ -# LiveKit Configuration -VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host -VITE_TOKEN_SERVER_URL=https://avanzacast-studio.bfzqqk.easypanel.host +# Example env for broadcast-panel (Vite) +# Enable/disable automatic getUserMedia preflight in StudioRoom +# Set to 1/true to enable (default), 0/false to disable +VITE_STUDIO_PREFLIGHT=1 + +# Other env examples used by the package (adjust as needed) +# VITE_BACKEND_API_URL=https://api.example.com +# VITE_BACKEND_TOKENS_URL=https://tokens.example.com +# VITE_BROADCASTPANEL_URL=https://broadcastpanel.example.com +# VITE_LIVEKIT_WS_URL=wss://livekit.example.com -# Application Configuration -VITE_APP_NAME=AvanzaCast Broadcast Panel -VITE_APP_VERSION=1.0.0 diff --git a/packages/broadcast-panel/e2e/README.md b/packages/broadcast-panel/e2e/README.md index f8eca8d..f5324c2 100644 --- a/packages/broadcast-panel/e2e/README.md +++ b/packages/broadcast-panel/e2e/README.md @@ -1,38 +1,86 @@ -Local E2E runner +# E2E visual baseline — instrucciones de uso -This E2E runner automates the UI flow for Broadcast Panel -> StudioPortal -> LiveKit token handshake. +Este README explica cómo verificar que un `token` y una `room` habilitan la videollamada con el flujo E2E del proyecto. -Prereqs -- Node 18+ -- npm packages installed in `packages/broadcast-panel` (run `npm install` there) -- Either a local Chrome launched with remote debugging or access to a remote browser service like browserless +Requisitos +- Node.js (>=16) +- Chrome/Chromium instalado en el host donde corras la prueba +- Opcional: browserless si no quieres ejecutar Chrome local -Start local Chrome with remote debugging (example): +Archivos relevantes +- `packages/broadcast-panel/e2e/generate_visual_baseline.js` — script E2E que crea una sesión en el token server, se conecta a Chrome remoto/browserless, navega al estudio y publica token vía postMessage. +- `packages/broadcast-panel/e2e/start-chrome-remote.sh` — script para arrancar Chrome/Chromium en modo remote-debugging. + +Flujo de verificación paso a paso + +1) Levantar token server (opcional si ya tienes uno) + +Si no tienes un token server, puedes usar la ruta Next.js incluida: `/app/api/session/route.ts`. +- Asegúrate de tener `LIVEKIT_API_KEY` y `LIVEKIT_API_SECRET` en el entorno (o usa un mock server local). + +2) Arrancar Chrome con remote debugging + +Opción segura (localhost): ```bash cd packages/broadcast-panel/e2e chmod +x start-chrome-remote.sh ./start-chrome-remote.sh -# verify -curl http://localhost:9222/json/version ``` -Run the E2E runner connecting to a remote Chrome (default) or browserless: +Opción con exposición pública (NO RECOMENDADA): +```bash +REMOTE_DEBUG_PUBLIC=1 REMOTE_DEBUG_PORT=9222 ./start-chrome-remote.sh +``` + +3) Comprobar endpoint CDP ```bash -# Connect to local remote-debugging chrome -cd packages/broadcast-panel -node e2e/run_local_e2e.js --ws http://localhost:9222 --show - -# Or connect to browserless remote (example) -REMOTE_WS="wss://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d" node e2e/run_local_e2e.js --show - -# To point to specific backend/broadcast hosts (useful when running remote browserless): -REMOTE_WS="..." BROADCAST_URL="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" TOKEN_SERVER="https://avanzacast-servertokens.bfzqqk.easypanel.host" node e2e/run_local_e2e.js --show +curl -sS http://localhost:9222/json/version +curl -sS http://localhost:9222/json/list ``` -Notes -- The script will postMessage the LIVEKIT_TOKEN to the StudioPortal when a token is created in backend-api. -- If StudioPortal does not auto-connect, the runner will attempt to click a "Conectar" button (class .btn-small). -- Logs and screenshots are saved to `packages/broadcast-panel/e2e/out`. +Busca `webSocketDebuggerUrl` en la salida. + +4) Ejecutar el script E2E + +```bash +# ejemplo apuntando al chrome local y al token server local (Next.js en 3000 o 3001) +REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 TOKEN_SERVER="http://localhost:3001" ROOM="e2e-room-1" BROADCAST_URL="http://url_dominio_publico/room/:idroom" node packages/broadcast-panel/e2e/generate_visual_baseline.js +``` + +El script escribirá logs a `e2e/out/generate_visual_baseline.log` y guardará capturas en `e2e/out/visual_/studio.png`. + +5) Validar en el frontend que el token es recibido + +El script hace `window.postMessage({ type: 'LIVEKIT_TOKEN', token })`. En el frontend (estudio) debes tener un listener similar a: + +```js +window.addEventListener('message', (ev) => { + if (ev.origin !== window.location.origin) return; + if (ev.data && ev.data.type === 'LIVEKIT_TOKEN') { + const token = ev.data.token; + // conectar a LiveKit con token: room.connect(serverUrl, token) o usando components-react + } +}); +``` + +6) Debug rápido + +- Si `createSession` falla: revisa `TOKEN_SERVER` y mira `curl -v TOKEN_SERVER/api/session`. +- Si no se resuelve CDP: valida `curl http://localhost:9222/json/version`. +- Si el frontend no conecta: abre la consola del navegador (o inspecciona la ejecución en Puppeteer con page.on('console') logs en `e2e/out/generate_visual_baseline.log`). + +7) Qué revisar si la videollamada no se inicia + +- El token tiene que ser válido (LIVEKIT_API_KEY/SECRET correctos y server LiveKit accesible desde el frontend). +- `studioUrl` debe corresponder al dominio público donde está el frontend. `BROADCAST_URL` o `studioUrl` devuelto por el token server debe ser exactamente la URL que abrirás (por ejemplo: `https://miapp.example/room/abc123`). +- El frontend debe usar el token para conectar al room (Room.connect o LiveKit components). + + +Si quieres, puedo: +- Añadir una ruta de verificación GET `/api/session/:id` que devuelva la sesión guardada (facilita recuperar token tras POST). +- Añadir un mock server `tools/mock-token-server.js` para pruebas locales sin credenciales. + +Dime si quieres que cree el mock-server o adapte la API a un flujo GET/POST adicional. diff --git a/packages/broadcast-panel/e2e/e2e_remote_9222.js b/packages/broadcast-panel/e2e/e2e_remote_9222.js new file mode 100644 index 0000000..7c6d3b4 --- /dev/null +++ b/packages/broadcast-panel/e2e/e2e_remote_9222.js @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// E2E script: connect to remote Chrome on localhost:9222 and run token flow (create session -> open Broadcast Panel -> Entrar al Estudio) +// Usage: BROWSERLESS_WS not used. Instead set REMOTE_DEBUGGER_URL (e.g. http://localhost:9222) or default to http://localhost:9222 +// Environment variables: +// TOKEN_SERVER (default: https://avanzacast-servertokens.bfzqqk.easypanel.host) +// ROOM, USERNAME +// OUT_DIR to save artifacts + +const fetch = require('node-fetch'); +const puppeteer = require('puppeteer-core'); +const fs = require('fs'); +const path = require('path'); + +async function main() { + const REMOTE_DEBUGGER = process.env.REMOTE_DEBUGGER_URL || 'http://localhost:9222'; + const TOKEN_SERVER = process.env.TOKEN_SERVER || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; + const ROOM = process.env.ROOM || 'e2e-room'; + const USERNAME = process.env.USERNAME || 'e2e-runner'; + const OUT_DIR = process.env.OUT_DIR || '/tmp'; + + function outLog(...args) { + console.log(...args); + if (OUT_DIR) { + try { fs.appendFileSync(path.join(OUT_DIR, 'e2e_remote_9222.log'), args.map(String).join(' ') + '\n'); } catch (e) {} + } + } + + outLog('E2E remote 9222 starting', { REMOTE_DEBUGGER, TOKEN_SERVER, ROOM, USERNAME, OUT_DIR }); + + // Create session on token server + let sessResp; + try { + sessResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: ROOM, username: USERNAME, ttl: 300 }) + }); + } catch (err) { + outLog('Network error when creating session:', String(err)); + process.exit(3); + } + + outLog('Token server status', sessResp.status); + let sessBodyText = await sessResp.text().catch(() => ''); + let sessJson = null; + try { sessJson = JSON.parse(sessBodyText); } catch(e) { sessJson = null } + outLog('Session response body start:', String(sessBodyText).slice(0,400)); + if (!sessResp.ok) { + outLog('Failed to create session'); + process.exit(4); + } + if (!sessJson) { + outLog('Invalid JSON from token server'); + process.exit(5); + } + + const sessionId = sessJson.id || sessJson.sessionId || null; + const token = sessJson.token || null; + const studioUrl = sessJson.studioUrl || sessJson.redirectUrl || sessJson.url || null; + + outLog('Session created', { sessionId: sessionId ? sessionId.slice(0,10) + '...' : null, studioUrl, hasToken: !!token }); + + // Connect to remote Chrome DevTools + // We expect a running Chrome with --remote-debugging-port=9222. puppeteer.connect accepts websocket endpoint; fetch the ws endpoint from /json/version + let wsEndpoint; + try { + const versionResp = await fetch(`${REMOTE_DEBUGGER.replace(/\/$/, '')}/json/version`); + const verJson = await versionResp.json(); + wsEndpoint = verJson.webSocketDebuggerUrl || null; + } catch (err) { + outLog('Failed to query remote debugger /json/version:', String(err)); + process.exit(6); + } + + if (!wsEndpoint) { + outLog('Remote debugger did not return webSocketDebuggerUrl. Check Chrome is running with --remote-debugging-port=9222'); + process.exit(7); + } + + outLog('Connecting to remote chrome wsEndpoint', wsEndpoint); + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1366, height: 768 }, timeout: 20000 }); + } catch (err) { + outLog('puppeteer.connect failed:', String(err)); + process.exit(8); + } + + try { + const page = await browser.newPage(); + page.on('console', msg => outLog('[BROWSER]', msg.type(), msg.text())); + page.on('pageerror', err => outLog('[PAGEERROR]', String(err))); + + // Navigate to either the studioUrl returned or to broadcast panel root and click 'Entrar al Estudio' + let targetUrl = studioUrl; + if (!targetUrl && sessionId) { + // use broadcast panel host from environment or default + const BROADCAST_HOST = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + targetUrl = `${BROADCAST_HOST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`; + } + + if (!targetUrl) { + outLog('No target URL to open'); + process.exit(9); + } + + outLog('Opening target URL:', targetUrl); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(err => { outLog('page.goto error', String(err)); throw err; }); + + // If page has a visible button/link text 'Entrar' or 'Entrar al Estudio', click it + const clickSelectors = ["button[aria-label='Entrar al Estudio']","button:has-text('Entrar al Estudio')","button:has-text('Entrar')","a:has-text('Entrar al Estudio')","a:has-text('Entrar')"]; + let clicked = false; + for (const sel of clickSelectors) { + try { + // Puppeteer does not support :has-text in older versions; use evaluate to find by text + const found = await page.evaluate((text) => { + const els = Array.from(document.querySelectorAll('button,a')); + const el = els.find(e => (e.innerText || '').trim().toLowerCase().includes(text.toLowerCase())); + if (el) { el.scrollIntoView(); el.click(); return true; } + return false; + }, sel.replace(/.*:has-text\('(.+)'\).*/, '$1')); + if (found) { clicked = true; outLog('Clicked element matching', sel); break; } + } catch (e) { /* ignore */ } + } + + // If token is present, postMessage it + if (token) { + outLog('Posting token via postMessage (len=' + String(token.length) + ')'); + try { + await page.evaluate((tk) => { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, url: window.location.href }, window.location.origin); }, token); + } catch (e) { outLog('postMessage failed', String(e)); } + } else { + outLog('No token present in session response; relying on redirect flow'); + } + + // Wait for token received indicator in page + const gotToken = await page.waitForFunction(() => document.body && document.body.innerText && (document.body.innerText.includes('Token recibido') || document.body.innerText.includes('Token recibido desde Broadcast Panel') || document.body.innerText.includes('Esperando token')), { timeout: 10000 }).catch(() => false); + outLog('Token indicator found:', !!gotToken); + + // Save artifacts + if (OUT_DIR) { + try { const html = await page.content(); fs.writeFileSync(path.join(OUT_DIR, `page_${sessionId || 'noid'}.html`), html); outLog('Saved page HTML'); } catch(e) { outLog('Failed to save HTML', String(e)); } + try { await page.screenshot({ path: path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`), fullPage: true }); outLog('Saved screenshot'); } catch(e) { outLog('Failed to save screenshot', String(e)); } + } + + await page.close(); + } finally { + try { await browser.disconnect(); } catch(e) {} + } + + outLog('E2E remote 9222 finished'); +} + +main().catch(err => { console.error('Unhandled error in main:', err && err.stack ? err.stack : String(err)); process.exit(99); }); + diff --git a/packages/broadcast-panel/e2e/generate_visual_baseline.js b/packages/broadcast-panel/e2e/generate_visual_baseline.js new file mode 100644 index 0000000..1d39ee2 --- /dev/null +++ b/packages/broadcast-panel/e2e/generate_visual_baseline.js @@ -0,0 +1,222 @@ +#!/usr/bin/env node +/* + generate_visual_baseline.js + - Creates a session on the token server + - Connects to a remote Chrome with debugging enabled (default http://localhost:9222), + or to browserless via WS/CDP using puppeteer-core if remote-debug is not available + - Navigates to studioUrl and posts token if present + - Saves a screenshot to e2e/baseline/studio.png and e2e/out//studio.png +*/ + +// Diagnostic helpers early to surface environment issues quickly +try { + console.log('=== generate_visual_baseline start ==='); + console.log('cwd:', process.cwd()); + console.log('node version:', process.version); + console.log('SCRIPT: generate_visual_baseline.js'); + console.log('ENV: REMOTE_DEBUG, REMOTE_DEBUG_WS, BROWSERLESS_WS, BROWSERLESS_TOKEN, TOKEN_SERVER, BROADCAST_URL'); + console.log('REMOTE_DEBUG=', process.env.REMOTE_DEBUG, 'REMOTE_DEBUG_WS=', process.env.REMOTE_DEBUG_WS || process.env.REMOTE_WS, 'REMOTE_DEBUG_PORT=', process.env.REMOTE_DEBUG_PORT); + console.log('BROWSERLESS_WS=', process.env.BROWSERLESS_WS, 'BROWSERLESS_TOKEN=', !!process.env.BROWSERLESS_TOKEN); + console.log('TOKEN_SERVER=', process.env.TOKEN_SERVER || process.env.BACKEND); + console.log('BROADCAST_URL=', process.env.BROADCAST_URL); +} catch (e) { /* ignore */ } + +process.on('uncaughtException', (err) => { + console.error('UNCAUGHT EXCEPTION:', err && err.stack ? err.stack : err); + process.exit(2); +}); +process.on('unhandledRejection', (reason) => { + console.error('UNHANDLED REJECTION:', reason && reason.stack ? reason.stack : reason); + process.exit(2); +}); + +const fetch = global.fetch ? global.fetch : (...args) => import('node-fetch').then(m => m.default(...args)); +const puppeteer = require('puppeteer-core'); +const fs = require('fs'); +const path = require('path'); + +// Enhanced remote debug resolution: support REMOTE_DEBUG_ADDRESS + REMOTE_DEBUG_PORT +const REMOTE_DEBUG_ADDRESS = process.env.REMOTE_DEBUG_ADDRESS || process.env.REMOTE_DEBUG_HOST || 'localhost'; +const REMOTE_DEBUG_PORT = process.env.REMOTE_DEBUG_PORT || process.env.REMOTE_PORT || ''; +let REMOTE_DEBUG_WS = process.env.REMOTE_WS || process.env.REMOTE_DEBUG || ''; +if (!REMOTE_DEBUG_WS) { + if (REMOTE_DEBUG_PORT) { + REMOTE_DEBUG_WS = `http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}`; + } else { + REMOTE_DEBUG_WS = 'http://localhost:9222'; + } +} + +const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WSE || 'wss://browserless.bfzqqk.easypanel.host'; +const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || process.env.BROWSERLESS || ''; +const TOKEN_SERVER = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; +// Cambiar BROADCAST default para seguir la estructura rooms/ +const BROADCAST_BASE = process.env.BROADCAST_BASE || process.env.STUDIO_BASE || 'http://localhost:5175'; +const ROOM = process.env.ROOM || 'iuqiw-aksjka'; +const BROADCAST = process.env.BROADCAST_URL || `${BROADCAST_BASE.replace(/\/$/, '')}/rooms/${encodeURIComponent(ROOM)}`; +const CDP_RESOLVE_RETRIES = Number(process.env.CDP_RESOLVE_RETRIES || 6); +const CDP_RESOLVE_INTERVAL = Number(process.env.CDP_RESOLVE_INTERVAL_MS || 2000); + +function log(...args){ + try { console.log(...args); } catch (e) {} + try { + const outdir = path.join(process.cwd(), 'e2e', 'out'); + try { fs.mkdirSync(outdir, { recursive: true }); } catch(e) {} + const logfile = path.join(outdir, 'generate_visual_baseline.log'); + try { fs.appendFileSync(logfile, `[${new Date().toISOString()}] ${args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}\n`); } catch(e) {} + } catch (e) {} +} + +async function createSession(){ + log('Creating session on token server', TOKEN_SERVER, ROOM); + const res = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }), + }); + const text = await res.text(); try { return JSON.parse(text); } catch(e){ return null; } +} + +async function resolveRemoteWSEndpoint(raw){ + if (!raw) return null; + raw = String(raw).trim(); + // if already a ws:// or wss:// endpoint, return as-is + if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw; + // if given as a numeric port, assume localhost:port + if (/^\d+$/.test(raw)) raw = `http://localhost:${raw}`; + // if it's missing scheme, assume http + if (!raw.startsWith('http://') && !raw.startsWith('https://')) raw = `http://${raw}`; + try{ + const ver = await fetch(raw.replace(/\/$/, '') + '/json/version'); + if (ver && ver.ok){ const j = await ver.json(); if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; } + }catch(e){ log('resolveRemoteWSEndpoint /json/version error', String(e)); } + try{ + const list = await fetch(raw.replace(/\/$/, '') + '/json/list'); + if (list && list.ok){ const arr = await list.json(); if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; } + }catch(e){ log('resolveRemoteWSEndpoint /json/list error', String(e)); } + try{ + const j = await fetch(raw.replace(/\/$/, '') + '/json'); + if (j && j.ok){ const arr = await j.json(); if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; } + }catch(e){ log('resolveRemoteWSEndpoint /json error', String(e)); } + return null; +} + +async function waitForCDP(raw, retries = CDP_RESOLVE_RETRIES, interval = CDP_RESOLVE_INTERVAL){ + let attempt = 0; + while(attempt < retries){ + attempt++; + try{ + log(`CDP resolve attempt ${attempt}/${retries} for ${raw}`); + const resolved = await resolveRemoteWSEndpoint(raw); + if (resolved) return resolved; + }catch(e){ log('waitForCDP attempt error', String(e)); } + await new Promise(r => setTimeout(r, interval)); + } + return null; +} + +(async ()=>{ + try{ + const session = await createSession(); + if (!session) throw new Error('Failed to create session'); + log('Session created:', session.id, 'studioUrl=', session.studioUrl || session.url); + const studioUrl = session.studioUrl || session.redirectUrl || session.url || BROADCAST; + let token = session.token || null; + if (!token && session.id) { + // try GET + try{ + const getResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(session.id)}`); + const txt = await getResp.text(); const json = JSON.parse(txt); + if (json && json.token) token = json.token; + }catch(e){ log('GET session token failed', String(e)); } + } + + // Try to resolve CDP endpoint from remote debug first + let connectEndpoint = null; + if (REMOTE_DEBUG_WS) { + try{ + const resolved = await waitForCDP(REMOTE_DEBUG_WS); + if (resolved) { + connectEndpoint = resolved; + log('Resolved remote-debug CDP endpoint:', connectEndpoint); + } else { + log('No CDP endpoint discovered at remote-debug URL after retries:', REMOTE_DEBUG_WS); + } + } catch(e){ log('Error resolving remote-debug endpoint', String(e)); } + } + + // If remote debug not available, try browserless + if (!connectEndpoint && BROWSERLESS_WS) { + try{ + if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) { + connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`; + } else { + connectEndpoint = await waitForCDP(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : '')); + if (!connectEndpoint) connectEndpoint = await waitForCDP(BROWSERLESS_WS); + } + log('Browserless resolution result:', connectEndpoint || '(none)'); + }catch(e){ log('Browserless resolve error', String(e)); } + } + + log('connectEndpoint resolved to', connectEndpoint || '(none)'); + + let browser = null; + if (connectEndpoint) { + try{ + browser = await puppeteer.connect({ browserWSEndpoint: connectEndpoint, timeout: 30000, ignoreHTTPSErrors: true }); + log('Connected to remote browser via puppeteer (CDP)'); + }catch(e){ + log('puppeteer.connect failed:', String(e)); + browser = null; + } + } + + if (!browser) { + log('Launching local puppeteer fallback'); + browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); + } + + const page = await browser.newPage(); + page.on('console', msg => { try{ log('[BROWSER]', msg.type(), msg.text()); } catch(e){} }); + page.on('pageerror', err => log('[PAGEERROR]', err && err.stack ? err.stack : String(err))); + + log('Navigating to', studioUrl); + await page.goto(studioUrl, { waitUntil: 'networkidle2' }).catch(e=>{ log('page.goto failed', String(e)); }); + await page.waitForTimeout(1200); + + if (token) { + try{ + log('Posting token to page via postMessage (length', token.length, ')'); + await page.evaluate((tk)=>{ try{ window.postMessage({ type:'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin); } catch(e){} }, token); + }catch(e){ log('postMessage evaluate failed', String(e)); } + } + + await page.waitForTimeout(1200); + + const baselineDir = path.join(process.cwd(), 'e2e', 'baseline'); + try{ fs.mkdirSync(baselineDir, { recursive: true }); } catch(e){} + const outDir = path.join(process.cwd(), 'e2e', 'out', `visual_${Date.now()}`); + try{ fs.mkdirSync(outDir, { recursive: true }); } catch(e){} + const baselinePath = path.join(baselineDir, 'studio.png'); + const outPath = path.join(outDir, 'studio.png'); + + await page.screenshot({ path: outPath, fullPage: false }); + log('Saved capture to', outPath); + + // Copy to baseline if not exists or if FORCE_BASELINE=1 + const force = process.env.FORCE_BASELINE === '1'; + if (!fs.existsSync(baselinePath) || force) { + try{ fs.copyFileSync(outPath, baselinePath); log('Wrote baseline to', baselinePath); } catch(e){ log('Failed writing baseline', String(e)); } + } else { + log('Baseline exists at', baselinePath, '(not overwritten)'); + } + + try{ await page.close(); } catch(e){} + try{ await browser.close(); } catch(e){} + + log('Done'); + process.exit(0); + }catch(err){ + console.error('Fatal', err && err.stack ? err.stack : err); + process.exit(2); + } +})(); diff --git a/packages/broadcast-panel/e2e/playwright.config.ts b/packages/broadcast-panel/e2e/playwright.config.ts new file mode 100644 index 0000000..cb5ed20 --- /dev/null +++ b/packages/broadcast-panel/e2e/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './', + timeout: 60_000, + expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.02 } }, + use: { + headless: true, + viewport: { width: 1280, height: 800 }, + actionTimeout: 10_000, + trace: 'on-first-retry', + ignoreHTTPSErrors: true, + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); + diff --git a/packages/broadcast-panel/e2e/run_e2e_auto.sh b/packages/broadcast-panel/e2e/run_e2e_auto.sh new file mode 100755 index 0000000..12472a1 --- /dev/null +++ b/packages/broadcast-panel/e2e/run_e2e_auto.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env zsh +set -euo pipefail + +# run_e2e_auto.sh +# Script helper para ejecutar los runners E2E del broadcast-panel (browserless o local) +# - Crea un OUT_DIR con timestamp +# - Exporta variables necesarias y ejecuta el runner seleccionado +# - Guarda stdout/stderr en OUT_DIR/e2e.log + +# Defaults (puedes sobrescribir por env o con flags) +BROWSERLESS_WS_DEFAULT="wss://browserless.bfzqqk.easypanel.host" +BROWSERLESS_TOKEN_DEFAULT="" +ROOM_DEFAULT="e2e-room" +TOKEN_SERVER_DEFAULT="https://avanzacast-servertokens.bfzqqk.easypanel.host" +BROADCAST_URL_DEFAULT="http://avanzacast-studio.bfzqqk.easypanel.host" + +# Parse args simples +LOCAL_MODE=0 +SHOW=0 +BROWSERLESS_WS="${BROWSERLESS_WS:-$BROWSERLESS_WS_DEFAULT}" +BROWSERLESS_TOKEN="${BROWSERLESS_TOKEN:-$BROWSERLESS_TOKEN_DEFAULT}" +ROOM="${ROOM:-$ROOM_DEFAULT}" +TOKEN_SERVER="${TOKEN_SERVER:-$TOKEN_SERVER_DEFAULT}" +BROADCAST_URL="${BROADCAST_URL:-$BROADCAST_URL_DEFAULT}" +OUT_DIR="${OUT_DIR:-}" + +usage() { + cat < "$LOGFILE" + +# run and capture both stdout and stderr +{ + "${RUN_CMD[@]}" 2>&1 +} | tee -a "$LOGFILE" +EXIT_CODE=${PIPESTATUS[1]:-0} + +echo "=== FINISH $(date -u) exit=$EXIT_CODE ===" | tee -a "$LOGFILE" + +if [[ $EXIT_CODE -ne 0 ]]; then + echo "Runner exited with code $EXIT_CODE. Revisa $LOGFILE y los archivos en $OUT_DIR" + exit $EXIT_CODE +fi + +echo "E2E runner finished OK. Revisa logs y capturas en: $OUT_DIR" +exit 0 diff --git a/packages/broadcast-panel/e2e/session_loader.e2e.js b/packages/broadcast-panel/e2e/session_loader.e2e.js new file mode 100644 index 0000000..0b1b816 --- /dev/null +++ b/packages/broadcast-panel/e2e/session_loader.e2e.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// E2E test: session_loader +// - Creates a session via backend-api POST /api/session +// - Opens broadcast panel at / +// - Waits for sessionStorage key and verifies token/url present + +const puppeteer = require('puppeteer'); +const fetch = require('node-fetch'); + +(async () => { + const BACKEND = process.env.BACKEND || 'http://localhost:4000'; + const BROADCAST = process.env.BROADCAST || 'http://localhost:5175'; + const HEADLESS = process.env.HEADLESS !== '0'; // set HEADLESS=0 to see browser + + console.log('Using BACKEND=%s BROADCAST=%s HEADLESS=%s', BACKEND, BROADCAST, HEADLESS); + + // 1) create session via backend + const room = 'e2e-test-room-' + Math.random().toString(36).slice(2,8); + const username = 'e2e-user'; + console.log('Creating session for room=%s username=%s', room, username); + let resp; + try { + resp = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username }) + }); + } catch (err) { + console.error('Failed to reach backend to create session:', String(err)); + process.exit(2); + } + if (!resp.ok) { + console.error('Failed to create session on backend:', resp.status); + const txt = await resp.text().catch(()=>null); + console.error('Body:', txt); + process.exit(2); + } + const js = await resp.json(); + const sessionId = js.id || js.sessionId || js.id; + if (!sessionId) { + console.error('Session creation response missing id:', js); + process.exit(2); + } + console.log('Created session id=', sessionId); + + // 2) open broadcast panel at / + const url = `${BROADCAST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`; + console.log('Opening', url); + + const browser = await puppeteer.launch({ headless: HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] }); + const page = await browser.newPage(); + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + page.on('pageerror', err => console.error('PAGE ERROR:', err.message)); + + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + } catch (e) { + console.error('Failed to open broadcast panel url:', e.message); + // capture screenshot for debugging if possible + try { await page.screenshot({ path: '/tmp/e2e_page_error.png', fullPage: false }); console.log('Saved screenshot to /tmp/e2e_page_error.png'); } catch(e){} + await browser.close(); + process.exit(3); + } + + // wait for sessionStorage key with retry + const STORE_KEY = process.env.STORE_KEY || 'avanzacast_studio_session'; + console.log('Waiting for sessionStorage key=', STORE_KEY); + + let found = null; + try { + found = await page.waitForFunction((k) => { try { return !!sessionStorage.getItem(k); } catch (e) { return false } }, { timeout: 15000 }, STORE_KEY).catch(()=>null); + } catch (err) { found = null } + + if (!found) { + console.error('sessionStorage key not found within timeout'); + try { const html = await page.content(); console.error('Page content snapshot length:', html.length); } catch(e){} + try { await page.screenshot({ path: '/tmp/e2e_no_sessionstorage.png' }); console.log('Saved screenshot to /tmp/e2e_no_sessionstorage.png'); } catch(e){} + await browser.close(); + process.exit(4); + } + + const stored = await page.evaluate((k) => sessionStorage.getItem(k), STORE_KEY); + console.log('Stored value (truncated):', stored && stored.slice(0, 400)); + + let parsed = null; + try { parsed = JSON.parse(stored); } catch (e) { console.error('Failed to parse stored JSON', e); } + if (!parsed) { + console.error('No valid session stored'); + await browser.close(); + process.exit(5); + } + + // Accept either token or participantToken + const token = parsed.token || parsed.participantToken || parsed.access_token || parsed.token; + const urlField = parsed.url || parsed.serverUrl || parsed.studioUrl || parsed.redirectUrl; + console.log('Extracted token prefix:', token ? token.slice(0, 24) + '...' : '(none)'); + console.log('Extracted url:', urlField || '(none)'); + + if (!token) { + console.error('Token not found in stored session'); + await browser.close(); + process.exit(6); + } + + // Optionally validate token via backend validate proxy + try { + const v = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session/validate?token=${encodeURIComponent(token)}`); + const vt = await v.text(); + console.log('Validate response (first 400 chars):', vt && vt.slice(0,400)); + } catch (e) { + console.warn('Validate proxy call failed:', e.message); + } + + console.log('E2E test succeeded'); + await browser.close(); + process.exit(0); +})(); diff --git a/packages/broadcast-panel/e2e/start-chrome-remote.sh b/packages/broadcast-panel/e2e/start-chrome-remote.sh old mode 100644 new mode 100755 index 8f6eddd..b8d6555 --- a/packages/broadcast-panel/e2e/start-chrome-remote.sh +++ b/packages/broadcast-panel/e2e/start-chrome-remote.sh @@ -4,25 +4,84 @@ # Uso: # chmod +x start-chrome-remote.sh # ./start-chrome-remote.sh +# +# Mejoras: +# - Permite configurar REMOTE_DEBUG_ADDRESS (por defecto 127.0.0.1). +# - Si REMOTE_DEBUG_PUBLIC=1 se fuerza 0.0.0.0 (exponer a red pública, INSEGURO). +# - Permite configurar REMOTE_DEBUG_PORT (por defecto 9222). +# - Permite pasar flags adicionales con EXTRA_CHROME_FLAGS. +# - Mantiene comportamiento headless cuando no hay DISPLAY. PROFILE_DIR="$HOME/.config/avanzacast-e2e-profile" mkdir -p "$PROFILE_DIR" -echo "Chrome arrancado (si el binario es válido). Remote debugging en: http://localhost:9222/" - - --window-size=1280,900 "$@" & - --disable-extensions \ - --disable-backgrounding-occluded-windows \ - --no-first-run \ - --user-data-dir="$PROFILE_DIR" \ - --remote-debugging-port=9222 \ -"$CHROME_BIN" \ -# Ejecutar Chrome con puerto 9222 (remote debugging) y perfil persistente - -fi - CHROME_BIN=/usr/bin/google-chrome - echo "Advertencia: no se encontró $CHROME_BIN ejecutable, intentando /usr/bin/google-chrome" +# Permite sobreescribir binario con variable de entorno CHROME_BIN +CHROME_BIN=${CHROME_BIN:-/usr/bin/google-chrome} if [ ! -x "$CHROME_BIN" ]; then -CHROME_BIN=${CHROME_BIN:-/usr/bin/google-chrome-stable} -# Ajusta la ruta al binario de Chrome si tu sistema usa otra ruta + CHROME_BIN=/usr/bin/google-chrome-stable +fi +if [ ! -x "$CHROME_BIN" ]; then + CHROME_BIN=/usr/bin/chromium-browser +fi +if [ ! -x "$CHROME_BIN" ]; then + CHROME_BIN=/usr/bin/chromium +fi +if [ ! -x "$CHROME_BIN" ]; then + echo "No se encontró un binario de Chrome/Chromium. Exporta CHROME_BIN o instala Chrome/Chromium." >&2 + exit 1 +fi + +# Configurables +REMOTE_DEBUG_PORT=${REMOTE_DEBUG_PORT:-9222} +# By default restrict debugging to localhost; set REMOTE_DEBUG_PUBLIC=1 to allow 0.0.0.0 +if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then + REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-0.0.0.0} +else + REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-127.0.0.1} +fi + +# Extra flags puede ser usado para ajustar comportamiento en CI o debug +EXTRA_CHROME_FLAGS=${EXTRA_CHROME_FLAGS:-} + +echo "Arrancando Chrome/Chromium con remote-debugging en http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT} usando perfil: $PROFILE_DIR" +if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then + echo "ADVERTENCIA: REMOTE_DEBUG_PUBLIC=1 permite conexiones desde cualquier host. Asegúrate de usar firewall/SSH tunneling para seguridad." >&2 +fi + +# Opciones: usa headless nuevo por defecto si se ejecuta en CI (no hay DISPLAY), pero permite pasar flags extra. +FLAGS=( + --remote-debugging-port=${REMOTE_DEBUG_PORT} + --remote-debugging-address=${REMOTE_DEBUG_ADDRESS} + --user-data-dir="$PROFILE_DIR" + --no-first-run + --disable-extensions + --disable-backgrounding-occluded-windows + --window-size=1280,900 + --enable-logging + --v=1 +) + +# Si no hay DISPLAY, arrancamos headless; si hay DISPLAY, arrancamos en modo normal +if [ -z "$DISPLAY" ]; then + FLAGS+=(--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage) +fi + +# Añadir flags extra si se proporcionan +if [ -n "$EXTRA_CHROME_FLAGS" ]; then + # Separa por espacio, permite pasar múltiples flags + FLAGS+=(${=EXTRA_CHROME_FLAGS}) +fi + +# Ejecutar en background y devolver control +"$CHROME_BIN" "${(@)FLAGS}" "$@" & +CHROME_PID=$! + +echo "Chrome PID: $CHROME_PID" +echo "Remote debugging URL: http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}/" + +# Notas de uso: +# - Para exponer a otra máquina de forma segura, crea un tunnel SSH en lugar de exponer el puerto públicamente: +# ssh -L 9222:localhost:9222 user@remoteserver +# - Para probar la disponibilidad del endpoint: +# curl -sS http://localhost:${REMOTE_DEBUG_PORT}/json/version diff --git a/packages/broadcast-panel/e2e/test_cdp_connect.js b/packages/broadcast-panel/e2e/test_cdp_connect.js new file mode 100644 index 0000000..b9a01f8 --- /dev/null +++ b/packages/broadcast-panel/e2e/test_cdp_connect.js @@ -0,0 +1,17 @@ +(async () => { + const { chromium } = require('playwright'); + const ws = process.env.BROWSERLESS_WS || 'wss://browserless.bfzqqk.easypanel.host'; + const token = process.env.BROWSERLESS_TOKEN || ''; + const endpoint = ws + (ws.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token); + console.log('Attempting CDP connect to', endpoint); + try { + const browser = await chromium.connectOverCDP(endpoint, { timeout: 20000 }); + console.log('Connected! version:', await browser.version()); + await browser.close(); + process.exit(0); + } catch (e) { + console.error('CDP connect failed:', e && e.stack ? e.stack : e); + process.exit(1); + } +})(); + diff --git a/packages/broadcast-panel/e2e/visual-studio.spec.ts b/packages/broadcast-panel/e2e/visual-studio.spec.ts new file mode 100644 index 0000000..8f3aeb4 --- /dev/null +++ b/packages/broadcast-panel/e2e/visual-studio.spec.ts @@ -0,0 +1,132 @@ +import { test, expect } from '@playwright/test'; +import { chromium as pwChromium } from 'playwright'; + +// Defaults point to public deployments (overrideable via env) +const BACKEND = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; +const BROADCAST = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; +const ROOM = process.env.ROOM || `e2e-visual-${Date.now()}`; +const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WS || ''; +const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || ''; + +async function createSession() { + const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }), + }); + const text = await res.text(); + try { return JSON.parse(text); } catch (e) { return null; } +} + +async function resolveRemoteWSEndpoint(raw: string | undefined) { + if (!raw) return null; + let r = String(raw).trim(); + // if starts with ws or wss, return as-is (may still need token appended) + if (r.startsWith('ws://') || r.startsWith('wss://')) return r; + // numeric port -> assume localhost + if (/^\d+$/.test(r)) r = `http://localhost:${r}`; + if (!r.startsWith('http://') && !r.startsWith('https://')) r = `http://${r}`; + try { + const ver = await fetch(r.replace(/\/$/, '') + '/json/version'); + if (ver && ver.ok) { + const j = await ver.json(); + if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; + } + } catch (e) { /* ignore */ } + try { + const list = await fetch(r.replace(/\/$/, '') + '/json/list'); + if (list && list.ok) { + const arr = await list.json(); + if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; + } + } catch (e) { /* ignore */ } + try { + const j = await fetch(r.replace(/\/$/, '') + '/json'); + if (j && j.ok) { + const arr = await j.json(); + if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; + } + } catch (e) { /* ignore */ } + return null; +} + +test('Studio visual snapshot (browserless-aware)', async () => { + const session = await createSession(); + const target = session && session.studioUrl ? session.studioUrl : BROADCAST; + + // Create browser/page either via browserless CDP or local chromium + let browser: any = null; + let context: any = null; + let page: any = null; + let usedRemote = false; + + try { + if (BROWSERLESS_WS) { + // try resolve to webSocketDebuggerUrl (CDP) + let connectEndpoint: string | null = null; + // if BROWSERLESS_WS already contains token param, use as-is; otherwise append token + if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) { + connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`; + } else { + // try to resolve via http endpoints + connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : '')); + if (!connectEndpoint) connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS); + } + + if (connectEndpoint) { + // connect via CDP + try { + browser = await pwChromium.connectOverCDP(connectEndpoint, { timeout: 20000 }); + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + usedRemote = true; + console.log('Connected to remote browser via CDP:', connectEndpoint); + } catch (err) { + console.warn('connectOverCDP failed, will fallback to local chromium:', String(err)); + } + } else { + console.warn('Could not resolve remote CDP endpoint for', BROWSERLESS_WS); + } + } + + if (!page) { + // fallback: launch local chromium + browser = await pwChromium.launch({ headless: true }); + context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + page = await context.newPage(); + console.log('Launched local chromium for visual test'); + } + + console.log('Navigating to', target); + await page.goto(target, { waitUntil: 'networkidle' }); + + // Wait a bit for UI to settle + await page.waitForTimeout(1500); + + // Attempt to post message if we have token + if (session && session.token) { + try { + await page.evaluate((tk) => { + window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin); + }, session.token); + } catch (e) { console.warn('postMessage failed', e); } + } + + // Wait for connection indicators or token received text + await page.waitForTimeout(1500); + + // Take screenshot and compare to baseline + const now = Date.now(); + const outDir = `${process.cwd()}/e2e/out/visual_${now}`; + const fs = require('fs'); + try { fs.mkdirSync(outDir, { recursive: true }); } catch (e) {} + const capturePath = `${outDir}/studio.png`; + await page.screenshot({ path: capturePath, fullPage: false }); + console.log('Saved visual capture to', capturePath); + // Playwright snapshot assertion (baseline management) + await expect(page).toHaveScreenshot('studio.png', { fullPage: false }); + } finally { + try { if (context) await context.close(); } catch (e) {} + try { if (browser && usedRemote && typeof browser.close === 'function') await browser.close(); else if (browser) await browser.close(); } catch (e) {} + } +}); diff --git a/packages/broadcast-panel/package.json b/packages/broadcast-panel/package.json index ae39277..8442021 100644 --- a/packages/broadcast-panel/package.json +++ b/packages/broadcast-panel/package.json @@ -7,10 +7,16 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png" + "test:connect-backend": "node scripts/connect-via-backend.js", + "e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png", + "e2e:session-loader": "node e2e/session_loader.e2e.js", + "e2e:visual": "npx playwright test e2e/visual-studio.spec.ts", + "e2e:visual:update": "BROWSERLESS_WS=\"wss://browserless.bfzqqk.easypanel.host\" BROWSERLESS_TOKEN=\"e2e098863b912f6a178b68e71ec3c58d\" node e2e/generate_visual_baseline.js", + "create:session": "node scripts/create_session.js" }, "dependencies": { "@livekit/components-react": "^2.9.15", + "ws": "^8.13.0", "@livekit/components-styles": "^1.1.6", "avanza-ui": "file:../avanza-ui", "livekit-client": "^2.15.14", diff --git a/packages/broadcast-panel/public/create_session.html b/packages/broadcast-panel/public/create_session.html new file mode 100644 index 0000000..095c914 --- /dev/null +++ b/packages/broadcast-panel/public/create_session.html @@ -0,0 +1,83 @@ + + + + + + Crear sesión — Broadcast Panel + + + +
+

Crear sesión (Broadcast Panel)

+

Genera una sessionId en el token-server y abre la ruta del broadcast panel: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/<sessionId>

+ + + + + + + + + + +
+ + +
+ + + +
Nota: este script intenta POST `/api/session` al token-server y abrir la URL del broadcast panel producida.
+
+ + + + + diff --git a/packages/broadcast-panel/scripts/connect-via-backend.js b/packages/broadcast-panel/scripts/connect-via-backend.js new file mode 100644 index 0000000..c739026 --- /dev/null +++ b/packages/broadcast-panel/scripts/connect-via-backend.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/* connect-via-backend.js + +Simple script de prueba que solicita al backend /api/connection-details y luego intenta una conexión WebSocket al servidor LiveKit +USO: node scripts/connect-via-backend.js --backend http://localhost:4000 --room test_room --name tester +*/ + +import fetch from 'node-fetch'; +import WebSocket from 'ws'; +import { argv } from 'process'; + +function parseArgs() { + const out = { backend: 'http://localhost:4000', room: 'test_room', name: 'tester' }; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + if (a === '--backend' && argv[i+1]) { out.backend = argv[i+1]; i++; continue } + if (a === '--room' && argv[i+1]) { out.room = argv[i+1]; i++; continue } + if (a === '--name' && argv[i+1]) { out.name = argv[i+1]; i++; continue } + } + return out; +} + +(async () => { + const { backend, room, name } = parseArgs(); + console.log('Requesting token from backend:', backend, 'room=', room, 'name=', name); + try { + const resp = await fetch(`${backend}/api/connection-details?room=${encodeURIComponent(room)}&participantName=${encodeURIComponent(name)}`); + if (!resp.ok) { + console.error('Backend responded', resp.status, await resp.text()); + process.exit(2); + } + const json = await resp.json(); + console.log('Backend returned:', json); + const { serverUrl, participantToken } = json; + if (!serverUrl || !participantToken) { + console.error('Missing serverUrl or participantToken in backend response'); + process.exit(3); + } + + // Build wss url param if serverUrl is http(s) + let wsUrl = serverUrl; + if (wsUrl.startsWith('http://')) wsUrl = wsUrl.replace(/^http:\/\//, 'ws://'); + if (wsUrl.startsWith('https://')) wsUrl = wsUrl.replace(/^https:\/\//, 'wss://'); + // append path if not present + if (!wsUrl.endsWith('/')) wsUrl = wsUrl.replace(/\/$/, ''); + const connectUrl = `${wsUrl}/rtc?access_token=${encodeURIComponent(participantToken)}`; + console.log('Attempting WebSocket connect to', connectUrl.slice(0,200)); + + const ws = new WebSocket(connectUrl, { rejectUnauthorized: false }); + let connected = false; + ws.on('open', () => { + connected = true; + console.log('WebSocket opened OK'); + // close after a short timeout + setTimeout(() => { ws.close(); }, 1500); + }); + ws.on('message', (msg) => { + try { console.log('Message:', msg.toString().slice(0,200)); } catch (e) { } + }); + ws.on('close', (code, reason) => { + console.log('WebSocket closed', code, reason && reason.toString ? reason.toString() : reason); + process.exit(0); + }); + ws.on('error', (err) => { + console.error('WebSocket error', err && err.message ? err.message : err); + if (!connected) process.exit(4); + }); + + // Timeout if no connect + setTimeout(() => { + if (!connected) { + console.error('Connection timeout'); + process.exit(5); + } + }, 8000); + + } catch (e) { + console.error('Script failed', String(e)); + process.exit(1); + } +})(); + diff --git a/packages/broadcast-panel/scripts/create_session.js b/packages/broadcast-panel/scripts/create_session.js new file mode 100644 index 0000000..0b77920 --- /dev/null +++ b/packages/broadcast-panel/scripts/create_session.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// create_session.js +// Usage: node scripts/create_session.js [room] [username] [--open] +// Environment overrides: BACKEND (default http://localhost:4000) or TOKEN_SERVER env + +const fetch = require('node-fetch'); +const child = require('child_process'); + +async function main() { + console.log('[create_session] starting'); + const args = process.argv.slice(2).filter(a => !a.startsWith('--')); + const openFlag = process.argv.slice(2).some(a => a === '--open'); + const room = args[0] || `broadcast-${Math.random().toString(36).slice(2,8)}`; + const username = args[1] || 'browser-user'; + + const backend = process.env.BACKEND || process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || 'http://localhost:4000'; + const base = backend.replace(/\/$/, ''); + const url = `${base}/api/session`; + + console.log('[create_session] Creating session on', url); + console.log('[create_session] room=', room, 'username=', username); + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username }), signal: controller.signal }); + clearTimeout(timeout); + console.log('[create_session] response status=', r.status); + const text = await r.text().catch(() => null); + console.log('[create_session] response body (raw):', text && text.slice(0,2000)); + let json = null; + try { json = text ? JSON.parse(text) : null; } catch (e) { json = null } + if (!r.ok) { + console.error('[create_session] Request failed status=', r.status); + console.error('[create_session] Body:', text); + process.exit(2); + } + const id = (json && (json.id || json.sessionId)) || null; + if (!id) { + console.error('[create_session] No session id in response:', json || text); + process.exit(3); + } + const studioHost = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + const sessionUrl = `${studioHost.replace(/\/$/, '')}/${encodeURIComponent(id)}`; + console.log('[create_session] Session created:', JSON.stringify(json, null, 2)); + console.log('[create_session] Open Studio URL:', sessionUrl); + + if (openFlag) { + // xdg-open for linux + try { + child.execSync(`xdg-open "${sessionUrl}"`); + } catch (e) { + console.warn('[create_session] Failed to open browser automatically:', String(e.message || e)); + } + } + process.exit(0); + } catch (err) { + if (err.name === 'AbortError') { + console.error('[create_session] request timed out'); + } else { + console.error('[create_session] Failed to create session:', String(err)); + } + process.exit(1); + } +} + +main(); diff --git a/packages/broadcast-panel/src/components/Banner.tsx b/packages/broadcast-panel/src/components/Banner.tsx index fef0a09..c460701 100644 --- a/packages/broadcast-panel/src/components/Banner.tsx +++ b/packages/broadcast-panel/src/components/Banner.tsx @@ -1,22 +1,55 @@ -import React from 'react' +import React from "react"; interface BannerProps { - children: React.ReactNode - type?: 'info' | 'error' | 'warning' - onClose?: () => void - actionLabel?: string - onAction?: () => void - actionDisabled?: boolean + children: React.ReactNode; + type?: "info" | "error" | "warning"; + onClose?: () => void; + actionLabel?: string; + onAction?: () => void; + actionDisabled?: boolean; } -const Banner: React.FC = ({ children, type = 'info', onClose, actionLabel, onAction, actionDisabled = false }) => { - const bg = type === 'error' ? 'bg-red-700' : type === 'warning' ? 'bg-amber-500' : 'bg-sky-600' - const icon = type === 'error' ? ( - - ) : null +const Banner: React.FC = ({ + children, + type = "info", + onClose, + actionLabel, + onAction, + actionDisabled = false, +}) => { + const bg = + type === "error" + ? "bg-red-700" + : type === "warning" + ? "bg-amber-500" + : "bg-sky-600"; + const icon = + type === "error" ? ( + + + + + ) : null; return ( -
+
{icon}
{children}
@@ -26,17 +59,22 @@ const Banner: React.FC = ({ children, type = 'info', onClose, actio )} {onClose && ( - + )}
- ) -} + ); +}; -export default Banner +export default Banner; diff --git a/packages/broadcast-panel/src/components/Dropdown.module.css b/packages/broadcast-panel/src/components/Dropdown.module.css index fa1842b..4222dca 100644 --- a/packages/broadcast-panel/src/components/Dropdown.module.css +++ b/packages/broadcast-panel/src/components/Dropdown.module.css @@ -8,20 +8,20 @@ top: calc(100% + 6px); right: 0; background-color: var(--surface-color); - border: 1px solid rgba(0,0,0,0.04); + border: 1px solid rgba(0, 0, 0, 0.04); border-radius: 8px; /* lighter shadow so it's less pronounced */ - box-shadow: 0 6px 14px rgba(2,6,23,0.10); + box-shadow: 0 6px 14px rgba(2, 6, 23, 0.1); /* match the create-card min width (220px) so menus align visually */ width: 220px; padding: 4px 0; z-index: 1200; - animation: slideDown 0.18s cubic-bezier(.2,.9,.2,1); + animation: slideDown 0.18s cubic-bezier(0.2, 0.9, 0.2, 1); } /* caret (little pointer) under the trigger */ .dropdownMenu::before { - content: ''; + content: ""; position: absolute; top: -6px; /* horizontal offset from the right edge; configurable via --dropdown-caret-right */ @@ -30,8 +30,8 @@ height: 12px; background: var(--surface-color); transform: rotate(45deg); - border-left: 1px solid rgba(0,0,0,0.04); - border-top: 1px solid rgba(0,0,0,0.04); + border-left: 1px solid rgba(0, 0, 0, 0.04); + border-top: 1px solid rgba(0, 0, 0, 0.04); } @keyframes slideDown { @@ -72,12 +72,12 @@ } .dropdownItem.deleteItem:hover { - background-color: rgba(234,67,53,0.04); + background-color: rgba(234, 67, 53, 0.04); } .dropdownItem:focus { outline: none; - box-shadow: 0 0 0 3px rgba(59,130,246,0.12); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); } /* ensure header (non-interactive) doesn't get hover background */ @@ -103,12 +103,12 @@ padding: 8px 14px; color: var(--text-primary); font-size: 14px; - opacity: 0.90; /* user requested opacity */ + opacity: 0.9; /* user requested opacity */ cursor: default; } .dropdownHeaderLabel { - opacity: 0.90; + opacity: 0.9; color: var(--text-primary); } @@ -134,7 +134,7 @@ /* make delete icon sit inside a small rounded red box like the screenshot */ .deleteItem .icon { - background: rgba(234,67,53,0.08); /* subtle red bg */ + background: rgba(234, 67, 53, 0.08); /* subtle red bg */ padding: 4px; border-radius: 6px; display: inline-flex; @@ -145,6 +145,6 @@ /* tighten divider */ .divider { height: 1px; - background-color: rgba(0,0,0,0.04); + background-color: rgba(0, 0, 0, 0.04); margin: 6px 0; } diff --git a/packages/broadcast-panel/src/components/Dropdown.tsx b/packages/broadcast-panel/src/components/Dropdown.tsx index 8543403..d2a2bc9 100644 --- a/packages/broadcast-panel/src/components/Dropdown.tsx +++ b/packages/broadcast-panel/src/components/Dropdown.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import styles from './Dropdown.module.css'; +import React, { useEffect, useRef, useState } from "react"; +import styles from "./Dropdown.module.css"; interface DropdownItem { label: string; @@ -22,26 +22,27 @@ export const Dropdown: React.FC = ({ trigger, items }) => { useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { setIsOpen(false); } }; if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); } return () => { - document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen]); return (
-
setIsOpen(!isOpen)}> - {trigger} -
- +
setIsOpen(!isOpen)}>{trigger}
+ {isOpen && (
{items.map((item, index) => ( @@ -52,17 +53,31 @@ export const Dropdown: React.FC = ({ trigger, items }) => { (() => { const cp = item.containerProps || {}; const { className: cpClassName, ...cpRest } = cp as any; - const headerClasses = [styles.dropdownHeader, cpClassName].filter(Boolean).join(' '); + const headerClasses = [styles.dropdownHeader, cpClassName] + .filter(Boolean) + .join(" "); return (
- {item.icon && {item.icon}} + {item.icon && ( + {item.icon} + )} { // merge className for the header label (() => { const lp = item.labelProps || {}; - const { className: lpClassName, ...lpOther } = lp as any; - const classes = [styles.dropdownHeaderLabel, lpClassName].filter(Boolean).join(' '); - return {item.label}; + const { className: lpClassName, ...lpOther } = + lp as any; + const classes = [ + styles.dropdownHeaderLabel, + lpClassName, + ] + .filter(Boolean) + .join(" "); + return ( + + {item.label} + + ); })() }
@@ -72,7 +87,9 @@ export const Dropdown: React.FC = ({ trigger, items }) => { (() => { const cp = item.containerProps || {}; const { className: cpClassName, ...cpRest } = cp as any; - const btnClasses = [styles.dropdownItem, cpClassName].filter(Boolean).join(' '); + const btnClasses = [styles.dropdownItem, cpClassName] + .filter(Boolean) + .join(" "); return ( ); })() diff --git a/packages/broadcast-panel/src/components/ExampleModal.tsx b/packages/broadcast-panel/src/components/ExampleModal.tsx index 6132cc8..51b870f 100644 --- a/packages/broadcast-panel/src/components/ExampleModal.tsx +++ b/packages/broadcast-panel/src/components/ExampleModal.tsx @@ -1,9 +1,15 @@ -import React, { useState } from 'react' -import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components' +import React, { useState } from "react"; +import { + Modal, + ModalLink, + ModalCopyInput, + ModalShareButtons, + ModalToggle, +} from "@shared/components"; interface Props { - open: boolean - onClose: () => void + open: boolean; + onClose: () => void; } /** @@ -11,7 +17,7 @@ interface Props { * Demuestra cómo reutilizar las partes creadas */ const ExampleModal: React.FC = ({ open, onClose }) => { - const [enabled, setEnabled] = useState(true) + const [enabled, setEnabled] = useState(true); return ( = ({ open, onClose }) => { width="md" footer={ <> - + {/* Segmented theme control: Sistema / Claro / Oscuro */}
- - - - + @@ -127,7 +148,7 @@ const Header: React.FC = () => { />
- ) -} + ); +}; -export default Header +export default Header; diff --git a/packages/broadcast-panel/src/components/InviteGuestsModal.module.css b/packages/broadcast-panel/src/components/InviteGuestsModal.module.css index c98077c..724c049 100644 --- a/packages/broadcast-panel/src/components/InviteGuestsModal.module.css +++ b/packages/broadcast-panel/src/components/InviteGuestsModal.module.css @@ -57,7 +57,6 @@ border-top-color: #5f6368; } - /* Dark mode */ [data-theme="dark"] .helpText, [data-theme="dark"] .planText { @@ -67,4 +66,3 @@ [data-theme="dark"] .toggle { border-top-color: #5f6368; } - diff --git a/packages/broadcast-panel/src/components/InviteGuestsModal.tsx b/packages/broadcast-panel/src/components/InviteGuestsModal.tsx index ffa7f7b..c05e0c6 100644 --- a/packages/broadcast-panel/src/components/InviteGuestsModal.tsx +++ b/packages/broadcast-panel/src/components/InviteGuestsModal.tsx @@ -1,27 +1,36 @@ -import React, { useState } from 'react' -import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components' -import styles from './InviteGuestsModal.module.css' +import React, { useState } from "react"; +import { + Modal, + ModalLink, + ModalCopyInput, + ModalShareButtons, + ModalToggle, +} from "@shared/components"; +import styles from "./InviteGuestsModal.module.css"; interface Props { - open: boolean - onClose: () => void - link: string + open: boolean; + onClose: () => void; + link: string; } const InviteGuestsModal: React.FC = ({ open, onClose, link }) => { - const [guestPermissions, setGuestPermissions] = useState(true) + const [guestPermissions, setGuestPermissions] = useState(true); const handleGmailShare = () => { - window.open(`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`, '_blank') - } + window.open( + `https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`, + "_blank", + ); + }; const handleEmailShare = () => { - window.location.href = `mailto:?body=${encodeURIComponent(link)}` - } + window.location.href = `mailto:?body=${encodeURIComponent(link)}`; + }; const handleMessengerShare = () => { - console.log('Compartir por Messenger:', link) - } + console.log("Compartir por Messenger:", link); + }; return ( = ({ open, onClose, link }) => { >

- Envía este enlace a tus invitados. Es posible que también quieras compartir nuestras {' '} + Envía este enlace a tus invitados. Es posible que también quieras + compartir nuestras{" "} instrucciones para invitados - . + + .

- Puedes tener hasta 6 personas en pantalla a la vez. {' '} - Mejora tu plan {' '} - si necesitas más. + Puedes tener hasta 6 personas en pantalla a la vez.{" "} + Mejora tu plan si necesitas + más.

- = ({ open, onClose, link }) => { />
- ) -} + ); +}; -export default InviteGuestsModal +export default InviteGuestsModal; diff --git a/packages/broadcast-panel/src/components/NewTransmissionModal.module.css b/packages/broadcast-panel/src/components/NewTransmissionModal.module.css index dc90a1a..2b30154 100644 --- a/packages/broadcast-panel/src/components/NewTransmissionModal.module.css +++ b/packages/broadcast-panel/src/components/NewTransmissionModal.module.css @@ -65,7 +65,7 @@ } .selectedDestination::before { - content: ''; + content: ""; position: absolute; top: 0; left: 50%; diff --git a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx index 202697e..5930bb0 100644 --- a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx +++ b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect } from 'react' -import { - Modal, - ModalRadioGroup, - ModalSection, +import React, { useState, useEffect } from "react"; +import { + Modal, + ModalRadioGroup, + ModalSection, ModalDestinationButton, ModalInput, ModalTextarea, @@ -12,504 +12,706 @@ import { ModalDateTimeGroup, ModalButton, ModalButtonGroup, - ModalPlatformCard -} from '@shared/components' -import { MdVideocam, MdVideoLibrary, MdAdd, MdImage, MdAutoAwesome, MdArrowBack } from 'react-icons/md' -import { FaYoutube, FaFacebook, FaLinkedin, FaTwitch, FaInstagram, FaKickstarterK } from 'react-icons/fa' -import { FaXTwitter } from 'react-icons/fa6' -import { BsInfoCircle } from 'react-icons/bs' -import styles from './NewTransmissionModal.module.css' -import type { Transmission } from '@shared/types' + ModalPlatformCard, +} from "@shared/components"; +import { + MdVideocam, + MdVideoLibrary, + MdAdd, + MdImage, + MdAutoAwesome, + MdArrowBack, +} from "react-icons/md"; +import { + FaYoutube, + FaFacebook, + FaLinkedin, + FaTwitch, + FaInstagram, + FaKickstarterK, +} from "react-icons/fa"; +import { FaXTwitter } from "react-icons/fa6"; +import { BsInfoCircle } from "react-icons/bs"; +import styles from "./NewTransmissionModal.module.css"; +import type { Transmission } from "@shared/types"; interface Props { - open: boolean - onClose: () => void - onCreate: (t: Transmission) => void - onUpdate?: (t: Transmission) => void - transmission?: Transmission // Transmisión a editar + open: boolean; + onClose: () => void; + onCreate: (t: Transmission) => void; + onUpdate?: (t: Transmission) => void; + transmission?: Transmission; // Transmisión a editar // If provided, modal can be used only to add a destination. When true, selecting a platform // will call onAddDestination with the created DestinationData and close the modal. - onlyAddDestination?: boolean - onAddDestination?: (d: DestinationData) => void + onlyAddDestination?: boolean; + onAddDestination?: (d: DestinationData) => void; } interface DestinationData { - id: string - platform: string - icon: React.ReactNode - badge?: React.ReactNode + id: string; + platform: string; + icon: React.ReactNode; + badge?: React.ReactNode; } const NewTransmissionModal: React.FC = (props) => { - const { open, onClose, onCreate, onUpdate, transmission, onlyAddDestination, onAddDestination } = props - const [view, setView] = useState<'main' | 'add-destination'>('main') - const [source, setSource] = useState('studio') - + const { + open, + onClose, + onCreate, + onUpdate, + transmission, + onlyAddDestination, + onAddDestination, + } = props; + const [view, setView] = useState<"main" | "add-destination">("main"); + const [source, setSource] = useState("studio"); + // Modo edición: si hay transmission, inicializar con sus datos - const isEditMode = !!transmission - + const isEditMode = !!transmission; + const [destinations, setDestinations] = useState([ { - id: 'yt_1', - platform: 'YouTube', + id: "yt_1", + platform: "YouTube", icon: , - badge: - } - ]) - const [selectedDestination, setSelectedDestination] = useState(null) - + badge: , + }, + ]); + const [selectedDestination, setSelectedDestination] = useState( + null, + ); + const [isCreating, setIsCreating] = useState(false); + // Blank stream - const [blankTitle, setBlankTitle] = useState('') - + const [blankTitle, setBlankTitle] = useState(""); + // Form fields - const [title, setTitle] = useState('') - const [description, setDescription] = useState('') - const [privacy, setPrivacy] = useState('Pública') - const [category, setCategory] = useState('') - const [addReferral, setAddReferral] = useState(true) - const [scheduleForLater, setScheduleForLater] = useState(false) - + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [privacy, setPrivacy] = useState("Pública"); + const [category, setCategory] = useState(""); + const [addReferral, setAddReferral] = useState(true); + const [scheduleForLater, setScheduleForLater] = useState(false); + // Scheduled date/time - const [scheduledDate, setScheduledDate] = useState('') - const [scheduledHour, setScheduledHour] = useState('01') - const [scheduledMinute, setScheduledMinute] = useState('10') + const [scheduledDate, setScheduledDate] = useState(""); + const [scheduledHour, setScheduledHour] = useState("01"); + const [scheduledMinute, setScheduledMinute] = useState("10"); // Inicializar campos en modo edición useEffect(() => { if (transmission && open) { - setTitle(transmission.title) - + setTitle(transmission.title); + // Si es genérico, activar modo blank - if (transmission.platform === 'Genérico') { - setSelectedDestination('blank') - setBlankTitle(transmission.title) + if (transmission.platform === "Genérico") { + setSelectedDestination("blank"); + setBlankTitle(transmission.title); } else { // Si tiene una plataforma específica, crear el destino y seleccionarlo // Por ahora, simplemente no seleccionamos nada hasta que se agregue el destino - setSelectedDestination(null) + setSelectedDestination(null); } } else if (!open) { // Reset cuando se cierra el modal - resetForm() + resetForm(); } - }, [transmission, open]) + }, [transmission, open]); const resetForm = () => { - setView('main') - setSource('studio') - setSelectedDestination(null) - setTitle('') - setDescription('') - setPrivacy('Pública') - setCategory('') - setAddReferral(true) - setScheduleForLater(false) - setScheduledDate('') - setScheduledHour('01') - setScheduledMinute('10') - setBlankTitle('') - } + setView("main"); + setSource("studio"); + setSelectedDestination(null); + setTitle(""); + setDescription(""); + setPrivacy("Pública"); + setCategory(""); + setAddReferral(true); + setScheduleForLater(false); + setScheduledDate(""); + setScheduledHour("01"); + setScheduledMinute("10"); + setBlankTitle(""); + }; - const generateId = () => `t_${Date.now()}_${Math.floor(Math.random()*1000)}` + const generateId = () => + `t_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + + // publicId generator: base62 with length 20 + const generatePublicId = (len = 20) => { + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let out = ""; + for (let i = 0; i < len; i++) + out += chars.charAt(Math.floor(Math.random() * chars.length)); + return out; + }; const handleAddDestination = () => { - setView('add-destination') - } + setView("add-destination"); + }; const handleBackToMain = () => { - setView('main') - } + setView("main"); + }; const handleSkipForNow = () => { - setSelectedDestination('blank') - } + setSelectedDestination("blank"); + }; const handlePlatformSelect = (platform: string) => { - const platformData: Record = { - 'YouTube': { icon: , color: '#FF0000' }, - 'Facebook': { icon: , color: '#1877F2' }, - 'LinkedIn': { icon: , color: '#0A66C2' }, - 'X (Twitter)': { icon: , color: '#000000' }, - 'Twitch': { icon: , color: '#9146FF' }, - 'Instagram Live': { icon: , color: '#E4405F' }, - 'Kick': { icon: , color: '#53FC18' }, - 'Brightcove': { icon:
B
, color: '#000000' } - } + const platformData: Record< + string, + { icon: React.ReactNode; color: string } + > = { + YouTube: { icon: , color: "#FF0000" }, + Facebook: { icon: , color: "#1877F2" }, + LinkedIn: { icon: , color: "#0A66C2" }, + "X (Twitter)": { icon: , color: "#000000" }, + Twitch: { icon: , color: "#9146FF" }, + "Instagram Live": { + icon: , + color: "#E4405F", + }, + Kick: { icon: , color: "#53FC18" }, + Brightcove: { + icon:
B
, + color: "#000000", + }, + }; const newDest: DestinationData = { id: `dest_${Date.now()}`, platform, icon: platformData[platform]?.icon || , - badge: platform === 'YouTube' ? : undefined - } + badge: + platform === "YouTube" ? ( + + ) : undefined, + }; // If this modal is used only to add a destination, call the callback and close if ((props as any).onlyAddDestination && (props as any).onAddDestination) { - ;(props as any).onAddDestination(newDest) - onClose() - return + (props as any).onAddDestination(newDest); + onClose(); + return; } - setDestinations([...destinations, newDest]) - setView('main') - } + setDestinations([...destinations, newDest]); + setView("main"); + }; const handleDestinationClick = (destId: string) => { // Si el destino ya está seleccionado, deseleccionarlo if (selectedDestination === destId) { - setSelectedDestination(null) + setSelectedDestination(null); } else { - setSelectedDestination(destId) + setSelectedDestination(destId); } - } + }; const handleCreate = async () => { if (!selectedDestination) { - alert('Por favor selecciona un destino de transmisión') - return + alert("Por favor selecciona un destino de transmisión"); + return; } - + // If blank transmission (generic) - if (selectedDestination === 'blank') { + if (selectedDestination === "blank") { const blankTransmission: Transmission = { id: isEditMode && transmission ? transmission.id : generateId(), - title: blankTitle || 'Transmisión en vivo', - platform: 'Genérico', - scheduled: 'Próximamente', - createdAt: isEditMode && transmission?.createdAt ? transmission.createdAt : new Date().toLocaleDateString('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }) - } - + title: blankTitle || "Transmisión en vivo", + platform: "Genérico", + scheduled: "Próximamente", + createdAt: + isEditMode && transmission?.createdAt + ? transmission.createdAt + : new Date().toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }), + }; + // If editing, call onUpdate, else onCreate if (isEditMode && onUpdate) { - onUpdate(blankTransmission) + onUpdate(blankTransmission); } else { - onCreate(blankTransmission) + onCreate(blankTransmission); } - - resetForm() - onClose() - return + + resetForm(); + onClose(); + return; } - + // Transmission with a specific destination const t: Transmission = { - id: isEditMode && transmission ? transmission.id : generateId(), - title: title || 'Nueva transmisión', - platform: destinations.find(d => d.id === selectedDestination)?.platform || 'YouTube', - scheduled: '', - createdAt: isEditMode && transmission?.createdAt ? transmission.createdAt : new Date().toLocaleDateString('es-ES', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }) - } + id: isEditMode && transmission ? transmission.id : generateId(), + title: title || "Nueva transmisión", + platform: + destinations.find((d) => d.id === selectedDestination)?.platform || + "YouTube", + scheduled: "", + createdAt: + isEditMode && transmission?.createdAt + ? transmission.createdAt + : new Date().toLocaleDateString("es-ES", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }), + }; // Try to create a session for this broadcast on the backend and attach sessionId to transmission try { - const BACKEND_ABS = (import.meta.env.VITE_BACKEND_API_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || '' - const sessionUrl = BACKEND_ABS ? `${BACKEND_ABS.replace(/\/$/, '')}/api/broadcasts/${encodeURIComponent(t.id)}/session` : `/api/broadcasts/${encodeURIComponent(t.id)}/session` - // Call backend to create session associated with this broadcast. - const resp = await fetch(sessionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: (localStorage.getItem('avanzacast_user') || 'Guest') }) }) + const BACKEND_ABS = + (import.meta.env.VITE_BACKEND_API_URL as string) || + (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || + ""; + const sessionUrl = BACKEND_ABS + ? `${BACKEND_ABS.replace(/\/$/, "")}/api/broadcasts/${encodeURIComponent(t.id)}/session` + : `/api/broadcasts/${encodeURIComponent(t.id)}/session`; + // Call backend to create session associated with this broadcast. Include creator info so backend issues token scoped to creator. + const creator = localStorage.getItem("avanzacast_user") || "Guest"; + setIsCreating(true); + const resp = await fetch(sessionUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: creator, creator }), + }); if (resp.ok) { try { - const json = await resp.json() + const json = await resp.json(); // attach session id returned by backend to transmission object (optional field) - ;(t as any).sessionId = json.id || undefined + (t as any).sessionId = json.id || undefined; + (t as any).creator = creator; + (t as any).token = json.token || json.participantToken || null; + + // Choose a publicId for nicer studio/:id routes. If backend id is short (<10), generate a longer one. + const backendId = json.id || ""; + const publicId = + backendId && backendId.length >= 10 + ? backendId + : generatePublicId(22); + + // Persist mapping publicId -> sessionData so SessionLoader can pick it up by publicId + try { + const mapKey = + (import.meta.env.VITE_STUDIO_SESSION_MAP_KEY as string) || + "avanzacast_studio_session_map"; + const raw = sessionStorage.getItem(mapKey); + const map = raw ? JSON.parse(raw) : {}; + // store full session info plus backendId for reference + map[publicId] = { ...(json as any), backendId }; + sessionStorage.setItem(mapKey, JSON.stringify(map)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { + detail: { publicId, session: map[publicId] }, + }), + ); + } catch (e) { + /* ignore */ + } + } catch (e) { + console.warn( + "[NewTransmissionModal] failed to persist session map in sessionStorage", + e, + ); + } + + // Lightweight validation: if token present we consider validated; else attempt connection-details from backend + let validated = Boolean( + (json as any).token || (json as any).participantToken, + ); + if (!validated) { + try { + const TOKEN_SERVER = + (import.meta.env.VITE_BACKEND_API_URL as string) || + (import.meta.env.VITE_TOKEN_SERVER_URL as string) || + "https://avanzacast-backend.bfzqqk.easypanel.host"; + const cdUrl = `${TOKEN_SERVER.replace(/\/$/, "")}/api/connection-details?roomName=${encodeURIComponent(t.id)}&participantName=${encodeURIComponent(creator)}`; + const cdResp = await fetch(cdUrl); + if (cdResp.ok) { + const cdJson = await cdResp.json().catch(() => null); + if (cdJson && (cdJson.participantToken || cdJson.serverUrl)) { + // merge tokens into stored map + try { + const mapKey = + (import.meta.env.VITE_STUDIO_SESSION_MAP_KEY as string) || + "avanzacast_studio_session_map"; + const raw = sessionStorage.getItem(mapKey); + const map = raw ? JSON.parse(raw) : {}; + map[publicId] = { + ...(map[publicId] || {}), + token: cdJson.participantToken || null, + url: cdJson.serverUrl || cdJson.serverUrl, + }; + sessionStorage.setItem(mapKey, JSON.stringify(map)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { + detail: { publicId, session: map[publicId] }, + }), + ); + } catch (e) { + /* ignore */ + } + } catch (e) { + /* ignore */ + } + validated = true; + } + } + } catch (e) { + /* ignore validation errors */ + } + } + + // If validated or even if not (we still redirect), call onCreate and redirect to public route + try { + onCreate(t); + } catch (e) { + /* ignore */ + } + const BROADCAST_BASE = + (import.meta.env.VITE_BROADCASTPANEL_URL as string) || + "https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"; + const target = + (json as any).studioUrl || + `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${encodeURIComponent(publicId)}`; + try { + window.location.href = target; + } catch (e) { + try { + window.location.assign(target); + } catch (e2) { + /* ignore */ + } + } + return; } catch (e) { // ignore JSON parse } } else { // if session creation failed, continue; the studio flow will create one on demand - console.warn('[NewTransmissionModal] failed to create session for broadcast:', resp.status) + console.warn( + "[NewTransmissionModal] failed to create session for broadcast:", + resp.status, + ); } } catch (e) { - console.warn('[NewTransmissionModal] error creating session for broadcast', e) + console.warn( + "[NewTransmissionModal] error creating session for broadcast", + e, + ); + } finally { + setIsCreating(false); } // If editing, call onUpdate, else onCreate if (isEditMode && onUpdate) { - onUpdate(t) + onUpdate(t); } else { - onCreate(t) + onCreate(t); } - - resetForm() - onClose() - } - const modalTitle = view === 'add-destination' ? 'Agregar destino' : (isEditMode ? 'Editar transmisión' : 'Crear transmisión en vivo') - const showBackButton = view === 'add-destination' + resetForm(); + onClose(); + }; + + const modalTitle = + view === "add-destination" + ? "Agregar destino" + : isEditMode + ? "Editar transmisión" + : "Crear transmisión en vivo"; + const showBackButton = view === "add-destination"; return ( -
- +
+ {showBackButton && ( )} - {view === 'main' && ( - <> -
- - } - name="source" - value={source} - onChange={setSource} - options={[ - { value: 'studio', label: 'Estudio', icon: }, - { value: 'prerecorded', label: 'Video pregrabado', icon: } - ]} - /> - - - -
- {destinations.map((dest) => ( -
- handleDestinationClick(dest.id)} - title={dest.platform} - /> -
- ))} - - } - label="" - onClick={handleAddDestination} - title="Agregar destino" + {view === "main" && ( + <> +
+ + } + name="source" + value={source} + onChange={setSource} + options={[ + { value: "studio", label: "Estudio", icon: }, + { + value: "prerecorded", + label: "Video pregrabado", + icon: , + }, + ]} /> - {!selectedDestination && ( -
- - Omitir por ahora - + + + +
+ {destinations.map((dest) => ( +
+ handleDestinationClick(dest.id)} + title={dest.platform} + /> +
+ ))} + + } + label="" + onClick={handleAddDestination} + title="Agregar destino" + /> + {!selectedDestination && ( +
+ + Omitir por ahora + +
+ )} +
+
+ + {selectedDestination && selectedDestination !== "blank" && ( + <> + + + + + + + + + + } + subtext="Gana $25 en crédito por cada referencia exitosa." + /> + + + + + + + + + + + + + + + {scheduleForLater && ( + <> + + } + dateValue={scheduledDate} + hourValue={scheduledHour} + minuteValue={scheduledMinute} + onDateChange={setScheduledDate} + onHourChange={setScheduledHour} + onMinuteChange={setScheduledMinute} + timezone="GMT-7" + /> + + + + + }> + Subir imagen en miniatura + + } + > + Crear con IA + + + + + )} + + )} + + {selectedDestination === "blank" && ( +
+

+ Empezarás una transmisión en el estudio sin configurar + ningún destino. Podrás agregar destinos más tarde desde el + estudio. +

+ +
)} -
- +
- - - {selectedDestination && selectedDestination !== 'blank' && ( - <> - - - - - - - - - - } - subtext="Gana $25 en crédito por cada referencia exitosa." - /> - - - - - - - - - - - - - - - {scheduleForLater && ( - <> - - } - dateValue={scheduledDate} - hourValue={scheduledHour} - minuteValue={scheduledMinute} - onDateChange={setScheduledDate} - onHourChange={setScheduledHour} - onMinuteChange={setScheduledMinute} - timezone="GMT-7" - /> - - - - - } - > - Subir imagen en miniatura - - } - > - Crear con IA - - - - - )} - - )} - - {selectedDestination === 'blank' && ( -
-

- Empezarás una transmisión en el estudio sin configurar ningún destino. - Podrás agregar destinos más tarde desde el estudio. +

+ {selectedDestination && selectedDestination !== "blank" && ( +

+ Esta transmisión no se grabará en StreamYard. Para grabar, + tendrás que{" "} + + pasarte a un plan superior. +

- - -
- )} -
+ )} + +
+ + )} -
- {selectedDestination && selectedDestination !== 'blank' && ( -

- Esta transmisión no se grabará en StreamYard. Para grabar, tendrás que{' '} - pasarte a un plan superior. -

- )} - + label="Brightcove" + onClick={() => handlePlatformSelect("Brightcove")} + /> + RTMP
+ } + label="Otras plataformas" + onClick={() => handlePlatformSelect("RTMP")} + badge="pro" + />
- - )} - - {view === 'add-destination' && ( -
- } - label="YouTube" - onClick={() => handlePlatformSelect('YouTube')} - /> - } - label="Facebook" - onClick={() => handlePlatformSelect('Facebook')} - /> - } - label="LinkedIn" - onClick={() => handlePlatformSelect('LinkedIn')} - /> - } - label="X (Twitter)" - onClick={() => handlePlatformSelect('X (Twitter)')} - /> - } - label="Twitch" - onClick={() => handlePlatformSelect('Twitch')} - /> - } - label="Instagram Live" - onClick={() => handlePlatformSelect('Instagram Live')} - /> - } - label="Kick" - onClick={() => handlePlatformSelect('Kick')} - badge="pro" - /> - B
} - label="Brightcove" - onClick={() => handlePlatformSelect('Brightcove')} - /> - RTMP
} - label="Otras plataformas" - onClick={() => handlePlatformSelect('RTMP')} - badge="pro" - /> -
- )} -
+ )} +
- ) -} + ); +}; -export default NewTransmissionModal +export default NewTransmissionModal; diff --git a/packages/broadcast-panel/src/components/PageContainer.module.css b/packages/broadcast-panel/src/components/PageContainer.module.css index 941e68b..665991f 100644 --- a/packages/broadcast-panel/src/components/PageContainer.module.css +++ b/packages/broadcast-panel/src/components/PageContainer.module.css @@ -53,14 +53,17 @@ align-items: center; gap: 12px; cursor: pointer; - transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease; + transition: + box-shadow 0.18s ease, + transform 0.12s ease, + border-color 0.12s ease; font-size: 15px; font-weight: 600; position: relative; /* for plus overlay */ } .createCard:hover { - box-shadow: 0 6px 18px rgba(16,24,40,0.06); + box-shadow: 0 6px 18px rgba(16, 24, 40, 0.06); transform: translateY(-3px); border-color: var(--primary-blue); } @@ -81,7 +84,9 @@ transform: translateY(-50%) translateX(8px) scale(0.85); opacity: 0; pointer-events: none; - transition: transform 0.18s ease, opacity 0.18s ease; + transition: + transform 0.18s ease, + opacity 0.18s ease; display: flex; align-items: center; } @@ -103,16 +108,34 @@ /* color variants for left icon box and plus badge */ .cardBlue .createIconBox, -.cardBlue .plusBadge { background: rgba(26,115,232,0.12); } -.cardBlue .createIconBox svg { color: var(--primary-blue); fill: var(--primary-blue); stroke: var(--primary-blue); } +.cardBlue .plusBadge { + background: rgba(26, 115, 232, 0.12); +} +.cardBlue .createIconBox svg { + color: var(--primary-blue); + fill: var(--primary-blue); + stroke: var(--primary-blue); +} .cardRed .createIconBox, -.cardRed .plusBadge { background: rgba(234,67,53,0.12); } -.cardRed .createIconBox svg { color: #ea4335; fill: #ea4335; stroke: #ea4335; } +.cardRed .plusBadge { + background: rgba(234, 67, 53, 0.12); +} +.cardRed .createIconBox svg { + color: #ea4335; + fill: #ea4335; + stroke: #ea4335; +} .cardGreen .createIconBox, -.cardGreen .plusBadge { background: rgba(52,168,83,0.12); } -.cardGreen .createIconBox svg { color: #34a853; fill: #34a853; stroke: #34a853; } +.cardGreen .plusBadge { + background: rgba(52, 168, 83, 0.12); +} +.cardGreen .createIconBox svg { + color: #34a853; + fill: #34a853; + stroke: #34a853; +} /* ensure icons are solid and not dimmed */ .createIconBox svg { diff --git a/packages/broadcast-panel/src/components/PageContainer.tsx b/packages/broadcast-panel/src/components/PageContainer.tsx index dae17d5..c49fbe1 100644 --- a/packages/broadcast-panel/src/components/PageContainer.tsx +++ b/packages/broadcast-panel/src/components/PageContainer.tsx @@ -1,69 +1,88 @@ -import React, { useEffect, useState } from 'react' -import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md' -import PlusLarge from './icons/PlusLarge' -import { ThemeProvider } from './ThemeProvider' -import { Skeleton, SkeletonCard } from './Skeleton' -import styles from './PageContainer.module.css' -import Sidebar from './Sidebar' -import Header from './Header' -import TransmissionsTable from './TransmissionsTable' -import { NewTransmissionModal } from '@shared/components' -import Studio from './Studio' -import StudioConnector from './StudioConnector' -import type { Transmission } from '@shared/types' +import React, { useEffect, useState } from "react"; +import { MdVideocam, MdFiberManualRecord, MdSchool } from "react-icons/md"; +import PlusLarge from "./icons/PlusLarge"; +import { ThemeProvider } from "./ThemeProvider"; +import { SkeletonCard } from "./Skeleton"; +import styles from "./PageContainer.module.css"; +import Sidebar from "./Sidebar"; +import Header from "./Header"; +import TransmissionsTable from "./TransmissionsTable"; +import { NewTransmissionModal } from "@shared/components"; +import Studio from "./Studio"; +import type { Transmission } from "@shared/types"; -const STORAGE_KEY = 'broadcast_transmissions' +const STORAGE_KEY = "broadcast_transmissions"; const PageContainer: React.FC = () => { - const [transmissions, setTransmissions] = useState([]) - const [isModalOpen, setIsModalOpen] = useState(false) - const [isLoading, setIsLoading] = useState(true) - const [currentPage, setCurrentPage] = useState('inicio') + const [transmissions, setTransmissions] = useState([]); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(true); + // Determine initial page: default 'inicio' but allow opening the Studio when feature enabled + const initialPage = (() => { + try { + // feature via Vite env at build time + const envFlag = + (import.meta.env.VITE_FEATURE_STUDIOPORTAL as string) === "1"; + if (envFlag) return "studio"; + // runtime toggle via localStorage (useful during development) + if ( + typeof window !== "undefined" && + window.localStorage.getItem("feature_studioportal") === "1" + ) + return "studio"; + } catch (e) { + // ignore + } + return "inicio"; + })(); + const [currentPage, setCurrentPage] = useState(initialPage); useEffect(() => { // Simular carga de datos setTimeout(() => { try { - const raw = localStorage.getItem(STORAGE_KEY) - if (raw) setTransmissions(JSON.parse(raw)) + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) setTransmissions(JSON.parse(raw)); } catch (e) { - console.error('Failed to load transmissions', e) + console.error("Failed to load transmissions", e); } - setIsLoading(false) - }, 800) - }, []) + setIsLoading(false); + }, 800); + }, []); useEffect(() => { if (!isLoading) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions)) + localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions)); } catch (e) { - console.error('Failed to save transmissions', e) + console.error("Failed to save transmissions", e); } } - }, [transmissions, isLoading]) + }, [transmissions, isLoading]); const handleCreate = (t: Transmission) => { - setTransmissions(prev => [t, ...prev]) - setIsModalOpen(false) - } + setTransmissions((prev) => [t, ...prev]); + setIsModalOpen(false); + }; const handleDelete = (id: string) => { - setTransmissions(prev => prev.filter(p => p.id !== id)) - } + setTransmissions((prev) => prev.filter((p) => p.id !== id)); + }; const handleUpdate = (updated: Transmission) => { - setTransmissions(prev => prev.map(p => p.id === updated.id ? updated : p)) - } + setTransmissions((prev) => + prev.map((p) => (p.id === updated.id ? updated : p)), + ); + }; const handleNavigate = (page: string) => { - setCurrentPage(page) - } + setCurrentPage(page); + }; // Renderizar página según navegación - if (currentPage === 'studio') { + if (currentPage === "studio") { // Dev: render StudioConnector for quick testing of the session flow - return + return ; } return ( @@ -76,8 +95,16 @@ const PageContainer: React.FC = () => {
{/* Sección Crear */} -
-

Crear

+
+

+ Crear +

{isLoading ? (
@@ -91,41 +118,63 @@ const PageContainer: React.FC = () => { className={`${styles.createCard} ${styles.cardBlue}`} >
-
+
Transmisión en vivo
-
+
- -
- ) -} + ); +}; -export default Studio +export default Studio; diff --git a/packages/broadcast-panel/src/components/StudioConnector.tsx b/packages/broadcast-panel/src/components/StudioConnector.tsx index 2702458..49c4cd4 100644 --- a/packages/broadcast-panel/src/components/StudioConnector.tsx +++ b/packages/broadcast-panel/src/components/StudioConnector.tsx @@ -1,162 +1,550 @@ -import React, { useEffect, useRef, useState } from 'react' -import { useStudioSession } from '../hooks/useStudioSession' -import { connect, createLocalTracks, Room, LocalTrack } from 'livekit-client' +import React, { useEffect, useRef, useState } from "react"; +import { + useStudioSession, + useStudioLauncher, + useStudioMessageListener, +} from "../hooks"; +import { + createLocalTracks, + Room, + ExternalE2EEKeyProvider, + RoomOptions, + RoomConnectOptions, + VideoPresets, + DeviceUnsupportedError, + RoomEvent, + LocalTrack, +} from "livekit-client"; +import Spinner from "./icons/Spinner"; -export const StudioConnector: React.FC = () => { - const { state, session, error, connect, disconnect } = useStudioSession() +type Props = { + defaultRoom?: string; + defaultUsername?: string; +}; - const [room, setRoom] = useState(null) - const [localTracks, setLocalTracks] = useState(null) - const videoRef = useRef(null) - const [connectingError, setConnectingError] = useState(null) +const StudioConnector: React.FC = ({ defaultRoom, defaultUsername }) => { + const { data: sessionFromApi, error } = useStudioSession(); + const { openStudio } = useStudioLauncher(); + + const [room, setRoom] = useState(null); + const [localTracks, setLocalTracks] = useState(null); + const videoRef = useRef(null); + const [connectingError, setConnectingError] = useState(null); + + const [session, setSession] = useState(null); + const [isOpening, setIsOpening] = useState(false); + const [serverInfo, setServerInfo] = useState<{ + serverUrl?: string; + sessionId?: string; + }>({}); // Determine LiveKit server URL (from Vite env or fallback) - const LIVEKIT_URL = (import.meta.env.VITE_LIVEKIT_URL as string) || (window as any).VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host' - - useEffect(() => { - // When the hook reports connected state and we have a token, establish the livekit Room - const doConnect = async () => { - setConnectingError(null) - if (!session?.token) return - // If we already have a room, skip - if (room) return + const LIVEKIT_URL = + (import.meta.env.VITE_LIVEKIT_URL as string) || + (window as any).VITE_LIVEKIT_URL || + "wss://livekit-server.bfzqqk.easypanel.host"; + // Listen for postMessage tokens (popup flow) + useStudioMessageListener((msg) => { + if (msg.type === "LIVEKIT_TOKEN") { + const s = { token: msg.token, room: msg.room, url: msg.url }; + setSession(s); try { - // Request local media permissions and create tracks - const tracks = await createLocalTracks({ audio: true, video: true }) - setLocalTracks(tracks) - - // Connect to LiveKit room using token (session.token) and LIVEKIT_URL - const r = await connect(LIVEKIT_URL, session.token, { reconnect: true }) - - // Publish local tracks - for (const t of tracks) { - try { - await r.localParticipant.publishTrack(t) - } catch (err) { - console.warn('publishTrack failed', err) - } - } - - // Attach the first video track to our preview element - const videoTrack = tracks.find((t) => t.kind === 'video') as LocalTrack | undefined - if (videoTrack && videoRef.current) { - try { - const el = videoTrack.attach() - // attach returns HTMLMediaElement, ensure it's a video element - // Replace container's children with this element - if (videoRef.current.parentElement) { - const parent = videoRef.current.parentElement - parent.replaceChild(el, videoRef.current) - videoRef.current = el as HTMLVideoElement - } - } catch (err) { - console.warn('attach track failed', err) - } - } - - // Listen for room events (optional) - r.on('disconnected', () => { - setRoom(null) - }) - r.on('reconnecting', () => { - // Could set state to reconnecting - }) - - setRoom(r) - } catch (e: any) { - console.error('LiveKit connect error', e) - setConnectingError(String(e?.message ?? e)) - } - } - - if (state === 'connected' && session?.token) { - void doConnect() - } - - return () => { - // nothing here: cleanup handled separately - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state, session?.token]) - - // Cleanup on unmount or disconnect - useEffect(() => { - return () => { - // Stop local tracks and disconnect room - try { - if (localTracks) { - for (const t of localTracks) { - try { t.stop(); t.detach(); } catch (e) { } - } - } - if (room) { - try { room.disconnect(); } catch (e) { } - } + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem(storeKey, JSON.stringify(s)); } catch (e) {} } - }, [localTracks, room]) + }); - const onCreateAndEnter = async () => { + // If session is cached in sessionStorage (integrated mode), read it on mount + useEffect(() => { try { - await connect({ createIfMissing: true, createPayload: { title: 'E2E Transmisión' } }) - } catch (e) { - console.error('connect failed', e) + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + const raw = sessionStorage.getItem(storeKey); + if (raw) { + try { + setSession(JSON.parse(raw)); + } catch (e) { + /* ignore */ + } + } + } catch (e) {} + + // Also support the session map stored by NewTransmissionModal + try { + const mapKey = + (import.meta.env.VITE_STUDIO_SESSION_MAP_KEY as string) || + "avanzacast_studio_session_map"; + const rawMap = sessionStorage.getItem(mapKey); + if (rawMap) { + try { + const map = JSON.parse(rawMap || "{}"); + // If current location is /studio/:publicId try to pick the session automatically + if (typeof window !== "undefined") { + const parts = window.location.pathname.split("/").filter(Boolean); + const idx = parts.indexOf("studio"); + if (idx !== -1 && parts.length > idx + 1) { + const publicId = parts[idx + 1]; + if (publicId && map[publicId]) { + setSession(map[publicId]); + setServerInfo({ + sessionId: map[publicId].id || undefined, + serverUrl: map[publicId].url || map[publicId].serverUrl, + }); + } + } + } + } catch (e) { + /* ignore parse errors */ + } + } + } catch (e) {} + + // Listen for AVZ_STUDIO_SESSION events dispatched by NewTransmissionModal or useStudioLauncher + const onAvz = (evt: Event) => { + try { + const ce = evt as CustomEvent; + const payload = ce?.detail || {}; + // payload can be { publicId, session } or session directly + const incoming = payload.session || payload; + if (!incoming) return; + // If session contains token/url use it directly + const sAny = incoming as any; + if (sAny.token || sAny.participantToken) { + const token = sAny.token || sAny.participantToken; + const url = sAny.url || sAny.serverUrl || LIVEKIT_URL; + setSession({ + token, + room: sAny.room || defaultRoom || "studio-room", + url, + }); + setServerInfo({ serverUrl: url, sessionId: sAny.id }); + } else if (sAny.id) { + // only id present + setSession(sAny); + setServerInfo({ + sessionId: sAny.id, + serverUrl: sAny.url || sAny.serverUrl, + }); + } else if (payload.publicId && !incoming) { + // If event provided only publicId, try to read from the stored map + try { + const mapKey = + (import.meta.env.VITE_STUDIO_SESSION_MAP_KEY as string) || + "avanzacast_studio_session_map"; + const rawMap = sessionStorage.getItem(mapKey); + if (rawMap) { + const map = JSON.parse(rawMap || "{}"); + const sess = map[payload.publicId]; + if (sess) { + const sAny2 = sess as any; + if (sAny2.token || sAny2.participantToken) { + const token = sAny2.token || sAny2.participantToken; + const url = sAny2.url || sAny2.serverUrl || LIVEKIT_URL; + setSession({ + token, + room: sAny2.room || defaultRoom || "studio-room", + url, + }); + setServerInfo({ + serverUrl: url, + sessionId: sAny2.id || payload.publicId, + }); + } else { + setSession(sAny2); + setServerInfo({ + sessionId: sAny2.id || payload.publicId, + serverUrl: sAny2.url || sAny2.serverUrl, + }); + } + } + } + } catch (e) { + /* ignore */ + } + } + } catch (e) { + /* ignore malformed event */ + } + }; + + try { + window.addEventListener("AVZ_STUDIO_SESSION", onAvz as EventListener); + } catch (e) {} + return () => { + try { + window.removeEventListener( + "AVZ_STUDIO_SESSION", + onAvz as EventListener, + ); + } catch (e) {} + }; + }, []); + + // If the API hook fetched session data (useStudioSession with id), prefer that + useEffect(() => { + if (sessionFromApi) setSession(sessionFromApi); + }, [sessionFromApi]); + + // E2EE passphrase + const e2eePassphrase = + typeof window !== "undefined" + ? location.hash + ? (() => { + try { + return decodeURIComponent(location.hash.substring(1)); + } catch (e) { + return undefined; + } + })() + : undefined + : undefined; + const keyProvider = new ExternalE2EEKeyProvider(); + const e2eeWorker = + typeof window !== "undefined" && e2eePassphrase + ? new Worker(new URL("livekit-client/e2ee-worker", import.meta.url)) + : undefined; + + const roomOptions: RoomOptions = { + publishDefaults: { + videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216], + red: !e2eePassphrase, + }, + adaptiveStream: true, + dynacast: true, + singlePeerConnection: true, + e2ee: e2eePassphrase + ? { keyProvider, worker: e2eeWorker as any } + : undefined, + }; + + // create Room instance once + useEffect(() => { + const r = new Room(roomOptions); + setRoom(r); + return () => { + try { + r.disconnect(); + } catch (e) {} + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!room) return; + const doConnect = async () => { + setConnectingError(null); + if (!session?.token) return; + + try { + const tracks = await createLocalTracks({ audio: true, video: true }); + setLocalTracks(tracks); + + if (e2eePassphrase && room.options.e2ee) { + try { + await (room.options.e2ee as any).keyProvider.setKey(e2eePassphrase); + await room.setE2EEEnabled(true); + } catch (e: any) { + if (e instanceof DeviceUnsupportedError) { + console.error("E2EE not supported in this browser", e); + } else { + console.warn("E2EE setup failed", e); + } + } + } + + const serverUrl = (session as any).url || LIVEKIT_URL; + const connectOptions: RoomConnectOptions = { autoSubscribe: true }; + + await room.connect(serverUrl, session.token, connectOptions); + + for (const t of tracks) { + try { + await room.localParticipant.publishTrack(t); + } catch (err) { + console.warn("publishTrack failed", err); + } + } + + const videoTrack = tracks.find((t) => t.kind === "video"); + if (videoTrack && videoRef.current) { + try { + const el = videoTrack.attach(); + if (videoRef.current.parentElement) { + const parent = videoRef.current.parentElement; + parent.replaceChild(el, videoRef.current); + videoRef.current = el as HTMLVideoElement; + } + } catch (err) { + console.warn("attach track failed", err); + } + } + + room.on(RoomEvent.Disconnected, () => setRoom(null)); + setRoom(room); + } catch (e: any) { + console.error("LiveKit connect error", e); + setConnectingError(String(e?.message ?? e)); + } + }; + + if (session?.token) void doConnect(); + + return () => { + /* no-op */ + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session?.token, room]); + + useEffect(() => { + return () => { + try { + if (localTracks) + for (const t of localTracks) + try { + t.stop(); + t.detach(); + } catch (e) {} + if (room) + try { + room.disconnect(); + } catch (e) {} + } catch (e) {} + }; + }, [localTracks, room]); + + // Helper: fetch connection-details with timeout + async function fetchConnectionDetails( + roomName: string, + participantName: string, + timeoutMs = 3000, + ) { + const BACKEND = + (import.meta.env.VITE_BACKEND_API_URL as string) || + (import.meta.env.VITE_TOKEN_SERVER_URL as string) || + ""; + const base = BACKEND ? BACKEND.replace(/\/$/, "") : ""; + const url = base + ? `${base}/api/connection-details` + : `/api/connection-details`; + + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + const u = new URL(url, window.location.origin); + u.searchParams.set("roomName", roomName); + u.searchParams.set("participantName", participantName); + const resp = await fetch(u.toString(), { + method: "GET", + signal: controller.signal, + }); + clearTimeout(id); + if (!resp.ok) + return { + ok: false, + status: resp.status, + body: await resp.text().catch(() => ""), + }; + const json = await resp.json().catch(() => null); + return { ok: true, json }; + } catch (err: any) { + clearTimeout(id); + return { ok: false, error: String(err) }; } } + const onCreateAndEnter = async (roomName?: string, username?: string) => { + setConnectingError(null); + setIsOpening(true); + const r = roomName || defaultRoom || "studio-room"; + const u = + username || + defaultUsername || + localStorage.getItem("avanzacast_user") || + "studio-user"; + + // Try direct connection-details first + try { + const result = await fetchConnectionDetails(r, u, 4000); + if (result.ok && result.json) { + const data = result.json; + const token = data.participantToken || data.token || data.token; + const serverUrl = data.serverUrl || data.url; + const sessionId = data.sessionId || data.id; + if (token && serverUrl) { + setSession({ token, room: data.roomName || r, url: serverUrl }); + setServerInfo({ serverUrl, sessionId }); + setIsOpening(false); + return; + } + } + } catch (e) { + // ignore and fallback + } + + // Fallback: use openStudio flow which creates a session (POST /api/session) + try { + const sd = await openStudio({ room: r, username: u } as any); + if (sd) { + // sd may include token/url or only id; prefer token if present + const sdAny = sd as any; + if (sdAny.token || sdAny.participantToken) { + const t = sdAny.token || sdAny.participantToken; + const url = sdAny.url || sdAny.serverUrl || LIVEKIT_URL; + setSession({ token: t, room: sdAny.room || r, url }); + setServerInfo({ serverUrl: url, sessionId: sdAny.id }); + } else { + // stored session id will be used by SessionLoader in studio route + setSession(sd); + setServerInfo({ + sessionId: sd.id, + serverUrl: (sd as any).url || (sd as any).serverUrl, + }); + } + } + } catch (err: any) { + console.error("openStudio fallback failed", err); + setConnectingError(String(err?.message || err)); + } finally { + setIsOpening(false); + } + }; + const onEnterExisting = async () => { - if (!session?.id) { - await onCreateAndEnter() - return + if (!(session as any)?.id) { + await onCreateAndEnter(); + return; } + // If session has only id, fetch token-to-use if needed try { - await connect({ sessionId: session.id }) + if ( + session && + session.id && + !session.token && + !session.participantToken + ) { + const resp = await fetch( + `/api/session/${encodeURIComponent(session.id)}`, + ); + if (resp.ok) { + const json = await resp.json(); + if (json.token || json.url) { + setSession({ token: json.token, room: json.room, url: json.url }); + setServerInfo({ serverUrl: json.url, sessionId: session.id }); + } + } + } } catch (e) { - console.error('connect failed', e) + /* ignore */ } - } + }; const onDisconnect = async () => { try { - if (room) { - try { room.disconnect() } catch (e) { } - setRoom(null) - } - if (localTracks) { - for (const t of localTracks) { - try { t.stop(); t.detach(); } catch (e) { } - } - setLocalTracks(null) - } - await disconnect() + if (room) + try { + room.disconnect(); + } catch (e) {} + setRoom(null); + if (localTracks) + for (const t of localTracks) + try { + t.stop(); + t.detach(); + } catch (e) {} + setLocalTracks(null); + try { + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.removeItem(storeKey); + } catch (e) {} + setSession(null); + setServerInfo({}); } catch (e) { - console.warn('disconnect error', e) + console.warn("disconnect error", e); } - } + }; return (
-
Estado: {state}
- {error &&
Error: {error}
} - {connectingError &&
Conexión LiveKit: {connectingError}
} +
+ Estado: {session ? "connected" : "idle"} +
+ {error && ( +
+ Error: {error} +
+ )} + {connectingError && ( +
+ Conexión LiveKit: {connectingError} +
+ )}
- - - + + +
-

Session: {session?.id ?? 'n/a'}

-

Token: {session?.token ? `${session.token.substring(0,20)}...` : 'n/a'}

-
- {/* placeholder video element - will be replaced by attach() result when track attaches */} -
- ) -} + ); +}; -export default StudioConnector +// Nota: export named y default para compatibilidad con imports existentes +export { StudioConnector }; +export default StudioConnector; diff --git a/packages/broadcast-panel/src/components/ThemeProvider.tsx b/packages/broadcast-panel/src/components/ThemeProvider.tsx index 00db7b0..180a2fb 100644 --- a/packages/broadcast-panel/src/components/ThemeProvider.tsx +++ b/packages/broadcast-panel/src/components/ThemeProvider.tsx @@ -1,50 +1,56 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext, useEffect, useState } from "react"; -type Theme = 'light' | 'dark' | 'system'; +type Theme = "light" | "dark" | "system"; interface ThemeContextType { theme: Theme; - resolvedTheme: 'light' | 'dark'; + resolvedTheme: "light" | "dark"; setThemeMode: (t: Theme) => void; } const ThemeContext = createContext(undefined); -export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [theme, setTheme] = useState(() => { - const saved = localStorage.getItem('avanzacast-theme-mode'); - return (saved as Theme) || 'system'; + const saved = localStorage.getItem("avanzacast-theme-mode"); + return (saved as Theme) || "system"; }); - const getSystem = () => (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; + const getSystem = () => + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; const apply = (mode: Theme) => { const root = document.documentElement; - const resolved = mode === 'system' ? getSystem() : mode; - root.setAttribute('data-theme', resolved); + const resolved = mode === "system" ? getSystem() : mode; + root.setAttribute("data-theme", resolved); }; useEffect(() => { apply(theme); - localStorage.setItem('avanzacast-theme-mode', theme); + localStorage.setItem("avanzacast-theme-mode", theme); - const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const mq = window.matchMedia("(prefers-color-scheme: dark)"); const handler = () => { // only react if mode is system - if (theme === 'system') apply('system'); + if (theme === "system") apply("system"); }; - if (mq.addEventListener) mq.addEventListener('change', handler); + if (mq.addEventListener) mq.addEventListener("change", handler); else mq.addListener(handler); return () => { - if (mq.removeEventListener) mq.removeEventListener('change', handler); + if (mq.removeEventListener) mq.removeEventListener("change", handler); else mq.removeListener(handler); }; }, [theme]); const setThemeMode = (t: Theme) => setTheme(t); - const resolvedTheme = theme === 'system' ? getSystem() : theme; + const resolvedTheme = theme === "system" ? getSystem() : theme; return ( @@ -56,7 +62,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre export const useTheme = () => { const context = useContext(ThemeContext); if (context === undefined) { - throw new Error('useTheme debe usarse dentro de ThemeProvider'); + throw new Error("useTheme debe usarse dentro de ThemeProvider"); } return context; }; diff --git a/packages/broadcast-panel/src/components/Toast.tsx b/packages/broadcast-panel/src/components/Toast.tsx index df906d8..78e4844 100644 --- a/packages/broadcast-panel/src/components/Toast.tsx +++ b/packages/broadcast-panel/src/components/Toast.tsx @@ -1,20 +1,43 @@ -import React from 'react' +import React from "react"; -export type ToastVariant = 'info'|'success'|'error'|'warning' +export type ToastVariant = "info" | "success" | "error" | "warning"; -export function Toast({ message, variant = 'info' }: { message: string, variant?: ToastVariant }) { - const bg = variant === 'success' ? '#d1fae5' : variant === 'error' ? '#fee2e2' : variant === 'warning' ? '#fff7ed' : '#eef2ff' - const color = variant === 'success' ? '#065f46' : variant === 'error' ? '#991b1b' : variant === 'warning' ? '#92400e' : '#3730a3' +export function Toast({ + message, + variant = "info", +}: { + message: string; + variant?: ToastVariant; +}) { + const bg = + variant === "success" + ? "#d1fae5" + : variant === "error" + ? "#fee2e2" + : variant === "warning" + ? "#fff7ed" + : "#eef2ff"; + const color = + variant === "success" + ? "#065f46" + : variant === "error" + ? "#991b1b" + : variant === "warning" + ? "#92400e" + : "#3730a3"; return ( -
{message}
- ) +
+ {message} +
+ ); } -export default Toast - +export default Toast; diff --git a/packages/broadcast-panel/src/components/Tooltip.tsx b/packages/broadcast-panel/src/components/Tooltip.tsx index f58418d..d3b9e89 100644 --- a/packages/broadcast-panel/src/components/Tooltip.tsx +++ b/packages/broadcast-panel/src/components/Tooltip.tsx @@ -1,17 +1,21 @@ -import React, { useState } from 'react'; -import styles from './Tooltip.module.css'; +import React, { useState } from "react"; +import styles from "./Tooltip.module.css"; interface TooltipProps { content: string; children: React.ReactNode; - position?: 'top' | 'bottom' | 'left' | 'right'; + position?: "top" | "bottom" | "left" | "right"; } -export const Tooltip: React.FC = ({ content, children, position = 'bottom' }) => { +export const Tooltip: React.FC = ({ + content, + children, + position = "bottom", +}) => { const [isVisible, setIsVisible] = useState(false); return ( -
setIsVisible(true)} onMouseLeave={() => setIsVisible(false)} diff --git a/packages/broadcast-panel/src/components/TransmissionsTable.module.css b/packages/broadcast-panel/src/components/TransmissionsTable.module.css index 3a0821c..cdaf2bf 100644 --- a/packages/broadcast-panel/src/components/TransmissionsTable.module.css +++ b/packages/broadcast-panel/src/components/TransmissionsTable.module.css @@ -51,7 +51,7 @@ font-size: 13px; font-weight: 600; color: var(--text-secondary); - border-bottom: 1px solid rgba(0,0,0,0.04); + border-bottom: 1px solid rgba(0, 0, 0, 0.04); } .tableRow { @@ -130,7 +130,7 @@ border-radius: 12px; padding: 12px 16px; /* subtle elevation to match mock */ - box-shadow: 0 6px 18px rgba(2,6,23,0.04); + box-shadow: 0 6px 18px rgba(2, 6, 23, 0.04); /* constrain width to visually match the Create cards and center */ max-width: 980px; margin: 0 auto; @@ -204,20 +204,28 @@ /* Spinner used in the Entrar button */ @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } .toast { position: fixed; right: 20px; bottom: 20px; - background: rgba(0,0,0,0.8); + background: rgba(0, 0, 0, 0.8); color: #fff; padding: 10px 14px; border-radius: 8px; z-index: 9999; - box-shadow: 0 6px 20px rgba(2,6,23,0.2); + box-shadow: 0 6px 20px rgba(2, 6, 23, 0.2); +} +.toast.error { + background: rgba(160, 40, 40, 0.95); +} +.toast.info { + background: rgba(30, 120, 255, 0.95); } -.toast.error { background: rgba(160, 40, 40, 0.95); } -.toast.info { background: rgba(30, 120, 255, 0.95); } diff --git a/packages/broadcast-panel/src/components/TransmissionsTable.tsx b/packages/broadcast-panel/src/components/TransmissionsTable.tsx index 8632567..1c63661 100644 --- a/packages/broadcast-panel/src/components/TransmissionsTable.tsx +++ b/packages/broadcast-panel/src/components/TransmissionsTable.tsx @@ -1,156 +1,272 @@ -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' +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 + transmissions: Transmission[]; + onDelete: (id: string) => void; + onUpdate: (t: Transmission) => void; + isLoading?: boolean; } const platformIcons: Record = { - 'YouTube': , - 'Facebook': , - 'Twitch': , - 'LinkedIn': , - 'Generico': , // Logo genérico para transmisiones sin destino -} + YouTube: , + Facebook: , + Twitch: , + LinkedIn: , + Generico: , // Logo genérico para transmisiones sin destino +}; const TransmissionsTable: React.FC = (props) => { - const { transmissions, onDelete, onUpdate, isLoading } = props - const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming') - const [inviteOpen, setInviteOpen] = useState(false) - const [inviteLink, setInviteLink] = useState(undefined) - const [editOpen, setEditOpen] = useState(false) - const [editTransmission, setEditTransmission] = useState(undefined) - const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher() - const [loadingId, setLoadingId] = useState(null) - const [studioSession, setStudioSession] = useState<{ serverUrl?: string; token?: string; room?: string } | null>(null) - const [validating, setValidating] = useState(false) - const [connectError, setConnectError] = useState(null) - const [currentAttempt, setCurrentAttempt] = useState(null) + const { transmissions, onDelete, onUpdate, isLoading } = props; + const [activeTab, setActiveTab] = useState<"upcoming" | "past">("upcoming"); + const [inviteOpen, setInviteOpen] = useState(false); + const [inviteLink, setInviteLink] = useState(undefined); + const [editOpen, setEditOpen] = useState(false); + const [editTransmission, setEditTransmission] = useState< + Transmission | undefined + >(undefined); + const { + openStudio, + loadingId: launcherLoadingId, + error: launcherError, + } = useStudioLauncher(); + const [loadingId, setLoadingId] = useState(null); + const [studioSession, setStudioSession] = useState<{ + serverUrl?: string; + token?: string; + room?: string; + } | null>(null); + const [validating, setValidating] = useState(false); + const [connectError, setConnectError] = useState(null); + const [currentAttempt, setCurrentAttempt] = useState( + 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) || '' + 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' }) + setValidating(true); + setConnectError(null); + setStudioSession({ + serverUrl, + token: msg.token, + room: msg.room || "external", + }); } - } catch (e) { /* ignore */ } - }) + } 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 (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 }) + 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 */ } - }, []) + } catch (e) { + /* ignore */ + } + }, []); const handleEdit = (t: Transmission) => { - setEditTransmission(t) - setEditOpen(true) - } + 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 + 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 + return scheduledDate < now; } - }) + }); const openStudioForTransmission = async (t: Transmission) => { - if (loadingId || launcherLoadingId) return - setLoadingId(t.id) - setCurrentAttempt(t) - setValidating(true) + 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 userRaw = localStorage.getItem("avanzacast_user") || "Demo User"; + const user = userRaw; + const room = t.id || "avanzacast-studio"; - const result = await openStudio({ room, username: user }) + const result = await openStudio({ room, username: user }); if (!result) { - throw new Error('No se pudo abrir el estudio') + // don't throw here — surface error in UI instead and stop loading + setConnectError( + "No se pudo abrir el estudio (launcher devolvió resultado vacío)", + ); + setValidating(false); + setLoadingId(null); + return; } - const resAny: any = result as any + const resAny: any = result as any; + + // If the token-server returns a direct studioUrl (redirect) but no token/id, navigate there + if (resAny && resAny.studioUrl && !resAny.token && !resAny.id) { + try { + const target = resAny.studioUrl; + try { + window.location.href = target; + return; + } catch (e) { + try { + window.location.assign(target); + return; + } catch (e2) { + /* ignore */ + } + } + } catch (e) { + /* ignore navigation errors */ + } + } // 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 + 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) { - try { window.location.assign(target) } catch (e2) { /* ignore */ } + /* ignore storage errors */ + } + + // Prefer explicit VITE_BROADCASTPANEL_URL; if not provided and we're running on localhost, + // default to the public host so the redirect goes to the deployed Broadcast Panel. + const envBroadcast = + (import.meta.env.VITE_BROADCASTPANEL_URL as string) || ""; + let BROADCAST_BASE = + envBroadcast || + (typeof window !== "undefined" ? window.location.origin : ""); + if ( + !envBroadcast && + typeof window !== "undefined" && + window.location.hostname === "localhost" + ) { + BROADCAST_BASE = + "https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"; + } + // Usar ruta amigable /studio/:id para redirigir al Studio Portal que validará el token/room + const target = `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${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 + 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) || + ""; + 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) || '' + 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 }) + setStudioSession({ + serverUrl, + token: resAny.token, + room: resAny.room || room, + }); } else { - setValidating(false) + setValidating(false); } - setLoadingId(null) + 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) + 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){} - } + try { + setStudioSession(null); + } catch (e) {} + }; if (isLoading) { return ( @@ -159,27 +275,25 @@ const TransmissionsTable: React.FC = (props) => { - +
- ) + ); } return (
@@ -188,10 +302,9 @@ const TransmissionsTable: React.FC = (props) => { {!filtered || filtered.length === 0 ? (
- {activeTab === 'upcoming' - ? 'No hay transmisiones programadas todavía.' - : 'No hay transmisiones anteriores.' - } + {activeTab === "upcoming" + ? "No hay transmisiones programadas todavía." + : "No hay transmisiones anteriores."}
) : ( @@ -202,79 +315,191 @@ const TransmissionsTable: React.FC = (props) => { Título Creado Programado - + {filtered.map((t: Transmission) => ( -
+
-
{platformIcons[t.platform] || platformIcons['YouTube']}
+
+ {platformIcons[t.platform] || + platformIcons["YouTube"]} +
-
{t.title}
-
- {t.platform === 'Generico' ? 'Solo grabación' : (t.platform || 'YouTube')} +
+ {t.title} +
+
+ {t.platform === "Generico" + ? "Solo grabación" + : t.platform || "YouTube"}
- - {t.createdAt || '---'} + + {t.createdAt || "---"} - - {(t.scheduled && t.scheduled !== 'Próximamente') ? t.scheduled : '---'} + + {t.scheduled && t.scheduled !== "Próximamente" + ? t.scheduled + : "---"} - +
+ {launcherError && ( // Mostrar modal claro si el hook de launcher reporta un error -
-
+
+

Error al iniciar el estudio

{launcherError}

-
- +
+
)} } + trigger={ + + } items={[ - { label: 'Agregar invitados', icon: , onClick: () => { setInviteLink(`https://streamyard.com/${t.id}`); setInviteOpen(true) } }, - { label: 'Editar', icon: , onClick: () => handleEdit(t) }, - { divider: true, label: '', disabled: false }, - { label: 'Ver en YouTube', icon: , onClick: () => {/* abrir */} }, - { divider: true, label: '', disabled: false }, - { label: 'Eliminar transmisión', icon: , onClick: () => onDelete(t.id), containerProps: { className: styles.deleteItem }, labelProps: { className: styles.dangerLabel } } + { + label: "Agregar invitados", + icon: , + onClick: () => { + setInviteLink(`https://streamyard.com/${t.id}`); + setInviteOpen(true); + }, + }, + { + label: "Editar", + icon: , + onClick: () => handleEdit(t), + }, + { divider: true, label: "", disabled: false }, + { + label: "Ver en YouTube", + icon: , + onClick: () => { + /* abrir */ + }, + }, + { divider: true, label: "", disabled: false }, + { + label: "Eliminar transmisión", + icon: , + onClick: () => onDelete(t.id), + containerProps: { className: styles.deleteItem }, + labelProps: { className: styles.dangerLabel }, + }, ]} /> - setInviteOpen(false)} link={inviteLink || ''} /> + setInviteOpen(false)} + link={inviteLink || ""} + />
@@ -283,52 +508,158 @@ const TransmissionsTable: React.FC = (props) => {
)} - - { setEditOpen(false); setEditTransmission(undefined) }} + + { + setEditOpen(false); + setEditTransmission(undefined); + }} onCreate={() => {}} onUpdate={onUpdate} transmission={editTransmission} /> {studioSession && ( -
-
- +
+
+ { setValidating(false); /* keep portal open */ }} - onRoomDisconnected={() => { closeStudio(); }} - onRoomConnectError={(err) => { setValidating(false); setConnectError(String(err?.message || err || 'Error al conectar')); }} + 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"), + ); + }} />
)} {validating && ( -
-
+
+
Validando token, por favor espera...
)} {connectError && ( -
-
+
+

Error al conectar al estudio

{connectError}

-
- - +
+ +
)}
- ) -} + ); +}; -export default TransmissionsTable +export default TransmissionsTable; diff --git a/packages/broadcast-panel/src/components/icons/PlusLarge.tsx b/packages/broadcast-panel/src/components/icons/PlusLarge.tsx index 18c5aa5..b70ba45 100644 --- a/packages/broadcast-panel/src/components/icons/PlusLarge.tsx +++ b/packages/broadcast-panel/src/components/icons/PlusLarge.tsx @@ -1,14 +1,18 @@ -import React from 'react' +import React from "react"; type Props = { - className?: string - size?: number - color?: string -} + className?: string; + size?: number; + color?: string; +}; -export const PlusLarge: React.FC = ({ className = '', size = 20, color = 'var(--primary-blue)' }) => { - const half = size / 2 - const stroke = Math.max(1, Math.round(size * 0.12)) +export const PlusLarge: React.FC = ({ + className = "", + size = 20, + color = "var(--primary-blue)", +}) => { + const half = size / 2; + const stroke = Math.max(1, Math.round(size * 0.12)); return ( = ({ className = '', size = 20, color = xmlns="http://www.w3.org/2000/svg" aria-hidden="true" > - - - + + + - ) -} + ); +}; -export default PlusLarge +export default PlusLarge; diff --git a/packages/broadcast-panel/src/components/icons/Spinner.tsx b/packages/broadcast-panel/src/components/icons/Spinner.tsx new file mode 100644 index 0000000..17e8d0e --- /dev/null +++ b/packages/broadcast-panel/src/components/icons/Spinner.tsx @@ -0,0 +1,68 @@ +import React from "react"; + +type Props = { size?: number; color?: string; className?: string }; + +// Stylized spinner: circular arc with smooth rotation; uses +export const Spinner: React.FC = ({ + size = 18, + color = "#0b5cff", + className = "", +}) => { + const stroke = Math.max(2, Math.round(size * 0.12)); + const r = (size - stroke) / 2; + const cx = size / 2; + const cy = size / 2; + const dashArray = Math.round(Math.PI * r * 1.5); + return ( + + ); +}; + +export default Spinner; diff --git a/packages/broadcast-panel/src/env.d.ts b/packages/broadcast-panel/src/env.d.ts index 2bdfffa..7ab2a25 100644 --- a/packages/broadcast-panel/src/env.d.ts +++ b/packages/broadcast-panel/src/env.d.ts @@ -1,10 +1,10 @@ // Local env declarations for broadcast-panel (Vite-style import.meta.env) interface ImportMetaEnv { - readonly VITE_TOKEN_SERVER_URL?: string - readonly VITE_STUDIO_URL?: string - [key: string]: string | undefined + readonly VITE_TOKEN_SERVER_URL?: string; + readonly VITE_STUDIO_URL?: string; + [key: string]: string | undefined; } interface ImportMeta { - readonly env: ImportMetaEnv + readonly env: ImportMetaEnv; } diff --git a/packages/broadcast-panel/src/features/studio/AutoRequestAndInject.tsx b/packages/broadcast-panel/src/features/studio/AutoRequestAndInject.tsx new file mode 100644 index 0000000..d0340f8 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/AutoRequestAndInject.tsx @@ -0,0 +1,142 @@ +"use client"; +import { useEffect, useState } from "react"; + +type SessionResp = { + id?: string; + token?: string; + studioUrl?: string; + username?: string; + [k: string]: any; +}; + +async function resolveIdentity(): Promise { + try { + const r = await fetch("/api/auth/session"); + if (r.ok) { + const j = await r.json(); + if (j && j.user) return j.user.name || j.user.email || null; + } + } catch (e) { + /* ignore */ + } + + try { + // @ts-ignore + if (typeof window !== "undefined" && (window as any).__AVANZACAST_USER) { + // @ts-ignore + const u = (window as any).__AVANZACAST_USER; + if (typeof u === "string") return u; + if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email; + } + } catch (e) { + /* ignore */ + } + + try { + const answer = + typeof window !== "undefined" + ? window.prompt( + "Identifícate (nombre de usuario) para ligar con la room", + "visual-runner", + ) + : null; + if (answer && answer.trim()) return answer.trim(); + } catch (e) { + /* ignore */ + } + + return null; +} + +export default function AutoRequestAndInject({ + roomName, +}: { + roomName: string; +}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [username, setUsername] = useState(null); + + useEffect(() => { + let mounted = true; + async function run() { + setLoading(true); + setError(null); + try { + const identity = await resolveIdentity(); + if (!identity) + throw new Error( + "No identity resolved; please login or set window.__AVANZACAST_USER", + ); + if (mounted) setUsername(identity); + + const resp = await fetch("/api/session/proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + room: roomName, + username: identity, + ttl: 300, + }), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const json: SessionResp = await resp.json(); + + if (!json.token) { + if (json.id) { + const getResp = await fetch( + `/api/session/proxy/${encodeURIComponent(json.id)}`, + ); + if (getResp.ok) { + const getJson = await getResp.json(); + if (getJson && getJson.token) json.token = getJson.token; + } + } + } + + if (!json.token) + throw new Error("No token returned from session proxy"); + + window.postMessage( + { + type: "LIVEKIT_TOKEN", + token: json.token, + room: roomName, + username: identity, + }, + window.location.origin, + ); + console.log("AutoRequestAndInject: posted LIVEKIT_TOKEN to window", { + room: roomName, + username: identity, + }); + } catch (err: any) { + console.error("AutoRequestAndInject error", err); + if (mounted) setError(String(err)); + } finally { + if (mounted) setLoading(false); + } + } + run(); + return () => { + mounted = false; + }; + }, [roomName]); + + if (loading) + return ( +
+ Solicitando token de sesión para room "{roomName}"... +
+ ); + if (error) return
Error solicitando token: {error}
; + return ( +
+ {username ? ( +
+ Identidad: {username} +
+ ) : null} +
+ ); +} diff --git a/packages/broadcast-panel/src/features/studio/BottomControls.tsx b/packages/broadcast-panel/src/features/studio/BottomControls.tsx index 98746b5..088ca8c 100644 --- a/packages/broadcast-panel/src/features/studio/BottomControls.tsx +++ b/packages/broadcast-panel/src/features/studio/BottomControls.tsx @@ -1,9 +1,9 @@ -import React, { useState, useContext } from 'react' -import { RoomContext } from '@livekit/components-react' -import { Room } from 'livekit-client' -import { ControlButton, ControlGroup, IconButton } from 'avanza-ui' -import IconCameraOn from './icons/IconCameraOn' -import IconMicOff from './icons/IconMicOff' +import React, { useState, useContext } from "react"; +import { RoomContext } from "@livekit/components-react"; +import { Room } from "livekit-client"; +import { ControlButton, ControlGroup, IconButton } from "avanza-ui"; +import IconCameraOn from "./icons/IconCameraOn"; +import IconMicOff from "./icons/IconMicOff"; interface BottomControlsProps { onToggleMute?: (muted: boolean) => void; @@ -11,129 +11,166 @@ interface BottomControlsProps { onToggleRecording?: (recording: boolean) => void; } -let idCounter = 0 -function uniqueId(prefix = 'id'){ - idCounter += 1 - return `${prefix}-${idCounter}` +let idCounter = 0; +function uniqueId(prefix = "id") { + idCounter += 1; + return `${prefix}-${idCounter}`; } -export default function BottomControls({ onToggleMute, onToggleCamera, onToggleRecording }: BottomControlsProps){ - const [muted, setMuted] = useState(false) - const [cameraOn, setCameraOn] = useState(true) - const [recording, setRecording] = useState(false) +export default function BottomControls({ + onToggleMute, + onToggleCamera, + onToggleRecording, +}: BottomControlsProps) { + const [muted, setMuted] = useState(false); + const [cameraOn, setCameraOn] = useState(true); + const [recording, setRecording] = useState(false); - const ctxRoom = useContext(RoomContext) as Room | null + const ctxRoom = useContext(RoomContext) as Room | null; React.useEffect(() => { function onGoLive(e: any) { try { const d = e?.detail || {}; - if (d.action === 'start') setRecording(true); - else if (d.action === 'stop') setRecording(false); - } catch (err) { console.warn('go-live handler error', err) } + if (d.action === "start") setRecording(true); + else if (d.action === "stop") setRecording(false); + } catch (err) { + console.warn("go-live handler error", err); + } } - window.addEventListener('avz:request:go-live', onGoLive as EventListener); - return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener); + window.addEventListener("avz:request:go-live", onGoLive as EventListener); + return () => + window.removeEventListener( + "avz:request:go-live", + onGoLive as EventListener, + ); }, []); - const muteTipId = React.useMemo(() => uniqueId('tip-mute'), []) - const camTipId = React.useMemo(() => uniqueId('tip-cam'), []) - const recTipId = React.useMemo(() => uniqueId('tip-rec'), []) + const muteTipId = React.useMemo(() => uniqueId("tip-mute"), []); + const camTipId = React.useMemo(() => uniqueId("tip-cam"), []); + const recTipId = React.useMemo(() => uniqueId("tip-rec"), []); const safeSetMic = async (enabled: boolean) => { try { - const r = ctxRoom as any - if (!r) return - const lp = r.localParticipant - if (!lp) return - if (typeof lp.setMicrophoneEnabled === 'function') { - await lp.setMicrophoneEnabled(enabled) - return + const r = ctxRoom as any; + if (!r) return; + const lp = r.localParticipant; + if (!lp) return; + if (typeof lp.setMicrophoneEnabled === "function") { + await lp.setMicrophoneEnabled(enabled); + return; } if (lp.audioTracks && Array.isArray(lp.audioTracks)) { for (const tpub of lp.audioTracks) { - try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + try { + tpub.track && + typeof tpub.track.enable === "function" && + tpub.track.enable(enabled); + } catch (e) {} } } } catch (e) { - console.warn('safeSetMic failed', e) + console.warn("safeSetMic failed", e); } - } + }; const safeSetCamera = async (enabled: boolean) => { try { - const r = ctxRoom as any - if (!r) return - const lp = r.localParticipant - if (!lp) return - if (typeof lp.setCameraEnabled === 'function') { - await lp.setCameraEnabled(enabled) - return + const r = ctxRoom as any; + if (!r) return; + const lp = r.localParticipant; + if (!lp) return; + if (typeof lp.setCameraEnabled === "function") { + await lp.setCameraEnabled(enabled); + return; } if (lp.videoTracks && Array.isArray(lp.videoTracks)) { for (const tpub of lp.videoTracks) { - try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + try { + tpub.track && + typeof tpub.track.enable === "function" && + tpub.track.enable(enabled); + } catch (e) {} } } } catch (e) { - console.warn('safeSetCamera failed', e) + console.warn("safeSetCamera failed", e); } - } + }; const safeToggleRecording = async (start: boolean) => { try { - const r = ctxRoom as any - if (!r) return - const lp = r.localParticipant - if (!lp) return - if (typeof lp.publishData === 'function') { - const payload = JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop', ts: Date.now() }) - const enc = new TextEncoder().encode(payload) - try { await lp.publishData(enc, { reliable: true }) } catch(e) { console.warn('publishData failed', e) } - return + const r = ctxRoom as any; + if (!r) return; + const lp = r.localParticipant; + if (!lp) return; + if (typeof lp.publishData === "function") { + const payload = JSON.stringify({ + type: "RECORDING", + action: start ? "start" : "stop", + ts: Date.now(), + }); + const enc = new TextEncoder().encode(payload); + try { + await lp.publishData(enc, { reliable: true }); + } catch (e) { + console.warn("publishData failed", e); + } + return; } - if (typeof r.sendData === 'function') { - try { r.sendData(JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop' })) } catch(e) {} + if (typeof r.sendData === "function") { + try { + r.sendData( + JSON.stringify({ + type: "RECORDING", + action: start ? "start" : "stop", + }), + ); + } catch (e) {} } } catch (e) { - console.warn('safeToggleRecording failed', e) + console.warn("safeToggleRecording failed", e); } - } + }; const handleToggleMute = async () => { - const next = !muted - setMuted(next) - onToggleMute?.(next) - await safeSetMic(!next ? true : false) - } + const next = !muted; + setMuted(next); + onToggleMute?.(next); + await safeSetMic(!next ? true : false); + }; const handleToggleCamera = async () => { - const next = !cameraOn - setCameraOn(next) - onToggleCamera?.(next) - await safeSetCamera(next) - } + const next = !cameraOn; + setCameraOn(next); + onToggleCamera?.(next); + await safeSetCamera(next); + }; const handleToggleRecording = async () => { - const next = !recording - setRecording(next) - onToggleRecording?.(next) - } + const next = !recording; + setRecording(next); + onToggleRecording?.(next); + }; return (
- - +
} active={!muted} - title={muted ? 'Activar micrófono' : 'Silenciar'} + title={muted ? "Activar micrófono" : "Silenciar"} onClick={handleToggleMute} size="sm" /> - {muted ? 'Activar micrófono' : 'Silenciar'} + + {muted ? "Activar micrófono" : "Silenciar"} +
@@ -141,31 +178,35 @@ export default function BottomControls({ onToggleMute, onToggleCamera, onToggleR id={`btn-cam`} icon={} active={cameraOn} - title={cameraOn ? 'Apagar cámara' : 'Encender cámara'} + title={cameraOn ? "Apagar cámara" : "Encender cámara"} onClick={handleToggleCamera} size="sm" /> - {cameraOn ? 'Apagar cámara' : 'Encender cámara'} + + {cameraOn ? "Apagar cámara" : "Encender cámara"} +
: undefined} - label={recording ? 'Stop' : 'Start'} + label={recording ? "Stop" : "Start"} active={recording} danger={true} - title={recording ? 'Detener grabación' : 'Iniciar grabación'} + title={recording ? "Detener grabación" : "Iniciar grabación"} onClick={handleToggleRecording} size="md" /> - {recording ? 'Detener grabación' : 'Iniciar grabación'} + + {recording ? "Detener grabación" : "Iniciar grabación"} +
- {recording ? 'Grabación iniciada' : 'Grabación detenida'} - + + {recording ? "Grabación iniciada" : "Grabación detenida"} +
- ) + ); } - diff --git a/packages/broadcast-panel/src/features/studio/StudioPortal.css b/packages/broadcast-panel/src/features/studio/StudioPortal.css index 2201b37..7f1c001 100644 --- a/packages/broadcast-panel/src/features/studio/StudioPortal.css +++ b/packages/broadcast-panel/src/features/studio/StudioPortal.css @@ -1,17 +1,184 @@ -// moved from studio-panel -.studio-portal{display:flex;height:100vh;gap:12px;background:var(--studio-bg-primary)} -.studio-portal__left{width:280px;background:var(--studio-bg-secondary);padding:12px;border-right:1px solid var(--studio-border)} -.studio-portal__right{width:320px;background:var(--studio-bg-secondary);padding:12px;border-left:1px solid var(--studio-border)} -.studio-portal__center{flex:1;display:flex;flex-direction:column;align-items:stretch;padding:12px} -.preview-wrapper{flex:1;background:#0f0f0f;border-radius:8px;display:flex;align-items:center;justify-content:center} -.controls-bar{display:flex;justify-content:space-between;align-items:center;padding:12px 0} -.scenes-header{font-weight:700;margin-bottom:8px} -.scenes-list{display:flex;flex-direction:column;gap:8px} -.scene-item{padding:10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)} -.scene-item.active{background:var(--studio-accent);color:#fff} -.layout-presets{display:flex;gap:8px} -.layout-btn{padding:8px 10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)} -.layout-btn.active{background:var(--studio-accent);color:#fff} -.actions .btn-record{background:#2563eb;color:#fff;padding:10px 16px;border-radius:8px;border:none} -.actions .btn-stop{background:#dc2626;color:#fff;padding:10px 16px;border-radius:8px;border:none} +/* moved from studio-panel */ +.studio-portal { + display: flex; + height: 100vh; + gap: 12px; + background: var(--studio-bg-primary); + align-items: stretch; +} +/* Left column: Scenes */ +.studio-portal__left { + width: 280px; + background: var(--studio-bg-secondary); + padding: 16px; + border-right: 1px solid var(--studio-border); + display: flex; + flex-direction: column; +} +.scenes-header { + font-weight: 700; + margin-bottom: 12px; +} +.scenes-list { + display: flex; + flex-direction: column; + gap: 12px; + overflow: auto; + padding-right: 4px; +} +.scene-item { + padding: 10px; + border-radius: 6px; + background: var(--studio-bg-primary); + border: 1px solid var(--studio-border); + display: flex; + align-items: center; + gap: 8px; +} +.scene-item.active { + background: var(--studio-accent); + color: #fff; +} +.btn-new-scene { + margin-top: auto; + padding: 10px; + border-radius: 8px; + border: none; + background: #fff; +} + +/* Center column: main studio area */ +.studio-portal__center { + flex: 1; + display: flex; + flex-direction: column; + padding: 16px; + gap: 12px; +} +.preview-wrapper { + flex: 1; + background: #0b0b0b; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + padding: 12px; +} + +/* Large video stage area */ +.studio-room__content { + flex: 1; + display: flex; + align-items: stretch; + justify-content: center; +} +.studio-room__grid { + width: 100%; + height: 100%; + border-radius: 6px; + overflow: hidden; +} + +/* Thumbnails / presenter row */ +.preview-thumbs { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 8px; + background: transparent; +} +.preview-thumb { + width: 160px; + height: 90px; + background: #111; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.04); +} +.preview-thumb .name { + margin-top: 6px; + font-size: 13px; + color: #d1d5db; +} + +/* Controls bar below central area */ +.controls-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; +} +.layout-presets { + display: flex; + gap: 8px; +} +.layout-btn { + padding: 8px 10px; + border-radius: 6px; + background: var(--studio-bg-primary); + border: 1px solid var(--studio-border); +} +.layout-btn.active { + background: var(--studio-accent); + color: #fff; +} +.actions { + display: flex; + gap: 8px; + align-items: center; +} +.actions .btn-record { + background: #2563eb; + color: #fff; + padding: 10px 16px; + border-radius: 8px; + border: none; +} +.actions .btn-stop { + background: #dc2626; + color: #fff; + padding: 10px 16px; + border-radius: 8px; + border: none; +} + +/* Right column: comments / assets */ +.studio-portal__right { + width: 320px; + background: var(--studio-bg-secondary); + padding: 12px; + border-left: 1px solid var(--studio-border); + display: flex; + flex-direction: column; +} +.sidebar-section { + padding: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.02); + font-size: 14px; +} + +/* Responsive behavior */ +@media (max-width: 1100px) { + .studio-portal { + flex-direction: column; + height: 100%; + } + .studio-portal__left, + .studio-portal__right { + width: 100%; + height: auto; + } + .studio-portal__center { + order: 2; + } +} + +/* Small utility */ +.scene-item img { + max-width: 36px; + border-radius: 4px; +} diff --git a/packages/broadcast-panel/src/features/studio/StudioPortal.tsx b/packages/broadcast-panel/src/features/studio/StudioPortal.tsx index f5b3d50..eb755b4 100644 --- a/packages/broadcast-panel/src/features/studio/StudioPortal.tsx +++ b/packages/broadcast-panel/src/features/studio/StudioPortal.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect, useRef } from 'react'; -import StudioRoom from './StudioRoom'; -import './StudioPortal.css'; -import { Room } from 'livekit-client'; +import React, { useState, useEffect, useRef } from "react"; +import StudioRoom from "./StudioRoom"; +import "./StudioPortal.css"; +import { Room } from "livekit-client"; +import AutoRequestAndInject from "./AutoRequestAndInject"; export interface StudioPortalProps { serverUrl: string; @@ -15,17 +16,27 @@ export interface StudioPortalProps { } const LAYOUTS = [ - { id: 'layout-1', label: 'Individual' }, - { id: 'layout-2', label: 'Gallery' }, - { id: 'layout-3', label: 'Speaker' }, - { id: 'layout-4', label: 'Wide' }, + { id: "layout-1", label: "Individual" }, + { id: "layout-2", label: "Gallery" }, + { id: "layout-3", label: "Speaker" }, + { id: "layout-4", label: "Wide" }, ]; -export default function StudioPortal({ serverUrl, token, roomName, onRoomConnected, onRoomDisconnected, onRoomConnectError, room }: StudioPortalProps) { +export default function StudioPortal({ + serverUrl, + token, + roomName, + onRoomConnected, + onRoomDisconnected, + onRoomConnectError, + room, +}: StudioPortalProps) { const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id); const [live, setLive] = useState(false); // allow override of serverUrl via postMessage (useful for e2e) - const [serverUrlOverride, setServerUrlOverride] = useState(null); + const [serverUrlOverride, setServerUrlOverride] = useState( + null, + ); // Local room management when App does not provide a room prop const localRoomRef = useRef(null); @@ -47,7 +58,9 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect setIsConnecting(true); // cleanup previous if (localRoomRef.current) { - try { localRoomRef.current.disconnect(); } catch(e) {} + try { + localRoomRef.current.disconnect(); + } catch (e) {} localRoomRef.current = null; } const r = new Room(); @@ -56,11 +69,13 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect setIsConnected(true); onRoomConnected && onRoomConnected(); } catch (err) { - console.error('StudioPortal: failed to connect local room', err); + console.error("StudioPortal: failed to connect local room", err); setIsConnected(false); const msg = (err as any)?.message ?? String(err); setConnectError(msg); - try { onRoomConnectError && onRoomConnectError(err) } catch(e){} + try { + onRoomConnectError && onRoomConnectError(err); + } catch (e) {} } finally { setIsConnecting(false); } @@ -72,14 +87,22 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect localRoomRef.current.disconnect(); localRoomRef.current = null; } - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } setIsConnected(false); onRoomDisconnected && onRoomDisconnected(); }; // Auto-connect when token becomes available and there is no external room useEffect(() => { - if (!isExternalRoom && (tokenFromMessage || token) && (tokenFromMessage || token).trim() && !isConnected && !isConnecting) { + if ( + !isExternalRoom && + (tokenFromMessage || token) && + (tokenFromMessage || token).trim() && + !isConnected && + !isConnecting + ) { connectWithToken(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -91,46 +114,79 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect try { const data = e.data || {}; // respond to ping from launcher so it knows we are ready - if (data && data.type === 'LIVEKIT_PING') { - try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch(e) { /* ignore */ } - return + if (data && data.type === "LIVEKIT_PING") { + try { + (e.source as Window)?.postMessage?.( + { type: "LIVEKIT_READY" }, + e.origin || "*", + ); + } catch (e) { + /* ignore */ + } + return; } // accept object messages with type LIVEKIT_TOKEN and token - if (data && data.type === 'LIVEKIT_TOKEN' && data.token) { - console.info('StudioPortal received token via postMessage', { origin: e.origin }) - setTokenFromMessage(String(data.token)) + if (data && data.type === "LIVEKIT_TOKEN" && data.token) { + console.info("StudioPortal received token via postMessage", { + origin: e.origin, + }); + setTokenFromMessage(String(data.token)); // optionally accept serverUrl override for e2e flows if (data.serverUrl) setServerUrlOverride(String(data.serverUrl)); // reply ack to sender - try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_ACK', room: data.room || '' }, e.origin || '*') } catch(e) { /* ignore */ } + try { + (e.source as Window)?.postMessage?.( + { type: "LIVEKIT_ACK", room: data.room || "" }, + e.origin || "*", + ); + } catch (e) { + /* ignore */ + } } - } catch (err) { console.warn('postMessage handler error', err) } + } catch (err) { + console.warn("postMessage handler error", err); + } } - window.addEventListener('message', onMessage); - return () => { window.removeEventListener('message', onMessage) } + window.addEventListener("message", onMessage); + return () => { + window.removeEventListener("message", onMessage); + }; }, []); // Cleanup on unmount useEffect(() => { return () => { - try { if (localRoomRef.current) { localRoomRef.current.disconnect(); localRoomRef.current = null; } } catch (e) {} + try { + if (localRoomRef.current) { + localRoomRef.current.disconnect(); + localRoomRef.current = null; + } + } catch (e) {} }; }, []); const handleStartLive = () => { - window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'start' } })); + window.dispatchEvent( + new CustomEvent("avz:request:go-live", { detail: { action: "start" } }), + ); setLive(true); }; const handleStopLive = () => { - window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'stop' } })); + window.dispatchEvent( + new CustomEvent("avz:request:go-live", { detail: { action: "stop" } }), + ); setLive(false); }; const changeLayout = (id: string) => { setActiveLayout(id); try { - window.dispatchEvent(new CustomEvent('avz:layout:change', { detail: { layoutId: id } })); - } catch (e) { console.warn('layout dispatch failed', e); } + window.dispatchEvent( + new CustomEvent("avz:layout:change", { detail: { layoutId: id } }), + ); + } catch (e) { + console.warn("layout dispatch failed", e); + } }; // Determine which room to pass into StudioRoom: external first, fallback to local @@ -149,49 +205,97 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
-
+
LiveKit: {serverUrlOverride || serverUrl}
-
+
{!isExternalRoom && ( <> - - )} {isExternalRoom && ( -
Usando Room externo
+
+ Usando Room externo +
)}
{/* show token status / errors for E2E debugging */}
+ {/* Optional auto-inject helper for integrated flows (controlled via Vite env VITE_ENABLE_AUTO_INJECT=1) */} + {(import.meta.env.VITE_ENABLE_AUTO_INJECT as string) === "1" && ( + + )} {tokenFromMessage ? ( -
Token recibido desde Broadcast Panel (length {tokenFromMessage.length})
+
+ Token recibido desde Broadcast Panel (length{" "} + {tokenFromMessage.length}) +
) : ( -
Esperando token...
+
+ Esperando token... +
)} {connectError && ( -
Error de conexión: {connectError}
+
+ Error de conexión: {connectError} +
)}
- +
- {LAYOUTS.map(l => ( + {LAYOUTS.map((l) => ( + ) : ( - + )}
diff --git a/packages/broadcast-panel/src/features/studio/StudioPortalWithComponents.tsx b/packages/broadcast-panel/src/features/studio/StudioPortalWithComponents.tsx new file mode 100644 index 0000000..010126b --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/StudioPortalWithComponents.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { LiveKitRoom, VideoConference } from "@livekit/components-react"; + +export interface StudioPortalWithComponentsProps { + serverUrl: string; + token: string; + connect?: boolean; + roomName?: string; +} + +const StudioPortalWithComponents: React.FC = ({ + serverUrl, + token, + connect = true, +}) => { + // This component is a thin wrapper around LiveKit components. It expects a valid token and serverUrl. + // Usage: + + if (!token || !serverUrl) { + return ( +
+
Studio (LiveKit)
+
+ Esperando token y/o URL del servidor... +
+
+ ); + } + + return ( +
+ + + +
+ ); +}; + +export default StudioPortalWithComponents; diff --git a/packages/broadcast-panel/src/features/studio/StudioRoom.css b/packages/broadcast-panel/src/features/studio/StudioRoom.css index 13d80cd..85c15c3 100644 --- a/packages/broadcast-panel/src/features/studio/StudioRoom.css +++ b/packages/broadcast-panel/src/features/studio/StudioRoom.css @@ -1,7 +1,110 @@ /* Minimal placeholder styles for StudioRoom used in tests */ -.studio-room { font-family: sans-serif; } -.studio-room__header { display:flex; justify-content:space-between; } -.studio-room__content { height: 400px; } -.controls-inner { display:flex; gap:8px; } -.tooltip { display:none; } +.studio-room { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial; + color: #e5e7eb; +} +.studio-room__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 8px; +} +.studio-room__title h1 { + margin: 0; + font-size: 18px; +} +.studio-room__status { + display: flex; + align-items: center; + gap: 8px; + color: #9ca3af; +} +.studio-room__content { + flex: 1; + display: flex; + align-items: stretch; + justify-content: center; + min-height: 420px; +} +.preview-wrapper .studio-room__grid { + width: 100%; + height: 100%; + background: #000; +} + +/* Grid layout for participants */ +.studio-room__grid { + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: minmax(200px, 1fr); + gap: 12px; +} + +/* Participant tile overrides to make them occupy full area */ +.lk-participant-tile { + background: #0b1220; + border-radius: 6px; + overflow: hidden; +} +.lk-participant-name { + font-size: 13px; + color: #d1d5db; +} + +/* Controls */ +.studio-room__controls { + padding: 12px 8px; + display: flex; + justify-content: flex-end; +} +.control-bar { + display: flex; + gap: 8px; + align-items: center; +} + +/* Thumbnails row under main stage */ +.preview-thumbs { + display: flex; + gap: 12px; + padding: 12px 8px; + align-items: center; +} +.preview-thumb { + width: 160px; + height: 90px; + background: #0f1724; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; +} +.preview-thumb .name { + font-size: 13px; + color: #cbd5e1; +} + +/* Overlay svg lines */ +.connections-overlay { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +/* Small helper for connecting state */ +.studio-room__connecting { + padding: 24px; + text-align: center; +} diff --git a/packages/broadcast-panel/src/features/studio/StudioRoom.tsx b/packages/broadcast-panel/src/features/studio/StudioRoom.tsx index 153d050..58e3b19 100644 --- a/packages/broadcast-panel/src/features/studio/StudioRoom.tsx +++ b/packages/broadcast-panel/src/features/studio/StudioRoom.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useRef } from 'react'; -import type { Room } from 'livekit-client' +import React, { useEffect, useState, useRef } from "react"; +import type { Room } from "livekit-client"; import { GridLayout, ParticipantTile, @@ -7,11 +7,11 @@ import { RoomAudioRenderer, useTracks, RoomContext, -} from '@livekit/components-react'; -import '@livekit/components-styles'; -import { Button } from 'avanza-ui'; -import './StudioRoom.css'; -import BottomControls from './BottomControls'; +} from "@livekit/components-react"; +import "@livekit/components-styles"; +import { Button } from "avanza-ui"; +import "./StudioRoom.css"; +import BottomControls from "./BottomControls"; export interface StudioRoomProps { /** LiveKit server URL */ @@ -39,25 +39,56 @@ export const StudioRoom: React.FC = ({ room: externalRoom, onConnectError, }) => { - // If an external Room is provided, use it; otherwise create an internal Room lazily - const internalRoomRef = useRef(null); - const getRoom = () => externalRoom || internalRoomRef.current; - // Local alias used throughout the component to reference current Room (external or internal) - const room = externalRoom || internalRoomRef.current; + // If an external Room is provided, use it; otherwise create an internal Room lazily + const internalRoomRef = useRef(null); + const getRoom = () => externalRoom || internalRoomRef.current; + // Local alias used throughout the component to reference current Room (external or internal) + const room = externalRoom || internalRoomRef.current; // effectiveRoom used to decide whether to render LiveKit components (must be a valid Room instance) const effectiveRoom = getRoom(); - const hasValidRoom = !!effectiveRoom && (typeof (effectiveRoom as any).connect === 'function' || !!(effectiveRoom as any).localParticipant) - const isExternalRoom = !!externalRoom; + const hasValidRoom = + !!effectiveRoom && + (typeof (effectiveRoom as any).connect === "function" || + !!(effectiveRoom as any).localParticipant); + const isExternalRoom = !!externalRoom; const [connectError, setConnectError] = useState(null); - const [participantsList, _setParticipantsList] = useState>([]); + const [participantsList, _setParticipantsList] = useState< + Array<{ + sid: string; + identity: string; + isLocal?: boolean; + accepted?: boolean; + }> + >([]); const connectedRef = React.useRef(false); const connectingRef = React.useRef(false); const previewRef = React.useRef(null); - const [lines, setLines] = useState>([]); + const preflightDoneRef = React.useRef(false); + // Enable or disable auto preflight (getUserMedia prompt) via env var + const PREFLIGHT_ENABLED = ((): boolean => { + try { + const v = (import.meta.env.VITE_STUDIO_PREFLIGHT as string) || undefined; + if (v === "0" || v === "false") return false; + if (v === "1" || v === "true") return true; + // default: enabled + return true; + } catch (e) { + return true; + } + })(); + const [lines, setLines] = useState< + Array<{ + x1: number; + y1: number; + x2: number; + y2: number; + accepted?: boolean; + }> + >([]); // connectRoom: reusable connect logic for initial attempt and retries const connectRoom = React.useCallback( - async (attemptToken?: string, attemptServer?: string) => { + async (attemptToken?: string, attemptServer?: string) => { if (connectingRef.current) return; connectingRef.current = true; setConnectError(null); @@ -66,18 +97,63 @@ export const StudioRoom: React.FC = ({ const tk = attemptToken || token; if (!sUrl || !tk) { // Avoid throwing inside this catch-all to keep analyzer happy; set error and return - setConnectError('Missing serverUrl or token'); + setConnectError("Missing serverUrl or token"); return; } + + // Prefetch media permissions once to trigger browser prompt early (preflight) + // This helps avoid PC establishment errors caused by delayed permission prompts + try { + if ( + PREFLIGHT_ENABLED && + typeof navigator !== "undefined" && + navigator.mediaDevices && + !preflightDoneRef.current + ) { + try { + // Request minimal constraints to prompt the permission dialog + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true, + }); + // Immediately stop all tracks to avoid holding camera/mic open + try { + const tracks = stream?.getTracks ? stream.getTracks() : []; + tracks.forEach((t) => { + try { + t.stop(); + } catch (e) {} + }); + } catch (e) {} + } catch (e) { + // If user denies or devices not available, just log and continue; connection may still work for passive viewers + console.warn("Preflight getUserMedia failed or denied", e); + } finally { + // mark preflight done to avoid repeated prompts + try { + preflightDoneRef.current = true; + } catch (e) {} + } + } + } catch (e) { + /* ignore preflight errors */ + } + // Only create/connect internal Room if no external one provided if (!isExternalRoom) { if (!internalRoomRef.current) { // dynamic import to avoid executing livekit-client at module load - const lk = await import('livekit-client'); + const lk = await import("livekit-client"); const LiveKitRoom = lk.Room; - internalRoomRef.current = new LiveKitRoom({ adaptiveStream: true, dynacast: true }) as any; + internalRoomRef.current = new LiveKitRoom({ + adaptiveStream: true, + dynacast: true, + }) as any; } - if (internalRoomRef.current && typeof internalRoomRef.current.connect === 'function') { + if ( + internalRoomRef.current && + typeof internalRoomRef.current.connect === "function" + ) { await internalRoomRef.current.connect(sUrl, tk); } } @@ -85,15 +161,17 @@ export const StudioRoom: React.FC = ({ setConnectError(null); onConnected?.(); } catch (err: any) { - console.error('StudioRoom connect failed', err); - setConnectError(String(err?.message || err || 'Connection failed')); - try { onConnectError && onConnectError(err) } catch(e){} + console.error("StudioRoom connect failed", err); + setConnectError(String(err?.message || err || "Connection failed")); + try { + onConnectError && onConnectError(err); + } catch (e) {} } finally { connectingRef.current = false; } }, - [serverUrl, token, onConnected, isExternalRoom] - ); + [serverUrl, token, onConnected, isExternalRoom], + ); useEffect(() => { let mounted = true; @@ -105,7 +183,15 @@ export const StudioRoom: React.FC = ({ if (!isExternalRoom) await connectRoom(); else { // If external room is already connected, notify parent - try { if ((getRoom() as any)?.state === 'connected' || (getRoom() as any)?.isConnected) { connectedRef.current = true; onConnected?.(); } } catch(e){} + try { + if ( + (getRoom() as any)?.state === "connected" || + (getRoom() as any)?.isConnected + ) { + connectedRef.current = true; + onConnected?.(); + } + } catch (e) {} } })(); @@ -115,14 +201,24 @@ export const StudioRoom: React.FC = ({ return () => { mounted = false; // cleanup listeners if present - try { const r = effectiveRoom; (r as any).off && (r as any).off('dataReceived'); } catch(e){} + try { + const r = effectiveRoom; + (r as any).off && (r as any).off("dataReceived"); + } catch (e) {} try { // Only disconnect if we actually connected - if (!isExternalRoom && connectedRef.current && internalRoomRef.current && typeof internalRoomRef.current.disconnect === 'function') { + if ( + !isExternalRoom && + connectedRef.current && + internalRoomRef.current && + typeof internalRoomRef.current.disconnect === "function" + ) { internalRoomRef.current.disconnect(); internalRoomRef.current = null; } - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } onDisconnected?.(); // poll removed }; @@ -132,11 +228,19 @@ export const StudioRoom: React.FC = ({ useEffect(() => { try { if (connectedRef.current) return; // already connected - if (!connectingRef.current && (token && token.trim()) && (serverUrl && serverUrl.trim())) { + if ( + !connectingRef.current && + token && + token.trim() && + serverUrl && + serverUrl.trim() + ) { // attempt connection with the latest props connectRoom(token, serverUrl); } - } catch (e) { console.warn('reactive connect attempt failed', e); } + } catch (e) { + console.warn("reactive connect attempt failed", e); + } }, [token, serverUrl, connectRoom]); // Notify parent when the room actually becomes connected @@ -156,12 +260,15 @@ export const StudioRoom: React.FC = ({ if (!isExternalRoom) return; const checkInterval = setInterval(() => { try { - if ((effectiveRoom as any)?.state === 'connected' || (effectiveRoom as any)?.isConnected) { + if ( + (effectiveRoom as any)?.state === "connected" || + (effectiveRoom as any)?.isConnected + ) { connectedRef.current = true; onConnected?.(); clearInterval(checkInterval); } - } catch(e){} + } catch (e) {} }, 250); return () => clearInterval(checkInterval); }, [isExternalRoom, effectiveRoom, onConnected]); @@ -178,23 +285,22 @@ export const StudioRoom: React.FC = ({ // Auto-enable camera try { await lp.setCameraEnabled(true); - console.log('Auto-enabled camera'); + console.log("Auto-enabled camera"); } catch (e) { - console.warn('Failed to auto-enable camera:', e); + console.warn("Failed to auto-enable camera:", e); } // Auto-enable microphone try { await lp.setMicrophoneEnabled(true); - console.log('Auto-enabled microphone'); + console.log("Auto-enabled microphone"); } catch (e) { - console.warn('Failed to auto-enable microphone:', e); + console.warn("Failed to auto-enable microphone:", e); } // NOTE: removed automatic recording/start signal per request (focus on transmission only) - } catch (e) { - console.warn('Auto-start failed:', e); + console.warn("Auto-start failed:", e); } }; @@ -208,24 +314,32 @@ export const StudioRoom: React.FC = ({ function onLayoutChange(e: any) { try { const layoutId = e?.detail?.layoutId; - const root = document.querySelector('.studio-room'); + const root = document.querySelector(".studio-room"); if (root && layoutId) { - (root as HTMLElement).setAttribute('data-layout', String(layoutId)); - console.log('Applied layout', layoutId); + (root as HTMLElement).setAttribute("data-layout", String(layoutId)); + console.log("Applied layout", layoutId); } - } catch (err) { console.warn('layout change handler error', err) } + } catch (err) { + console.warn("layout change handler error", err); + } } - window.addEventListener('avz:layout:change', onLayoutChange as EventListener); + window.addEventListener( + "avz:layout:change", + onLayoutChange as EventListener, + ); return () => { - window.removeEventListener('avz:layout:change', onLayoutChange as EventListener); + window.removeEventListener( + "avz:layout:change", + onLayoutChange as EventListener, + ); }; }, []); // Recalculate overlay lines between moderator (local) and guests React.useEffect(() => { - function computeLines(){ - try{ + function computeLines() { + try { const container = previewRef.current; if (!container) return setLines([]); const rootRect = container.getBoundingClientRect(); @@ -233,56 +347,134 @@ export const StudioRoom: React.FC = ({ const localIdentity = effectiveRoom?.localParticipant?.identity; let moderatorEl: Element | null = null; if (localIdentity) { - moderatorEl = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent || '').trim() === localIdentity) as Element || null; + moderatorEl = + (Array.from( + container.querySelectorAll(".lk-participant-name"), + ).find( + (el) => (el.textContent || "").trim() === localIdentity, + ) as Element) || null; } // fallback: try first participant tile inside container - if (!moderatorEl) moderatorEl = container.querySelector('.lk-participant-tile'); + if (!moderatorEl) + moderatorEl = container.querySelector(".lk-participant-tile"); if (!moderatorEl) return setLines([]); const mRect = (moderatorEl as HTMLElement).getBoundingClientRect(); - const mx = mRect.left + mRect.width/2 - rootRect.left; - const my = mRect.top + mRect.height/2 - rootRect.top; + const mx = mRect.left + mRect.width / 2 - rootRect.left; + const my = mRect.top + mRect.height / 2 - rootRect.top; - const newLines: Array<{x1:number,y1:number,x2:number,y2:number, accepted?: boolean}> = []; + const newLines: Array<{ + x1: number; + y1: number; + x2: number; + y2: number; + accepted?: boolean; + }> = []; // for each participant (excluding local), find tile by name and create line - participantsList.forEach(p => { + participantsList.forEach((p) => { if (p.isLocal) return; - const el = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent||'').trim() === p.identity) as Element | undefined; + const el = Array.from( + container.querySelectorAll(".lk-participant-name"), + ).find((el) => (el.textContent || "").trim() === p.identity) as + | Element + | undefined; if (!el) return; - const tRect = (el as HTMLElement).closest('.lk-participant-tile')?.getBoundingClientRect(); + const tRect = (el as HTMLElement) + .closest(".lk-participant-tile") + ?.getBoundingClientRect(); if (!tRect) return; - const tx = tRect.left + tRect.width/2 - rootRect.left; - const ty = tRect.top + tRect.height/2 - rootRect.top; - newLines.push({ x1: mx, y1: my, x2: tx, y2: ty, accepted: !!p.accepted }); + const tx = tRect.left + tRect.width / 2 - rootRect.left; + const ty = tRect.top + tRect.height / 2 - rootRect.top; + newLines.push({ + x1: mx, + y1: my, + x2: tx, + y2: ty, + accepted: !!p.accepted, + }); }); setLines(newLines); - }catch(e){ console.warn('computeLines error', e); setLines([]) } + } catch (e) { + console.warn("computeLines error", e); + setLines([]); + } } computeLines(); - if (typeof (globalThis as any).ResizeObserver !== 'undefined') { - const ro = new (globalThis as any).ResizeObserver(()=> computeLines()); + if (typeof (globalThis as any).ResizeObserver !== "undefined") { + const ro = new (globalThis as any).ResizeObserver(() => computeLines()); if (previewRef.current) ro.observe(previewRef.current); - window.addEventListener('resize', computeLines); + window.addEventListener("resize", computeLines); const interval = setInterval(computeLines, 1200); - return ()=>{ ro.disconnect(); window.removeEventListener('resize', computeLines); clearInterval(interval); }; + return () => { + ro.disconnect(); + window.removeEventListener("resize", computeLines); + clearInterval(interval); + }; } // If ResizeObserver not present (e.g., jsdom), just return cleanup-less or simple interval const interval = setInterval(computeLines, 1200); - window.addEventListener('resize', computeLines); - return () => { window.removeEventListener('resize', computeLines); clearInterval(interval); }; + window.addEventListener("resize", computeLines); + return () => { + window.removeEventListener("resize", computeLines); + clearInterval(interval); + }; }, [participantsList, effectiveRoom]); - return (
{connectError && ( -
-
Error al conectar a LiveKit
+
+
+ Error al conectar a LiveKit +
{connectError}
-
Server: {serverUrl}
-
- - +
+ Server:{" "} + + {serverUrl} + +
+
+ +
)} @@ -290,7 +482,7 @@ export const StudioRoom: React.FC = ({
-

Estudio - {roomName || 'Sin nombre'}

+

Estudio - {roomName || "Sin nombre"}

En vivo @@ -307,12 +499,34 @@ export const StudioRoom: React.FC = ({
-
+
{/* SVG overlay for connection lines */} - - {lines.map((ln,i)=>( - + + {lines.map((ln, i) => ( + ))}
@@ -328,41 +542,60 @@ export const StudioRoom: React.FC = ({ ) : ( -
-
Conectando al estudio...
-
Esperando a que la sesión se establezca. Si esto tarda demasiado, comprueba el token y conexión a LiveKit.
-
- -
-
- )} +
+
+ Conectando al estudio... +
+
+ Esperando a que la sesión se establezca. Si esto tarda demasiado, + comprueba el token y conexión a LiveKit. +
+
+ +
+
+ )}
); }; function VideoConferenceView() { // Defensive: ensure a Room exists in context before calling useTracks (livekit components throw otherwise) - const ctxRoom = React.useContext(RoomContext as any) + const ctxRoom = React.useContext(RoomContext as any); if (!ctxRoom) { // Render a lightweight placeholder while the room is not ready return ( -
+
Conectando streams...
- ) + ); } // Avoid direct dependency on livekit-client Track constants; cast to any to satisfy TS types in this integration layer - const tracks = useTracks(([ - { source: 'camera', withPlaceholder: true }, - { source: 'screen', withPlaceholder: false }, - ] as any), { onlySubscribed: false } as any) + const tracks = useTracks( + [ + { source: "camera", withPlaceholder: true }, + { source: "screen", withPlaceholder: false }, + ] as any, + { onlySubscribed: false } as any, + ); return ( - + ); diff --git a/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx index d6cc801..642488e 100644 --- a/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx +++ b/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx @@ -1,13 +1,13 @@ // @vitest-environment jsdom -import React from 'react' -import { render, screen, waitFor, fireEvent } from '@testing-library/react' -import { vi, describe, it, expect, beforeEach } from 'vitest' +import React from "react"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; // Ensure livekit UI lib is mocked before any module imports that may load it -vi.mock('@livekit/components-react', () => { - const React = require('react'); - const noop = () => React.createElement('div', null); +vi.mock("@livekit/components-react", () => { + const React = require("react"); + const noop = () => React.createElement("div", null); return { GridLayout: noop, ParticipantTile: noop, @@ -21,69 +21,106 @@ vi.mock('@livekit/components-react', () => { usePrefetchRoom: () => ({}), }; }); -vi.mock('@livekit/components-styles', () => ({})); +vi.mock("@livekit/components-styles", () => ({})); // Stub the actual StudioRoom implementation in the studio-panel package to avoid heavy hooks -vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({ - __esModule: true, - default: (props: any) => React.createElement('div', { id: 'studio-room-mock' }) -})) +vi.mock( + "../../../../studio-panel/src/components/StudioRoom/StudioRoom", + () => ({ + __esModule: true, + default: (props: any) => + React.createElement("div", { id: "studio-room-mock" }), + }), +); // Also stub the local re-export module to be safe -vi.mock('../StudioRoom/StudioRoom', () => ({ +vi.mock("../StudioRoom/StudioRoom", () => ({ __esModule: true, - default: (props: any) => React.createElement('div', { id: 'studio-room-mock' }) -})) + default: (props: any) => + React.createElement("div", { id: "studio-room-mock" }), +})); -import StudioPortal from '../StudioPortal' -const livekitMock: any = require('livekit-client'); +import StudioPortal from "../StudioPortal"; +const livekitMock: any = require("livekit-client"); -describe('StudioPortal', () => { +describe("StudioPortal", () => { beforeEach(() => { - vi.clearAllMocks() + vi.clearAllMocks(); // reset instances array - if (livekitMock && livekitMock.__mocks && Array.isArray(livekitMock.__mocks.instances)) { + if ( + livekitMock && + livekitMock.__mocks && + Array.isArray(livekitMock.__mocks.instances) + ) { livekitMock.__mocks.instances.length = 0; } - }) + }); - it('creates a local Room and connects when token is provided and no external room', async () => { - render() + it("creates a local Room and connects when token is provided and no external room", async () => { + render( + , + ); // wait for the connect to be called await waitFor(() => { - expect(livekitMock.__mocks.instances[0]).toBeDefined() - expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://example', 'FAKE_TOKEN') - }) - }) + expect(livekitMock.__mocks.instances[0]).toBeDefined(); + expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith( + "wss://example", + "FAKE_TOKEN", + ); + }); + }); - it('does not create a local Room when external room is provided', async () => { - const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() } - render() + it("does not create a local Room when external room is provided", async () => { + const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() }; + render( + , + ); // local constructor should not be called - await new Promise((r) => setTimeout(r, 50)) - expect(livekitMock.__mocks.instances.length).toBe(0) - }) + await new Promise((r) => setTimeout(r, 50)); + expect(livekitMock.__mocks.instances.length).toBe(0); + }); - it('connect/disconnect buttons call connectWithToken and disconnect', async () => { + it("connect/disconnect buttons call connectWithToken and disconnect", async () => { // render without auto token to test manual connect: pass empty token first - const { rerender } = render() + const { rerender } = render( + , + ); // Click connect button -> nothing happens since token empty, ensure no constructor called - const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { exact: false }) - fireEvent.click(connectBtn) - expect(livekitMock.__mocks.instances.length).toBe(0) + const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { + exact: false, + }); + fireEvent.click(connectBtn); + expect(livekitMock.__mocks.instances.length).toBe(0); // Rerender with token to enable connect via button - rerender() + rerender( + , + ); // Wait for auto connect (effect) or click button to trigger connect - await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined()) + await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined()); // Now test disconnect button triggers disconnect - const disconnectBtn = screen.getByText('Desconectar') - fireEvent.click(disconnectBtn) - await waitFor(() => expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled()) - }) -}) + const disconnectBtn = screen.getByText("Desconectar"); + fireEvent.click(disconnectBtn); + await waitFor(() => + expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled(), + ); + }); +}); diff --git a/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx index cd2f866..98d34cf 100644 --- a/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx +++ b/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx @@ -1,126 +1,181 @@ // @vitest-environment jsdom -import React, { useEffect, useState } from 'react' -import { render, fireEvent, screen, waitFor } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import React, { useEffect, useState } from "react"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import useStudioLauncher from '../../../hooks/useStudioLauncher' +import useStudioLauncher from "../../../hooks/useStudioLauncher"; // Ensure we don't import the heavy StudioRoom implementation that requires ResizeObserver/WebRTC -vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({ +vi.mock( + "../../../../studio-panel/src/components/StudioRoom/StudioRoom", + () => ({ + __esModule: true, + default: (_props: any) => + React.createElement("div", { id: "studio-room-mock" }), + }), +); +vi.mock("../StudioRoom/StudioRoom", () => ({ __esModule: true, - default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' }) -})) -vi.mock('../StudioRoom/StudioRoom', () => ({ - __esModule: true, - default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' }) -})) + default: (_props: any) => + React.createElement("div", { id: "studio-room-mock" }), +})); -import StudioPortal from '../StudioPortal' +import StudioPortal from "../StudioPortal"; // Simple component that exposes openStudio via a button using the hook -function LauncherButton({ room = 'sim-room', username = 'tester' }: { room?: string; username?: string }) { - const { openStudio } = useStudioLauncher() +function LauncherButton({ + room = "sim-room", + username = "tester", +}: { + room?: string; + username?: string; +}) { + const { openStudio } = useStudioLauncher(); return ( - - ) + ); } // App side that listens for LIVEKIT_TOKEN and renders StudioPortal when token arrives function TestApp() { - const [token, setToken] = useState('') - const [serverUrl, setServerUrl] = useState('') + const [token, setToken] = useState(""); + const [serverUrl, setServerUrl] = useState(""); useEffect(() => { function onMessage(e: MessageEvent) { try { - const d = e.data || {} - if (d?.type === 'LIVEKIT_TOKEN' && d.token) { + const d = e.data || {}; + if (d?.type === "LIVEKIT_TOKEN" && d.token) { // set token and optional url - setToken(String(d.token)) - if (d.url) setServerUrl(String(d.url)) + setToken(String(d.token)); + if (d.url) setServerUrl(String(d.url)); // Reply ACK via the message source if available try { - const ack = { type: 'LIVEKIT_ACK', status: 'connected', room: d.room } - if (e.source && typeof (e.source as any).postMessage === 'function') { - try { (e.source as any).postMessage(ack, e.origin || '*') } catch (err) {} + const ack = { + type: "LIVEKIT_ACK", + status: "connected", + room: d.room, + }; + if ( + e.source && + typeof (e.source as any).postMessage === "function" + ) { + try { + (e.source as any).postMessage(ack, e.origin || "*"); + } catch (err) {} } // also post to opener/parent just in case - try { window.postMessage(ack, e.origin || '*') } catch (err) {} + try { + window.postMessage(ack, e.origin || "*"); + } catch (err) {} } catch (err) {} } } catch (err) {} } - window.addEventListener('message', onMessage) - return () => window.removeEventListener('message', onMessage) - }, []) + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); return (
-
{token ? 'token:' + token : 'no-token'}
- {token ? : null} +
{token ? "token:" + token : "no-token"}
+ {token ? ( + + ) : null}
- ) + ); } -describe('E2E simulated flow: Broadcast -> Studio', () => { - let originalOpen: any - let popupMock: any +describe("E2E simulated flow: Broadcast -> Studio", () => { + let originalOpen: any; + let popupMock: any; beforeEach(() => { - vi.clearAllMocks() - originalOpen = (window as any).open + vi.clearAllMocks(); + originalOpen = (window as any).open; // popup mock that simply dispatches message events to window when postMessage is called // make popupMock a callable function object so TS doesn't complain if it's invoked - const pm: any = function() { /* noop callable */ }; - pm.location = { href: '' }; + const pm: any = function () { + /* noop callable */ + }; + pm.location = { href: "" }; pm.closed = false; pm.postMessage = (message: any, targetOrigin: string) => { // simulate asynchronous arrival in the popup (studio) setTimeout(() => { // Message arrives to the studio (which in our test is the same window) - const ev = new MessageEvent('message', { data: message, origin: targetOrigin, source: pm }) - window.dispatchEvent(ev) - }, 20) + const ev = new MessageEvent("message", { + data: message, + origin: targetOrigin, + source: pm, + }); + window.dispatchEvent(ev); + }, 20); }; - popupMock = pm - - // Replace window.open to return our popup mock - (window as any).open = vi.fn(() => popupMock) + popupMock = pm( + // Replace window.open to return our popup mock + window as any, + ).open = vi.fn(() => popupMock); // mock fetch to token server to return a session with token and studioUrl - globalThis.fetch = vi.fn(() => Promise.resolve({ - ok: true, - json: () => Promise.resolve({ token: 'SIM_TOKEN', room: 'sim-room', studioUrl: window.location.origin + '/studio', url: 'wss://livekit-server.example' }) - })) as any - }) + globalThis.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + token: "SIM_TOKEN", + room: "sim-room", + studioUrl: window.location.origin + "/studio", + url: "wss://livekit-server.example", + }), + }), + ) as any; + }); afterEach(() => { - (window as any).open = originalOpen + (window as any).open = originalOpen; // @ts-ignore - globalThis.fetch = undefined - }) + globalThis.fetch = undefined; + }); - it('opens popup, sends token, studio receives it and StudioPortal connects (mock Room)', async () => { + it("opens popup, sends token, studio receives it and StudioPortal connects (mock Room)", async () => { // render both launcher and app render(
-
- ) +
, + ); // click launcher which calls openStudio -> will call fetch and popup.postMessage repeatedly - const btn = screen.getByTestId('open-studio') - fireEvent.click(btn) + const btn = screen.getByTestId("open-studio"); + fireEvent.click(btn); // Wait for TestApp to receive token and render StudioPortal status - await waitFor(() => expect(screen.getByTestId('status').textContent).toContain('SIM_TOKEN'), { timeout: 2000 }) + await waitFor( + () => + expect(screen.getByTestId("status").textContent).toContain("SIM_TOKEN"), + { timeout: 2000 }, + ); // Now assert that the mocked livekit Room was instantiated and connect called. // The project-level vitest setup provides a mock for 'livekit-client' with __mocks.instances - const livekitMock: any = require('livekit-client') - await waitFor(() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0), { timeout: 2000 }) - expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://livekit-server.example', 'SIM_TOKEN') - }) -}) + const livekitMock: any = require("livekit-client"); + await waitFor( + () => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0), + { timeout: 2000 }, + ); + expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith( + "wss://livekit-server.example", + "SIM_TOKEN", + ); + }); +}); diff --git a/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts b/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts index 78a6d25..74bbdf0 100644 --- a/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts +++ b/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts @@ -1,11 +1,14 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; // load the postMessage util from broadcast-panel -import * as pm from '../../../utils/postMessage'; +import * as pm from "../../../utils/postMessage"; -describe('postMessage utils', () => { - it('exposes environment parsing helpers', () => { +describe("postMessage utils", () => { + it("exposes environment parsing helpers", () => { // basic existence - expect(typeof pm.getAllowedOriginsFromEnv === 'function' || typeof pm.isAllowedOrigin === 'function').toBeTruthy(); - }) -}) + expect( + typeof pm.getAllowedOriginsFromEnv === "function" || + typeof pm.isAllowedOrigin === "function", + ).toBeTruthy(); + }); +}); diff --git a/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx index 0d31f72..6305d20 100644 --- a/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx +++ b/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx @@ -1,32 +1,40 @@ // @vitest-environment jsdom -import React from 'react' -import { render, waitFor } from '@testing-library/react' -import { vi, describe, it, expect, beforeEach } from 'vitest' +import React from "react"; +import { render, waitFor } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; // Stub Studio implementation to a minimal status element to avoid heavy dependencies -vi.mock('../../../components/Studio', () => ({ +vi.mock("../../../components/Studio", () => ({ __esModule: true, - default: () => React.createElement('div', { id: 'status' }, 'Conectado') -})) + default: () => React.createElement("div", { id: "status" }, "Conectado"), +})); -import Studio from '../../../components/Studio' +import Studio from "../../../components/Studio"; -describe('smoke test - Broadcast Studio integration', () => { +describe("smoke test - Broadcast Studio integration", () => { beforeEach(() => { - vi.clearAllMocks() - document.body.innerHTML = '' - }) + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); - it('renders Studio and auto connects via proxy token', async () => { + it("renders Studio and auto connects via proxy token", async () => { // Mock fetch to return token - (globalThis as any).fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ token: 'SMOKE', url: 'wss://example' }) })) + (globalThis as any).fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ token: "SMOKE", url: "wss://example" }), + }), + ); - render() + render(); - await waitFor(() => { - const el = document.getElementById('status') - expect(el).toBeTruthy() - }, { timeout: 3000 }) - }) -}) + await waitFor( + () => { + const el = document.getElementById("status"); + expect(el).toBeTruthy(); + }, + { timeout: 3000 }, + ); + }); +}); diff --git a/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx b/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx index 579f8af..a00ba36 100644 --- a/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx +++ b/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx @@ -1,11 +1,22 @@ -import React from 'react' +import React from "react"; -export default function IconCameraOn(){ +export default function IconCameraOn() { return ( - - - + + + - ) + ); } - diff --git a/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx b/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx index 603c3ab..c725977 100644 --- a/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx +++ b/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx @@ -1,12 +1,35 @@ -import React from 'react' +import React from "react"; -export default function IconMicOff(){ +export default function IconMicOff() { return ( - - - - + + + + - ) + ); } - diff --git a/packages/broadcast-panel/src/features/studio/index.ts b/packages/broadcast-panel/src/features/studio/index.ts index e217cbc..38953a1 100644 --- a/packages/broadcast-panel/src/features/studio/index.ts +++ b/packages/broadcast-panel/src/features/studio/index.ts @@ -1,3 +1,2 @@ -export { default as StudioPortal } from './StudioPortal'; -export { default as StudioRoom } from './StudioRoom'; - +export { default as StudioPortal } from "./StudioPortal"; +export { default as StudioRoom } from "./StudioRoom"; diff --git a/packages/broadcast-panel/src/hooks/index.ts b/packages/broadcast-panel/src/hooks/index.ts new file mode 100644 index 0000000..fa4ecd9 --- /dev/null +++ b/packages/broadcast-panel/src/hooks/index.ts @@ -0,0 +1,6 @@ +// Barrel exports for hooks +export * from "./useStudioSession"; +export { default as useStudioLauncher } from "./useStudioLauncher"; +export { default as useStudioMessageListener } from "./useStudioMessageListener"; +export { useToast, ToastProvider } from "./useToast"; +// Note: `useLayouts.ts` currently has no exports; add it here once it exports symbols diff --git a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts index a07abb2..3bd20ff 100644 --- a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts +++ b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts @@ -1,233 +1,505 @@ -import { useState } from 'react' +import { useState } from "react"; export type OpenStudioOptions = { - room: string - username: string - ttl?: number -} + room: string; + username: string; + ttl?: number; +}; type SessionData = { - id?: string - studioUrl?: string - redirectUrl?: string - url?: string - token?: string - room?: string - ttl?: number -} + id?: string; + studioUrl?: string; + redirectUrl?: string; + url?: string; + token?: string; + room?: string; + ttl?: number; +}; export default function useStudioLauncher() { - const [loadingId, setLoadingId] = useState(null) - const [error, setError] = useState(null) + const [loadingId, setLoadingId] = useState(null); + const [error, setError] = useState(null); async function openStudio(opts: OpenStudioOptions) { - const { room, username, ttl } = opts + const { room, username, ttl } = opts; if (!room || !username) { - setError('room and username are required') - return null + setError("room and username are required"); + return null; } - setError(null) - setLoadingId(room) + setError(null); + setLoadingId(room); // Timeouts and retry config - const POST_MESSAGE_TIMEOUT = 5000 // ms - const POST_MESSAGE_INTERVAL = 300 // ms + const POST_MESSAGE_TIMEOUT = 5000; // ms + const POST_MESSAGE_INTERVAL = 300; // ms try { - // Prefer explicit backend API URL (VITE_BACKEND_API_URL) then legacy VITE_TOKEN_SERVER_URL, fallback to known host - const TOKEN_SERVER = (import.meta.env.VITE_BACKEND_API_URL as string) || (import.meta.env.VITE_TOKEN_SERVER_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' - const absoluteSessionUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` - const relativeSessionUrl = '/api/session' + // Prefer VITE_BACKEND_API_URL, then VITE_TOKEN_SERVER_URL, otherwise default to the public backend domain + const TOKEN_SERVER = + (import.meta.env.VITE_BACKEND_API_URL as string) || + (import.meta.env.VITE_TOKEN_SERVER_URL as string) || + "https://avanzacast-backend.bfzqqk.easypanel.host"; + const absoluteSessionUrl = `${TOKEN_SERVER.replace(/\/$/, "")}/api/session`; + const relativeSessionUrl = "/api/session"; + // Optionally use connection-details endpoint to get participantToken + serverUrl (like Meet) + const USE_CONN_DETAILS = + import.meta.env.VITE_USE_CONNECTION_DETAILS === "1" || + import.meta.env.VITE_USE_CONNECTION_DETAILS === "true" || + false; + const absoluteConnDetails = `${TOKEN_SERVER.replace(/\/$/, "")}/api/connection-details`; // Check if the app is running in integrated mode (Studio is a feature inside Broadcast Panel) - const INTEGRATED = (import.meta.env.VITE_STUDIO_INTEGRATED === 'true' || import.meta.env.VITE_STUDIO_INTEGRATED === '1') || false + const INTEGRATED = + import.meta.env.VITE_STUDIO_INTEGRATED === "true" || + import.meta.env.VITE_STUDIO_INTEGRATED === "1" || + false; // Helper to POST to a URL and return parsed JSON or null async function postSession(url: string) { try { const r = await fetch(url, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ room, username, ttl }) - }) - const txt = await r.text().catch(() => '') + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room, username, ttl }), + }); + const txt = await r.text().catch(() => ""); // If not ok, return object with status info - if (!r.ok) return { ok: false, status: r.status, body: txt } + if (!r.ok) return { ok: false, status: r.status, body: txt }; // parse JSON if possible - try { return { ok: true, json: JSON.parse(txt) } } catch (e) { return { ok: false, status: r.status, body: txt } } - } catch (err) { return { ok: false, error: String(err) } } + try { + return { ok: true, json: JSON.parse(txt) }; + } catch (e) { + return { ok: false, status: r.status, body: txt }; + } + } catch (err) { + return { ok: false, error: String(err) }; + } + } + + // Helper to GET connection-details + async function getConnectionDetails(url: string) { + try { + const u = new URL(url); + u.searchParams.set("roomName", room); + u.searchParams.set("participantName", username); + const r = await fetch(u.toString(), { method: "GET" }); + const txt = await r.text().catch(() => ""); + if (!r.ok) return { ok: false, status: r.status, body: txt }; + try { + return { ok: true, json: JSON.parse(txt) }; + } catch (e) { + return { ok: false, status: r.status, body: txt }; + } + } catch (err) { + return { ok: false, error: String(err) }; + } } // If integrated mode is enabled, fetch session data to be used inside the SPA if (INTEGRATED) { + // If configured, prefer connection-details to obtain participantToken/serverUrl (meet-style) + if (USE_CONN_DETAILS) { + let cd = await getConnectionDetails(absoluteConnDetails); + if (!cd.ok) { + // fall back to session creation + const sr = await postSession(absoluteSessionUrl); + if (!sr.ok) { + const attempted = sr.error ? sr.error : sr.body || "no body"; + const hint = `Intentado: ${absoluteSessionUrl}. Posibles causas: backend no accesible, CORS.`; + const msg = `No se pudo crear la sesión (${sr.status || "err"}) ${attempted} — ${hint}`; + console.error("[useStudioLauncher]", msg, { + sr, + sessionUrl: absoluteSessionUrl, + }); + setError(msg); + setLoadingId(null); + return null; + } + const sessionData: SessionData = sr.json; + try { + console.debug( + "[useStudioLauncher] sessionData (integrated, fallback session)", + sessionData, + ); + } catch (e) { + /* ignore */ + } + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem(storeKey, JSON.stringify(sessionData)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: sessionData }), + ); + } catch (e) {} + setLoadingId(null); + return sessionData; + } + const data = cd.json; + const sessionData: SessionData = { + token: data.participantToken, + url: data.serverUrl, + room: data.roomName, + studioUrl: undefined, + id: undefined, + redirectUrl: undefined, + ttl: undefined, + }; + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem(storeKey, JSON.stringify(sessionData)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: sessionData }), + ); + } catch (e) {} + setLoadingId(null); + return sessionData; + } // In integrated mode (Studio inside the same SPA) prefer backend API (absolute) first - let sr = await postSession(absoluteSessionUrl) - if (!sr.ok) sr = await postSession(relativeSessionUrl) + let sr = await postSession(absoluteSessionUrl); + if (!sr.ok) sr = await postSession(relativeSessionUrl); if (!sr.ok) { - const attempted = sr.error ? sr.error : (sr.body || 'no body') - const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'` - const msg = `No se pudo crear la sesión (${sr.status || 'err'}) ${attempted} — ${hint}` - console.error('[useStudioLauncher]', msg, { sr, sessionUrl: absoluteSessionUrl }) - setError(msg) - setLoadingId(null) - return null + const attempted = sr.error ? sr.error : sr.body || "no body"; + const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'`; + const msg = `No se pudo crear la sesión (${sr.status || "err"}) ${attempted} — ${hint}`; + console.error("[useStudioLauncher]", msg, { + sr, + sessionUrl: absoluteSessionUrl, + }); + setError(msg); + setLoadingId(null); + return null; + } + const sessionData: SessionData = sr.json; + try { + console.debug( + "[useStudioLauncher] sessionData (integrated)", + sessionData, + ); + } catch (e) { + /* ignore */ } - const sessionData: SessionData = sr.json - try { console.debug('[useStudioLauncher] sessionData (integrated)', sessionData) } catch (e) { /* ignore */ } // Store session data in sessionStorage so the integrated StudioPortal component can pick it up // and dispatch an event so the StudioPortal can react immediately. try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - const payload = JSON.stringify(sessionData) - sessionStorage.setItem(storeKey, payload) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: sessionData })) } catch (e) { /* ignore */ } - console.debug('[useStudioLauncher] sessionData stored in sessionStorage key=', storeKey) + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + const payload = JSON.stringify(sessionData); + sessionStorage.setItem(storeKey, payload); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: sessionData }), + ); + } catch (e) { + /* ignore */ + } + console.debug( + "[useStudioLauncher] sessionData stored in sessionStorage key=", + storeKey, + ); } catch (e) { - console.warn('[useStudioLauncher] failed to write sessionStorage', e) + console.warn("[useStudioLauncher] failed to write sessionStorage", e); + } + setLoadingId(null); + return sessionData; + } + + // If not integrated and USE_CONN_DETAILS requested, attempt to get connection-details for popup flow + if (USE_CONN_DETAILS) { + const cd = await getConnectionDetails(absoluteConnDetails); + if (!cd.ok) { + // fall back to existing session creation flow below + console.warn( + "[useStudioLauncher] connection-details failed, falling back to session", + cd, + ); + } else { + const data = cd.json; + const sessionData: SessionData = { + token: data.participantToken, + url: data.serverUrl, + room: data.roomName, + studioUrl: undefined, + id: undefined, + redirectUrl: undefined, + ttl: undefined, + }; + try { + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem(storeKey, JSON.stringify(sessionData)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: sessionData }), + ); + } catch (e) {} + } catch (e) {} + // proceed to popup flow using sessionData + // Try to open a blank popup immediately (in direct response to user action) to reduce popup-blocker issues + let popup: Window | null = null; + try { + popup = window.open("about:blank", "_blank", "noopener,noreferrer"); + } catch (e) { + popup = null; + } + // build targetUrl same as before + const BROADCAST_BASE = + (import.meta.env.VITE_BROADCASTPANEL_URL as string) || + "https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"; + // use friendly path /studio/:id + const broadcastPanelUrl = sessionData.id + ? `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${encodeURIComponent(sessionData.id)}` + : ""; + const targetUrl = + broadcastPanelUrl || + sessionData.studioUrl || + sessionData.redirectUrl || + (sessionData as any).url || + ""; + if (!popup) { + setLoadingId(null); + return sessionData; + } + if (targetUrl) { + try { + popup.location.href = targetUrl; + } catch (e) { + try { + popup.location.assign(targetUrl); + } catch (e2) { + /* ignore */ + } + } + } + const targetOrigin = (() => { + try { + return new URL(targetUrl).origin; + } catch (e) { + return "*"; + } + })(); + const msgPayload = { + type: "LIVEKIT_TOKEN", + token: sessionData.token, + room: sessionData.room, + url: (sessionData as any).url, + }; + try { + popup.postMessage(msgPayload, targetOrigin); + } catch (e) { + /* ignore */ + } + setLoadingId(null); + return sessionData; } - setLoadingId(null) - return sessionData } // Try to open a blank popup immediately (in direct response to user action) to reduce popup-blocker issues - let popup: Window | null = null + let popup: Window | null = null; try { - popup = window.open('about:blank', '_blank', 'noopener,noreferrer') + popup = window.open("about:blank", "_blank", "noopener,noreferrer"); } catch (e) { - popup = null + popup = null; } // If popup failed to open, we will fallback to redirect later // For popup flow prefer backend absolute API (may be on different host) then fallback to same-origin - let res = await postSession(absoluteSessionUrl) - if (!res.ok) res = await postSession(relativeSessionUrl) + let res = await postSession(absoluteSessionUrl); + if (!res.ok) res = await postSession(relativeSessionUrl); if (!res.ok) { - const attempted = res.error ? res.error : (res.body || 'no body') - const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'` - const msg = `No se pudo crear la sesión (${res.status || 'err'}) ${attempted} — ${hint}` - console.error('[useStudioLauncher]', msg, { res, sessionUrl: absoluteSessionUrl }) - setError(msg) - setLoadingId(null) - try { popup?.close() } catch (e) { /* ignore */ } - return null + const attempted = res.error ? res.error : res.body || "no body"; + const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'`; + const msg = `No se pudo crear la sesión (${res.status || "err"}) ${attempted} — ${hint}`; + console.error("[useStudioLauncher]", msg, { + res, + sessionUrl: absoluteSessionUrl, + }); + setError(msg); + setLoadingId(null); + try { + popup?.close(); + } catch (e) { + /* ignore */ + } + return null; } - const sessionData: SessionData = res.json - try { console.debug('[useStudioLauncher] sessionData', sessionData) } catch (e) { /* ignore */ } + const sessionData: SessionData = res.json; + try { + console.debug("[useStudioLauncher] sessionData", sessionData); + } catch (e) { + /* ignore */ + } // If the popup failed to open but we still have sessionData, store it locally so the user can continue try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - const payload = JSON.stringify(sessionData) - sessionStorage.setItem(storeKey, payload) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: sessionData })) } catch (e) { /* ignore */ } - console.debug('[useStudioLauncher] sessionData cached in sessionStorage key=', storeKey) + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + const payload = JSON.stringify(sessionData); + sessionStorage.setItem(storeKey, payload); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: sessionData }), + ); + } catch (e) { + /* ignore */ + } + console.debug( + "[useStudioLauncher] sessionData cached in sessionStorage key=", + storeKey, + ); } catch (e) { /* ignore storage errors */ } // Build targetUrl: prefer broadcast-panel route /:id so the Broadcast Panel path contains the session id - const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '') - const broadcastPanelUrl = sessionData.id ? `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(sessionData.id)}` : '' + const BROADCAST_BASE = + (import.meta.env.VITE_BROADCASTPANEL_URL as string) || + "https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"; + // use friendly path /studio/:id + const broadcastPanelUrl = sessionData.id + ? `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${encodeURIComponent(sessionData.id)}` + : ""; // Keep original fallback order but prefer broadcastPanelUrl when available - const targetUrl = broadcastPanelUrl || sessionData.studioUrl || sessionData.redirectUrl || (sessionData as any).url || '' + const targetUrl = + broadcastPanelUrl || + sessionData.studioUrl || + sessionData.redirectUrl || + (sessionData as any).url || + ""; if (!popup) { - setLoadingId(null) - return sessionData + setLoadingId(null); + return sessionData; } if (targetUrl) { try { - popup.location.href = targetUrl + popup.location.href = targetUrl; } catch (e) { - try { popup.location.assign(targetUrl) } catch (e2) { /* ignore */ } + try { + popup.location.assign(targetUrl); + } catch (e2) { + /* ignore */ + } } } const targetOrigin = (() => { - try { return new URL(targetUrl).origin } catch (e) { return '*' } - })() + try { + return new URL(targetUrl).origin; + } catch (e) { + return "*"; + } + })(); async function waitForPopupReady(timeout = 1500) { return new Promise((resolve) => { - let resolved = false + let resolved = false; const onMsg = (e: MessageEvent) => { try { - const d = e.data || {} - if (d?.type === 'LIVEKIT_READY') { - resolved = true - window.removeEventListener('message', onMsg) - resolve(true) + const d = e.data || {}; + if (d?.type === "LIVEKIT_READY") { + resolved = true; + window.removeEventListener("message", onMsg); + resolve(true); } - } catch (err) { /* ignore */ } - } - window.addEventListener('message', onMsg) - try { popup?.postMessage({ type: 'LIVEKIT_PING' }, targetOrigin) } catch (e) {} + } catch (err) { + /* ignore */ + } + }; + window.addEventListener("message", onMsg); + try { + popup?.postMessage({ type: "LIVEKIT_PING" }, targetOrigin); + } catch (e) {} setTimeout(() => { if (!resolved) { - window.removeEventListener('message', onMsg) - resolve(false) + window.removeEventListener("message", onMsg); + resolve(false); } - }, timeout) - }) + }, timeout); + }); } - const msgPayload = { type: 'LIVEKIT_TOKEN', token: sessionData.token, room: sessionData.room, url: (sessionData as any).url } + const msgPayload = { + type: "LIVEKIT_TOKEN", + token: sessionData.token, + room: sessionData.room, + url: (sessionData as any).url, + }; - let ackReceived = false + let ackReceived = false; function onMessage(e: MessageEvent) { try { - const d = e.data || {} - if (d?.type === 'LIVEKIT_ACK' && d?.room === sessionData.room) { - ackReceived = true - window.removeEventListener('message', onMessage) + const d = e.data || {}; + if (d?.type === "LIVEKIT_ACK" && d?.room === sessionData.room) { + ackReceived = true; + window.removeEventListener("message", onMessage); } } catch (err) { // ignore malformed messages } } - window.addEventListener('message', onMessage) + window.addEventListener("message", onMessage); - const popupReady = await waitForPopupReady(1200) + const popupReady = await waitForPopupReady(1200); if (popupReady) { - try { popup?.postMessage(msgPayload, targetOrigin) } catch (e) { /* ignore */ } - const ackWaitStart = Date.now() + try { + popup?.postMessage(msgPayload, targetOrigin); + } catch (e) { + /* ignore */ + } + const ackWaitStart = Date.now(); while (Date.now() - ackWaitStart < 1200 && !ackReceived) { - await new Promise((r) => setTimeout(r, 100)) + await new Promise((r) => setTimeout(r, 100)); } } - const start = Date.now() + const start = Date.now(); while (Date.now() - start < POST_MESSAGE_TIMEOUT && !ackReceived) { try { - try { popup?.postMessage(msgPayload, targetOrigin) } catch (e) { /* ignore cross-origin errors */ } + try { + popup?.postMessage(msgPayload, targetOrigin); + } catch (e) { + /* ignore cross-origin errors */ + } } catch (e) { // ignore } - await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL)) + await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL)); } if (!ackReceived && sessionData.redirectUrl) { try { - popup.location.href = sessionData.redirectUrl + popup.location.href = sessionData.redirectUrl; } catch (e) { - try { window.location.href = sessionData.redirectUrl } catch (e2) { /* ignore */ } + try { + window.location.href = sessionData.redirectUrl; + } catch (e2) { + /* ignore */ + } } } // finished - setLoadingId(null) - return sessionData + setLoadingId(null); + return sessionData; } catch (err: any) { - console.error('[useStudioLauncher] error opening studio', err) - setError(String(err?.message || err)) - setLoadingId(null) - return null + console.error("[useStudioLauncher] error opening studio", err); + setError(String(err?.message || err)); + setLoadingId(null); + return null; } } - return { openStudio, loadingId, error } + return { openStudio, loadingId, error }; } diff --git a/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts b/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts index be4a407..0b6226f 100644 --- a/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts +++ b/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts @@ -1,33 +1,48 @@ -import { useEffect } from 'react' +import { useEffect } from "react"; export type LivekitMessage = { - type: 'LIVEKIT_TOKEN' - token?: string - room?: string - url?: string -} + type: "LIVEKIT_TOKEN"; + token?: string; + room?: string; + url?: string; +}; -export default function useStudioMessageListener(onReceive: (msg: LivekitMessage) => void) { +export default function useStudioMessageListener( + onReceive: (msg: LivekitMessage) => void, +) { useEffect(() => { // Build allowed origins list from env or default to current origin - const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS || import.meta.env.VITE_BROADCASTPANEL_URL || '') as string - const allowedOrigins = envAllowed ? envAllowed.split(',').map(s => s.trim()) : [window.location.origin] + const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS || + import.meta.env.VITE_BROADCASTPANEL_URL || + "") as string; + const allowedOrigins = envAllowed + ? envAllowed.split(",").map((s) => s.trim()) + : [window.location.origin]; function handler(e: MessageEvent) { try { // Validate origin: allow same-window messages (origin may equal window.location.origin) - const origin = e.origin || window.location.origin - if (!allowedOrigins.includes(origin) && origin !== window.location.origin) return + const origin = e.origin || window.location.origin; + if ( + !allowedOrigins.includes(origin) && + origin !== window.location.origin + ) + return; - const data = e.data || {} - if (data && data.type === 'LIVEKIT_TOKEN') { - onReceive({ type: 'LIVEKIT_TOKEN', token: data.token, room: data.room, url: data.url }) + const data = e.data || {}; + if (data && data.type === "LIVEKIT_TOKEN") { + onReceive({ + type: "LIVEKIT_TOKEN", + token: data.token, + room: data.room, + url: data.url, + }); } } catch (err) { // ignore malformed messages } } - window.addEventListener('message', handler) - return () => window.removeEventListener('message', handler) - }, [onReceive]) + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [onReceive]); } diff --git a/packages/broadcast-panel/src/hooks/useStudioSession.ts b/packages/broadcast-panel/src/hooks/useStudioSession.ts index 5266686..25c20fb 100644 --- a/packages/broadcast-panel/src/hooks/useStudioSession.ts +++ b/packages/broadcast-panel/src/hooks/useStudioSession.ts @@ -2,7 +2,7 @@ // - GET /api/session/:id/token // - Returns { token, url, room, username } or error -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; export type StudioSession = { token: string; @@ -24,17 +24,28 @@ export function useStudioSession(sessionId?: string | null) { setLoading(true); setError(null); try { - const base = import.meta.env.VITE_BACKEND_TOKENS_URL || import.meta.env.VITE_BROADCASTPANEL_URL || ''; + const base = + import.meta.env.VITE_BACKEND_TOKENS_URL || + import.meta.env.VITE_BROADCASTPANEL_URL || + ""; // Prefer absolute backend URL env var; else assume same origin + port 4000 - const backend = import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`; - const url = `${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/token`; + const backend = + import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`; + const url = `${backend.replace(/\/$/, "")}/api/session/${encodeURIComponent(sessionId)}/token`; // Helpful debug information for E2E and development - console.debug('[useStudioSession] fetching token from', url, { sessionId, backend, envBase: base }); + console.debug("[useStudioSession] fetching token from", url, { + sessionId, + backend, + envBase: base, + }); - const resp = await fetch(url, { method: 'GET', credentials: 'include' }); + const resp = await fetch(url, { + method: "GET", + credentials: "include", + }); if (!resp.ok) { - const txt = await resp.text().catch(() => '()'); + const txt = await resp.text().catch(() => "()"); const msg = `fetch failed status=${resp.status} body=${txt}`; throw new Error(msg); } @@ -45,10 +56,17 @@ export function useStudioSession(sessionId?: string | null) { // Enriquecer el mensaje para ayudar a diagnosticar 'Failed to fetch' let enriched = String(err?.message || err); // If it's a network error (TypeError: Failed to fetch) give common causes hints - if (enriched.includes('Failed to fetch') || enriched.includes('NetworkError') || enriched.includes('TypeError')) { + if ( + enriched.includes("Failed to fetch") || + enriched.includes("NetworkError") || + enriched.includes("TypeError") + ) { enriched = `${enriched} — posible causa: backend no accesible, CORS bloqueando la petición, mixed-content (https página -> http backend), o error de red. Comprueba que VITE_BACKEND_TOKENS_URL apunta al backend correcto y que el backend está levantado en esa URL.`; } - console.error('[useStudioSession] error fetching token', { sessionId, error: enriched }); + console.error("[useStudioSession] error fetching token", { + sessionId, + error: enriched, + }); setError(enriched); } } finally { @@ -56,7 +74,9 @@ export function useStudioSession(sessionId?: string | null) { } } fetchToken(); - return () => { aborted = true }; + return () => { + aborted = true; + }; }, [sessionId]); return { loading, data, error }; diff --git a/packages/broadcast-panel/src/hooks/useToast.tsx b/packages/broadcast-panel/src/hooks/useToast.tsx index f32b9c0..4ee0789 100644 --- a/packages/broadcast-panel/src/hooks/useToast.tsx +++ b/packages/broadcast-panel/src/hooks/useToast.tsx @@ -1,44 +1,63 @@ -import React, { createContext, useContext, useState, useEffect } from 'react' -import Toast from '../components/Toast' +import React, { createContext, useContext, useState, useEffect } from "react"; +import Toast from "../components/Toast"; -type ToastItem = { id: string, message: string, variant?: 'info'|'success'|'error'|'warning' } +type ToastItem = { + id: string; + message: string; + variant?: "info" | "success" | "error" | "warning"; +}; -const ToastContext = createContext<{ show: (m: string, v?: ToastItem['variant']) => void } | undefined>(undefined) +const ToastContext = createContext< + { show: (m: string, v?: ToastItem["variant"]) => void } | undefined +>(undefined); export function ToastProvider({ children }: { children: React.ReactNode }) { - const [list, setList] = useState([]) + const [list, setList] = useState([]); - function show(message: string, variant: ToastItem['variant'] = 'info') { - const id = Math.random().toString(36).slice(2,9) - setList(l => [...l, { id, message, variant }]) - setTimeout(() => setList(l => l.filter(x => x.id !== id)), 5000) + function show(message: string, variant: ToastItem["variant"] = "info") { + const id = Math.random().toString(36).slice(2, 9); + setList((l) => [...l, { id, message, variant }]); + setTimeout(() => setList((l) => l.filter((x) => x.id !== id)), 5000); } useEffect(() => { function onGlobal(e: Event) { try { - const ce = e as CustomEvent - if (!ce?.detail) return - const { message, variant } = ce.detail as any - if (message) show(message, variant) + const ce = e as CustomEvent; + if (!ce?.detail) return; + const { message, variant } = ce.detail as any; + if (message) show(message, variant); } catch (err) {} } - window.addEventListener('AVZ_TOAST', onGlobal as EventListener) - return () => window.removeEventListener('AVZ_TOAST', onGlobal as EventListener) - }, []) + window.addEventListener("AVZ_TOAST", onGlobal as EventListener); + return () => + window.removeEventListener("AVZ_TOAST", onGlobal as EventListener); + }, []); return ( {children} -
- {list.map(i => )} +
+ {list.map((i) => ( + + ))}
- ) + ); } export function useToast() { - const ctx = useContext(ToastContext) - if (!ctx) throw new Error('useToast must be used within ToastProvider') - return ctx + const ctx = useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within ToastProvider"); + return ctx; } diff --git a/packages/broadcast-panel/src/index.ts b/packages/broadcast-panel/src/index.ts index 6d440cf..40be66c 100644 --- a/packages/broadcast-panel/src/index.ts +++ b/packages/broadcast-panel/src/index.ts @@ -1,4 +1,4 @@ -export { default as PageContainer } from './components/PageContainer' -export { default as Sidebar } from './components/Sidebar' -export { default as Header } from './components/Header' -export { default as TransmissionsTable } from './components/TransmissionsTable' +export { default as PageContainer } from "./components/PageContainer"; +export { default as Sidebar } from "./components/Sidebar"; +export { default as Header } from "./components/Header"; +export { default as TransmissionsTable } from "./components/TransmissionsTable"; diff --git a/packages/broadcast-panel/src/lib/client-utils.ts b/packages/broadcast-panel/src/lib/client-utils.ts new file mode 100644 index 0000000..adf01d6 --- /dev/null +++ b/packages/broadcast-panel/src/lib/client-utils.ts @@ -0,0 +1,17 @@ +export function encodePassphrase(passphrase: string) { + return encodeURIComponent(passphrase); +} + +export function decodePassphrase(base64String: string) { + return decodeURIComponent(base64String); +} + +export function randomString(length: number): string { + let result = ""; + const characters = "abcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} diff --git a/packages/broadcast-panel/src/main.tsx b/packages/broadcast-panel/src/main.tsx index c6d3a42..61ca014 100644 --- a/packages/broadcast-panel/src/main.tsx +++ b/packages/broadcast-panel/src/main.tsx @@ -1,197 +1,394 @@ -import React from 'react' -import { createRoot } from 'react-dom/client' -import PageContainer from './components/PageContainer' -import './styles.css' -import { ToastProvider } from './hooks/useToast' -import StudioPortal from './features/studio/StudioPortal' +import React from "react"; +import { createRoot } from "react-dom/client"; +import PageContainer from "./components/PageContainer"; +import "./styles.css"; +import { ToastProvider } from "./hooks/useToast"; +import StudioPortal from "./features/studio/StudioPortal"; function SessionLoader({ sessionId }: { sessionId: string }) { - const [state, setState] = React.useState<{ status: 'loading' | 'ready' | 'missing' | 'error'; token?: string; url?: string; err?: string }>({ status: 'loading' }) + const [state, setState] = React.useState<{ + status: "loading" | "ready" | "missing" | "error"; + token?: string; + url?: string; + err?: string; + }>({ status: "loading" }); React.useEffect(() => { - let cancelled = false - ;(async () => { + let cancelled = false; + (async () => { try { - // First try relative endpoint (same-origin) - const relUrl = `/api/session/${encodeURIComponent(sessionId)}` - let resp = null + // First: check for session map with publicId key (so we can redirect to /studio/:publicId) try { - resp = await fetch(relUrl) + const mapKey = + (import.meta.env.VITE_STUDIO_SESSION_MAP_KEY as string) || + "avanzacast_studio_session_map"; + const rawMap = sessionStorage.getItem(mapKey); + if (rawMap) { + const map = JSON.parse(rawMap); + const entry = map[sessionId]; + if (entry) { + // If token available in entry, use it immediately + const token = + entry.token || entry.participantToken || entry.token; + const url = entry.serverUrl || entry.url || entry.serverUrl; + if (!cancelled) { + try { + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem( + storeKey, + JSON.stringify({ + token, + url, + room: entry.room || entry.roomName || "", + }), + ); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { + detail: { token, url, room: entry.room }, + }), + ); + } catch (e) {} + } catch (e) {} + setState({ + status: token ? "ready" : "loading", + token: token || undefined, + url: url || undefined, + }); + } + if (token) return; + // if no token present, fallthrough to token server fetch below + } + } + } catch (e) { + /* ignore map parsing errors */ + } + + // First try relative endpoint (same-origin) - prefer token endpoint for direct token fetch + const relUrl = `/api/session/${encodeURIComponent(sessionId)}/token`; + let resp = null; + try { + resp = await fetch(relUrl); } catch (err) { - console.warn('[SessionLoader] relative fetch failed, will try token server absolute URL', err) - resp = null + console.warn( + "[SessionLoader] relative token fetch failed, will try token server absolute URL", + err, + ); + resp = null; } // If relative response exists and is ok, try to parse - let json: any = null + let json: any = null; if (resp && resp.ok) { - const text = await resp.text() - try { json = JSON.parse(text) } catch (e) { json = null } + const text = await resp.text(); + try { + json = JSON.parse(text); + } catch (e) { + json = null; + } if (json) { if (!cancelled) { // store session in sessionStorage so StudioPortal or embedded viewers can pick it up try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - const payload = JSON.stringify(json) - sessionStorage.setItem(storeKey, payload) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: json })) } catch(e){} - console.debug('[SessionLoader] stored session in sessionStorage key=', storeKey) - } catch(e) { console.warn('[SessionLoader] failed to write sessionStorage', e) } - setState({ status: 'ready', token: json.token, url: json.url }) + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + // normalize payload to include token & url if using /token endpoint + const payload = JSON.stringify(json); + sessionStorage.setItem(storeKey, payload); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: json }), + ); + } catch (e) {} + console.debug( + "[SessionLoader] stored session in sessionStorage key=", + storeKey, + ); + } catch (e) { + console.warn( + "[SessionLoader] failed to write sessionStorage", + e, + ); + } + // json from /token endpoint may be { token, ttlSeconds, room, username, url } + setState({ + status: "ready", + token: json.token || (json as any).token, + url: json.url || (json as any).url, + }); } - return + return; } // If parsing failed but resp.ok, fallthrough to absolute URL - console.warn('[SessionLoader] relative response not JSON, will try token server absolute URL') + console.warn( + "[SessionLoader] relative response not JSON, will try token server absolute URL", + ); } // Fallback: try token server absolute URL from env - const TOKEN_SERVER = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' - const absUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}` + const TOKEN_SERVER = + (import.meta.env.VITE_TOKEN_SERVER_URL as string) || + (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || + "https://avanzacast-backend.bfzqqk.easypanel.host"; + // Prefer token endpoint on absolute token-server too + const absUrl = `${TOKEN_SERVER.replace(/\/$/, "")}/api/session/${encodeURIComponent(sessionId)}/token`; try { - const resp2 = await fetch(absUrl, { mode: 'cors' }) + const resp2 = await fetch(absUrl, { mode: "cors" }); if (!resp2.ok) { // Distinguish 404 from network/CORS if (resp2.status === 404) { - console.warn('[SessionLoader] absolute session endpoint returned 404', absUrl) + console.warn( + "[SessionLoader] absolute session endpoint returned 404", + absUrl, + ); // Try to auto-create a new session on the token server using the missing id as room name fallback try { - const createUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` - const username = (typeof window !== 'undefined' ? (localStorage.getItem('avanzacast_user') || 'Guest') : 'Guest') - const createResp = await fetch(createUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room: sessionId, username }) }) + const createUrl = `${TOKEN_SERVER.replace(/\/$/, "")}/api/session`; + const username = + typeof window !== "undefined" + ? localStorage.getItem("avanzacast_user") || "Guest" + : "Guest"; + const createResp = await fetch(createUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ room: sessionId, username }), + }); if (createResp.ok) { - const created = await createResp.json().catch(() => null) + const created = await createResp.json().catch(() => null); if (created && created.id) { // Persist and redirect to new id so the loader will pick it up try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - sessionStorage.setItem(storeKey, JSON.stringify(created)) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: created })) } catch(e){} - } catch (e) { /* ignore */ } - const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '') - const target = created.studioUrl || `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(created.id)}` + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + sessionStorage.setItem(storeKey, JSON.stringify(created)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { + detail: created, + }), + ); + } catch (e) {} + } catch (e) { + /* ignore */ + } + const BROADCAST_BASE = + (import.meta.env.VITE_BROADCASTPANEL_URL as string) || + "https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"; + const target = + created.studioUrl || + `${BROADCAST_BASE.replace(/\/$/, "")}/studio/${encodeURIComponent(created.id)}`; if (!cancelled) { - try { window.location.href = target; return } catch(e) { try { window.location.assign(target); return } catch(e2){} } + try { + window.location.href = target; + return; + } catch (e) { + try { + window.location.assign(target); + return; + } catch (e2) {} + } } } } } catch (createErr) { - console.warn('[SessionLoader] auto-create session failed', createErr) + console.warn( + "[SessionLoader] auto-create session failed", + createErr, + ); } - if (!cancelled) setState({ status: 'missing' }) - return + if (!cancelled) setState({ status: "missing" }); + return; } - const body = await resp2.text().catch(() => '') - const msg = `[SessionLoader] token-server returned ${resp2.status} for ${absUrl} - ${body}` - console.warn(msg) - if (!cancelled) setState({ status: 'error', err: msg }) - return + const body = await resp2.text().catch(() => ""); + const msg = `[SessionLoader] token-server returned ${resp2.status} for ${absUrl} - ${body}`; + console.warn(msg); + if (!cancelled) setState({ status: "error", err: msg }); + return; + } + const text2 = await resp2.text(); + let json2 = null; + try { + json2 = JSON.parse(text2); + } catch (e) { + json2 = null; } - const text2 = await resp2.text() - let json2 = null - try { json2 = JSON.parse(text2) } catch (e) { json2 = null } if (!json2) { const msg = `[SessionLoader] token-server at ${absUrl} returned non-JSON response`; - console.warn(msg) - if (!cancelled) setState({ status: 'error', err: msg }) - return + console.warn(msg); + if (!cancelled) setState({ status: "error", err: msg }); + return; } if (!cancelled) { try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - const payload = JSON.stringify(json2) - sessionStorage.setItem(storeKey, payload) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: json2 })) } catch(e){} - console.debug('[SessionLoader] stored session in sessionStorage key=', storeKey, 'from', absUrl) - } catch(e) { console.warn('[SessionLoader] failed to write sessionStorage', e) } - setState({ status: 'ready', token: json2?.token, url: json2?.url }) + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + const payload = JSON.stringify(json2); + sessionStorage.setItem(storeKey, payload); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: json2 }), + ); + } catch (e) {} + console.debug( + "[SessionLoader] stored session in sessionStorage key=", + storeKey, + "from", + absUrl, + ); + } catch (e) { + console.warn("[SessionLoader] failed to write sessionStorage", e); + } + setState({ + status: "ready", + token: json2?.token || json2?.participantToken || json2?.token, + url: json2?.url || json2?.serverUrl || json2?.url, + }); } - return + return; } catch (err2) { // network-level error (DNS, CORS preflight blocked, TLS, etc) - const errMsg = `[SessionLoader] failed to fetch from token-server ${absUrl}: ${String(err2)}` - console.error(errMsg) - if (!cancelled) setState({ status: 'error', err: errMsg }) - return + const errMsg = `[SessionLoader] failed to fetch from token-server ${absUrl}: ${String(err2)}`; + console.error(errMsg); + if (!cancelled) setState({ status: "error", err: errMsg }); + return; } } catch (err: any) { - if (!cancelled) setState({ status: 'error', err: String(err) }) + if (!cancelled) setState({ status: "error", err: String(err) }); } - })() - return () => { cancelled = true } - }, [sessionId]) + })(); + return () => { + cancelled = true; + }; + }, [sessionId]); - if (state.status === 'loading') { - return
Cargando sesión del estudio...
+ if (state.status === "loading") { + return
Cargando sesión del estudio...
; } - if (state.status === 'missing') { + if (state.status === "missing") { // redirect to home if no session - if (typeof window !== 'undefined') window.location.replace('/') - return null + if (typeof window !== "undefined") window.location.replace("/"); + return null; } - if (state.status === 'error') { + if (state.status === "error") { // Show a helpful error with diagnostics commands - const curlRel = `curl -v "${window.location.origin}/api/session/${encodeURIComponent(sessionId)}"` - const tokenServer = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' - const curlAbs = `curl -v "${tokenServer.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}"` + const curlRel = `curl -v "${window.location.origin}/api/session/${encodeURIComponent(sessionId)}"`; + const tokenServer = + (import.meta.env.VITE_TOKEN_SERVER_URL as string) || + (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || + "https://avanzacast-backend.bfzqqk.easypanel.host"; + const curlAbs = `curl -v "${tokenServer.replace(/\/$/, "")}/api/session/${encodeURIComponent(sessionId)}"`; return (
-

Error cargando sesión: {state.err ? state.err : 'Error desconocido'}

-

Posibles causas: sesión no existe, backend inaccesible (CORS / mixed-content), o token-server rechaza la petición.

-
-
Prueba estos comandos desde tu máquina/servidor para diagnosticar:
-
{curlRel}
+

+ Error cargando sesión: {state.err ? state.err : "Error desconocido"} +

+

+ Posibles causas: sesión no existe, backend inaccesible (CORS / + mixed-content), o token-server rechaza la petición. +

+
+
+ Prueba estos comandos desde tu máquina/servidor para diagnosticar: +
+
{curlRel}
-
{curlAbs}
+
{curlAbs}
- +
- ) + ); } return ( // render StudioPortal directly (embedded) - - ) + + ); } -const root = createRoot(document.getElementById('root')!) +const root = createRoot(document.getElementById("root")!); -// detect session id in the path: if path is like / then try to load session -const pathname = typeof window !== 'undefined' ? window.location.pathname.replace(/\/$/, '') : '' -const maybeId = pathname && pathname.length > 1 ? pathname.slice(1) : '' +// detect session id in the path: support /studio/:id route or root /:id fallback +const pathname = + typeof window !== "undefined" + ? window.location.pathname.replace(/\/$/, "") + : ""; +let maybeId = ""; +if (pathname.startsWith("/studio/")) { + maybeId = pathname.split("/studio/")[1] || ""; +} else if (pathname && pathname.length > 1) { + maybeId = pathname.slice(1); +} // NEW: if the URL contains token as query param, persist it to sessionStorage and dispatch event -if (typeof window !== 'undefined') { +if (typeof window !== "undefined") { try { - const qs = new URLSearchParams(window.location.search) - const qtoken = qs.get('token') - const qroom = qs.get('room') - const qserver = qs.get('serverUrl') || qs.get('server') + const qs = new URLSearchParams(window.location.search); + const qtoken = qs.get("token"); + const qroom = qs.get("room"); + const qserver = qs.get("serverUrl") || qs.get("server"); if (qtoken) { try { - const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' - const payload = { token: qtoken, room: qroom || '', url: qserver || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' } - sessionStorage.setItem(storeKey, JSON.stringify(payload)) - try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: payload })) } catch(e) {} - console.debug('[main] token found in querystring, stored session under', storeKey) - } catch (e) { console.warn('[main] failed to persist token from querystring', e) } + const storeKey = + (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || + "avanzacast_studio_session"; + const payload = { + token: qtoken, + room: qroom || "", + url: qserver || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || "", + }; + sessionStorage.setItem(storeKey, JSON.stringify(payload)); + try { + window.dispatchEvent( + new CustomEvent("AVZ_STUDIO_SESSION", { detail: payload }), + ); + } catch (e) {} + console.debug( + "[main] token found in querystring, stored session under", + storeKey, + ); + } catch (e) { + console.warn("[main] failed to persist token from querystring", e); + } } - } catch(e) { /* ignore URL parsing errors */ } + } catch (e) { + /* ignore URL parsing errors */ + } } if (maybeId) { root.render( - - ) + , + ); } else { root.render( - - ) + , + ); } diff --git a/packages/broadcast-panel/src/styles.css b/packages/broadcast-panel/src/styles.css index 46be1c9..3aea005 100644 --- a/packages/broadcast-panel/src/styles.css +++ b/packages/broadcast-panel/src/styles.css @@ -42,12 +42,22 @@ } body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-family: + "Inter", + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: var(--background-color); color: var(--text-primary); - transition: background-color 0.3s ease, color 0.3s ease; + transition: + background-color 0.3s ease, + color 0.3s ease; } button { diff --git a/packages/broadcast-panel/src/types.ts b/packages/broadcast-panel/src/types.ts index ab2053a..a15ddd1 100644 --- a/packages/broadcast-panel/src/types.ts +++ b/packages/broadcast-panel/src/types.ts @@ -1,2 +1 @@ -export type { Transmission } from '@shared/types' - +export type { Transmission } from "@shared/types"; diff --git a/packages/broadcast-panel/src/utils/postMessage.ts b/packages/broadcast-panel/src/utils/postMessage.ts index c10cd24..92abdf4 100644 --- a/packages/broadcast-panel/src/utils/postMessage.ts +++ b/packages/broadcast-panel/src/utils/postMessage.ts @@ -2,21 +2,33 @@ export function getAllowedOriginsFromEnv(): string[] { const allowed = new Set(); try { - const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || ''; + const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || ""; if (raw) { - raw.split(',').map(s => s.trim()).filter(Boolean).forEach(o => allowed.add(o)); + raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .forEach((o) => allowed.add(o)); } - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } try { - const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || ''; + const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || ""; if (studioUrl) { try { const u = new URL(studioUrl); allowed.add(u.origin); - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } } - } catch (e) { /* ignore */ } - try { allowed.add(window.location.origin); } catch (e) {} + } catch (e) { + /* ignore */ + } + try { + allowed.add(window.location.origin); + } catch (e) {} return Array.from(allowed); } @@ -26,7 +38,11 @@ export function isAllowedOrigin(origin: string | null | undefined): boolean { return list.includes(origin); } -export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) { +export function safePostMessage( + target: Window | null | undefined, + message: any, + targetOrigin: string, +) { if (!target) return false; try { target.postMessage(message, targetOrigin); @@ -36,4 +52,3 @@ export function safePostMessage(target: Window | null | undefined, message: any, return false; } } - diff --git a/packages/broadcast-panel/src/utils/studioLauncher.ts b/packages/broadcast-panel/src/utils/studioLauncher.ts index 2330310..bba1824 100644 --- a/packages/broadcast-panel/src/utils/studioLauncher.ts +++ b/packages/broadcast-panel/src/utils/studioLauncher.ts @@ -1,100 +1,172 @@ export interface StudioTokenPayload { - token: string - serverUrl?: string - room?: string - user?: string + token: string; + serverUrl?: string; + room?: string; + user?: string; } -export async function openStudioWithToken(tokenData: StudioTokenPayload, opts?: { studioUrl?: string, shortIdLength?: number, onAck?: (ack: any) => void, forceRedirect?: boolean }) { - const STUDIO_URL = opts?.studioUrl || (import.meta.env.VITE_STUDIO_URL as string) || 'https://avanzacast-studio.bfzqqk.easypanel.host' - const shortId = Math.random().toString(36).slice(2, 2 + (opts?.shortIdLength || 8)) - const studioBase = STUDIO_URL.replace(/\/$/, '') - const studioPath = `${studioBase}/${shortId}` +export async function openStudioWithToken( + tokenData: StudioTokenPayload, + opts?: { + studioUrl?: string; + shortIdLength?: number; + onAck?: (ack: any) => void; + forceRedirect?: boolean; + }, +) { + const STUDIO_URL = + opts?.studioUrl || + (import.meta.env.VITE_STUDIO_URL as string) || + "https://avanzacast-studio.bfzqqk.easypanel.host"; + const shortId = Math.random() + .toString(36) + .slice(2, 2 + (opts?.shortIdLength || 8)); + const studioBase = STUDIO_URL.replace(/\/$/, ""); + const studioPath = `${studioBase}/${shortId}`; const payload = { - type: 'LIVEKIT_TOKEN', + type: "LIVEKIT_TOKEN", token: tokenData.token, - url: tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || '', - room: tokenData.room || '', - user: tokenData.user || '', - } + url: + tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || "", + room: tokenData.room || "", + user: tokenData.user || "", + }; const paramsForFallback = () => { - const p = new URLSearchParams({ token: tokenData.token || '', room: tokenData.room || '', username: tokenData.user || '' }) - if (tokenData.serverUrl) p.set('serverUrl', tokenData.serverUrl) - return p.toString() - } + const p = new URLSearchParams({ + token: tokenData.token || "", + room: tokenData.room || "", + username: tokenData.user || "", + }); + if (tokenData.serverUrl) p.set("serverUrl", tokenData.serverUrl); + return p.toString(); + }; - const forceRedirectEnv = (import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || '' - const forceRedirect = opts?.forceRedirect !== undefined ? opts.forceRedirect : (forceRedirectEnv === '0' ? false : true) + const forceRedirectEnv = + (import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || ""; + const forceRedirect = + opts?.forceRedirect !== undefined + ? opts.forceRedirect + : forceRedirectEnv === "0" + ? false + : true; try { if (forceRedirect) { - const q = paramsForFallback() - window.location.href = `${studioPath}?${q}` - return + const q = paramsForFallback(); + window.location.href = `${studioPath}?${q}`; + return; } const originAllowed = (() => { - try { return new URL(STUDIO_URL).origin } catch { return '*' } - })() + try { + return new URL(STUDIO_URL).origin; + } catch { + return "*"; + } + })(); try { - const win = window.open(studioPath, '_blank') + const win = window.open(studioPath, "_blank"); if (win) { - let ackTimeout: number | null = null + let ackTimeout: number | null = null; const ackListener = (e: MessageEvent) => { try { - if (!e?.data) return - const d = e.data - if (d?.type === 'LIVEKIT_ACK') { - try { opts?.onAck?.(d) } catch (err) { console.error('onAck callback error', err) } - if (!opts?.onAck) { - window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: d.status === 'connected' ? 'Studio conectado' : `Studio error: ${d?.error || d?.status}`, variant: d.status === 'connected' ? 'success' : 'error' } })) + if (!e?.data) return; + const d = e.data; + if (d?.type === "LIVEKIT_ACK") { + try { + opts?.onAck?.(d); + } catch (err) { + console.error("onAck callback error", err); } - if (ackTimeout) { clearTimeout(ackTimeout); ackTimeout = null } - window.removeEventListener('message', ackListener as unknown as EventListener) + if (!opts?.onAck) { + window.dispatchEvent( + new CustomEvent("AVZ_TOAST", { + detail: { + message: + d.status === "connected" + ? "Studio conectado" + : `Studio error: ${d?.error || d?.status}`, + variant: d.status === "connected" ? "success" : "error", + }, + }), + ); + } + if (ackTimeout) { + clearTimeout(ackTimeout); + ackTimeout = null; + } + window.removeEventListener( + "message", + ackListener as unknown as EventListener, + ); } - } catch (err) { console.error('[studioLauncher] ackListener error', err) } - } + } catch (err) { + console.error("[studioLauncher] ackListener error", err); + } + }; - window.addEventListener('message', ackListener as unknown as EventListener) + window.addEventListener( + "message", + ackListener as unknown as EventListener, + ); ackTimeout = window.setTimeout(() => { try { - window.removeEventListener('message', ackListener as unknown as EventListener) + window.removeEventListener( + "message", + ackListener as unknown as EventListener, + ); if (!opts?.onAck) { - window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: 'No se recibió confirmación del Studio', variant: 'warning' } })) + window.dispatchEvent( + new CustomEvent("AVZ_TOAST", { + detail: { + message: "No se recibió confirmación del Studio", + variant: "warning", + }, + }), + ); } - } catch (err) { console.error('[studioLauncher] ack timeout cleanup error', err) } - }, 20000) + } catch (err) { + console.error("[studioLauncher] ack timeout cleanup error", err); + } + }, 20000); const post = () => { try { - win.postMessage(payload, originAllowed) - console.debug('[studioLauncher] postMessage ->', originAllowed) + win.postMessage(payload, originAllowed); + console.debug("[studioLauncher] postMessage ->", originAllowed); } catch (err) { - try { (win as any).postMessage(payload, '*') } catch (err2) { console.error('[studioLauncher] postMessage failed', err2) } + try { + (win as any).postMessage(payload, "*"); + } catch (err2) { + console.error("[studioLauncher] postMessage failed", err2); + } } - } + }; - setTimeout(post, 300) - setTimeout(post, 800) - setTimeout(post, 1500) - return + setTimeout(post, 300); + setTimeout(post, 800); + setTimeout(post, 1500); + return; } // popup blocked -> fallback to redirect - const q = paramsForFallback() - window.location.href = `${studioPath}?${q}` + const q = paramsForFallback(); + window.location.href = `${studioPath}?${q}`; } catch (err) { - console.error('[studioLauncher] Error opening studio panel, fallback to redirect', err) - const q = paramsForFallback() - window.location.href = `${studioPath}?${q}` + console.error( + "[studioLauncher] Error opening studio panel, fallback to redirect", + err, + ); + const q = paramsForFallback(); + window.location.href = `${studioPath}?${q}`; } } catch (err) { - console.error('[studioLauncher] Unexpected error', err) + console.error("[studioLauncher] Unexpected error", err); } } -export default openStudioWithToken +export default openStudioWithToken; diff --git a/packages/broadcast-panel/src/vite-env.d.ts b/packages/broadcast-panel/src/vite-env.d.ts index d1c3d9e..f2d12bb 100644 --- a/packages/broadcast-panel/src/vite-env.d.ts +++ b/packages/broadcast-panel/src/vite-env.d.ts @@ -1,4 +1,4 @@ -declare module '*.module.css' { - const classes: { [key: string]: string } - export default classes +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; } diff --git a/scripts/restart-livekit-server.sh b/scripts/restart-livekit-server.sh new file mode 100755 index 0000000..b81c249 --- /dev/null +++ b/scripts/restart-livekit-server.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env zsh +# ...existing code... +# scripts/restart-livekit-server.sh +# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos. +# Uso típico: +# ./scripts/restart-livekit-server.sh --api-key devkey --api-secret secret --method dev --logs /tmp/livekit.log + +set -euo pipefail + +API_KEY="${LIVEKIT_API_KEY:-devkey}" +API_SECRET="${LIVEKIT_API_SECRET:-secret}" +METHOD="auto" # auto | systemd | docker | dev +SERVICE_NAME="livekit-server" # nombre del servicio para systemd / docker-compose +COMPOSE_PATH="./docker-compose.yml" +BINARY="${LIVEKIT_BINARY:-livekit-server}" +LOGS="${LOGS_FILE:-livekit-server.log}" +HTTP_URL="${LIVEKIT_HTTP_URL:-http://localhost:7880}" +WS_URL="${LIVEKIT_WS_URL:-ws://localhost:7880}" +SKIP_TESTS=false + +usage() { + cat </dev/null 2>&1; then + sudo systemctl list-unit-files --type=service --no-legend | grep -q "$SERVICE_NAME" || return 1 + return 0 + fi + return 1 +} + +restart_systemd() { + echo "[restart-livekit] intentando reiniciar servicio systemd: $SERVICE_NAME" + if ! sudo systemctl restart "$SERVICE_NAME"; then + echo "[restart-livekit] fallo al reiniciar systemd service $SERVICE_NAME" >&2 + return 1 + fi + echo "[restart-livekit] systemd restart pedido correctamente. Esperando 2s para estabilizar..." + sleep 2 + return 0 +} + +restart_docker_compose() { + if [ -f "$COMPOSE_PATH" ]; then + echo "[restart-livekit] intentando restart con docker-compose ($COMPOSE_PATH)" + if command -v docker-compose >/dev/null 2>&1; then + docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME" + sleep 2 + return 0 + elif command -v docker >/dev/null 2>&1 && command -v docker-compose >/dev/null 2>&1; then + docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME" + sleep 2 + return 0 + else + echo "[restart-livekit] docker-compose no encontrado" >&2 + return 1 + fi + fi + return 1 +} + +start_dev_process() { + echo "[restart-livekit] arrancando '$BINARY --dev' con LIVEKIT_KEYS='${API_KEY}:${API_SECRET}'" + # Matar procesos previos (intento seguro) + if pgrep -f "$BINARY" >/dev/null 2>&1; then + echo "[restart-livekit] matando procesos previos de $BINARY" + pkill -f "$BINARY" || true + sleep 1 + fi + + mkdir -p "$(dirname "$LOGS")" || true + echo "[restart-livekit] lanzando en background; logs -> $LOGS" + nohup env LIVEKIT_KEYS="${API_KEY}:${API_SECRET}" "$BINARY" --dev > "$LOGS" 2>&1 & + sleep 2 +} + +# Decide el método +if [ "$METHOD" = "auto" ]; then + if has_systemd_service; then + CHOSEN=systemd + elif [ -f "$COMPOSE_PATH" ]; then + CHOSEN=docker + else + CHOSEN=dev + fi +else + CHOSEN="$METHOD" +fi + +echo "[restart-livekit] método elegido: $CHOSEN" + +case "$CHOSEN" in + systemd) + if ! restart_systemd; then + echo "[restart-livekit] systemd falló, intentando método dev como fallback" + start_dev_process + fi + ;; + docker) + if ! restart_docker_compose; then + echo "[restart-livekit] docker-compose falló, intentando método dev como fallback" + start_dev_process + fi + ;; + dev) + start_dev_process + ;; + *) + echo "Método desconocido: $CHOSEN"; exit 2 + ;; +esac + +# Wait breve y realizar checks +if [ "$SKIP_TESTS" = true ]; then + echo "[restart-livekit] skip tests activado, terminado. Revisa logs: $LOGS" + exit 0 +fi + +echo "[restart-livekit] comprobaciones básicas: curl y wscat (si está instalado)" + +# 1) curl a /rtc/validate (si el usuario lo desea, la URL es configurable) +if command -v curl >/dev/null 2>&1; then + echo "\n--- curl ${HTTP_URL}/rtc/validate (timeout 5s) ---" + set +e + HTTP_OUT=$(curl -sS --max-time 5 -w "HTTP_STATUS:%{http_code}" "${HTTP_URL}/rtc/validate" 2>&1) + CURL_STATUS=$? + set -e + if [ $CURL_STATUS -ne 0 ]; then + echo "[restart-livekit] curl fallo (exit $CURL_STATUS). Salida:\n$HTTP_OUT" + else + echo "$HTTP_OUT" | sed -n '1,200p' + fi +else + echo "[restart-livekit] curl no está instalado; omitiendo check HTTP" +fi + +# 2) intento rápido con wscat para ver handshake (no envía token) +if command -v wscat >/dev/null 2>&1; then + echo "\n--- wscat -c ${WS_URL} (intento corto 4s) ---" + # usar timeout para no colgar + if command -v timeout >/dev/null 2>&1; then + timeout 4s wscat -c "${WS_URL}" || true + else + echo "(nota: no se encontró 'timeout', wscat puede esperar indefinidamente)" + wscat -c "${WS_URL}" || true + fi +else + echo "[restart-livekit] wscat no instalado; para una prueba WebSocket instálalo con 'npm i -g wscat' o utilice websocat" +fi + +echo "[restart-livekit] FIN. Revisa $LOGS para logs completos (tail -n 200 $LOGS)" +# ...existing code... + diff --git a/scripts/validate-livekit-token.sh b/scripts/validate-livekit-token.sh new file mode 100755 index 0000000..f736aff --- /dev/null +++ b/scripts/validate-livekit-token.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env zsh +# scripts/validate-livekit-token.sh +# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos. +# Uso: +# ./scripts/validate-livekit-token.sh --api-key KEY --api-secret SECRET --server https://example.com --room test_room --identity test_user + +set -euo pipefail + +API_KEY="${LIVEKIT_API_KEY:-}" +API_SECRET="${LIVEKIT_API_SECRET:-}" +SERVER_URL="${LIVEKIT_SERVER_URL:-https://livekit-server.bfzqqk.easypanel.host}" +ROOM="test_room" +IDENTITY="test_user" + +usage(){ + cat <, +try a WebSocket handshake to wss:///rtc?access_token= (if wscat/websocat available), +and print decoded JWT payload. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --api-key) API_KEY="$2"; shift 2;; + --api-secret) API_SECRET="$2"; shift 2;; + --server) SERVER_URL="$2"; shift 2;; + --room) ROOM="$2"; shift 2;; + --identity) IDENTITY="$2"; shift 2;; + -h|--help) usage; exit 0;; + *) echo "Unknown arg: $1"; usage; exit 1;; + esac +done + +if [ -z "$API_KEY" ] || [ -z "$API_SECRET" ]; then + echo "ERROR: API_KEY and API_SECRET must be set (via args or env)." >&2 + usage + exit 2 +fi + +echo "[validate-livekit-token] api_key=${API_KEY} server=${SERVER_URL} room=${ROOM} identity=${IDENTITY}" + +# Temp files for outputs +OUT_DIR="/tmp/validate-livekit" +mkdir -p "$OUT_DIR" +TOKEN_FILE="$OUT_DIR/token.txt" +CURL_FILE="$OUT_DIR/curl_strict.txt" +CURLK_FILE="$OUT_DIR/curl_insecure.txt" +WS_FILE="$OUT_DIR/ws_attempt.txt" +PAYLOAD_FILE="$OUT_DIR/payload.json" + +# Generar token via Node inline +# Pass credentials and params via environment to avoid issues with heredoc args +TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" IDENTITY="$IDENTITY" ROOM="$ROOM" node - <<'NODE' +try{ + const sdk = require('livekit-server-sdk'); + const AccessToken = sdk.AccessToken || (sdk.default && sdk.default.AccessToken); + const VideoGrant = sdk.VideoGrant || (sdk.default && sdk.default.VideoGrant); + const identity = process.env.IDENTITY || 'anon'; + const room = process.env.ROOM || 'default'; + const apiKey = process.env.API_KEY; + const apiSecret = process.env.API_SECRET; + if (!apiKey || !apiSecret) { + console.error('missing API_KEY/API_SECRET in env'); + process.exit(2); + } + const at = new AccessToken(apiKey, apiSecret, { identity: identity, name: identity }); + if (VideoGrant) at.addGrant(new VideoGrant({ room: room })); + else if (typeof at.addGrant === 'function') at.addGrant({ room: room, roomJoin: true, canPublish: true, canSubscribe: true }); + (async ()=>{ const t = (typeof at.toJwt === 'function') ? await at.toJwt() : at.jwt; console.log(t); })(); +}catch(e){ console.error('ERRGEN', e && e.stack ? e.stack : e); process.exit(2); } +NODE +) || { echo "[error] token generation failed" >&2; exit 3; } + +if [ -z "$TOKEN" ]; then + echo "[error] empty token" >&2; exit 4 +fi + +# Save token +echo "$TOKEN" > "$TOKEN_FILE" +chmod 600 "$TOKEN_FILE" + +echo; echo "=== Token generated and saved to: $TOKEN_FILE ===" +echo "Token preview: ${TOKEN:0:140}..." + +VALIDATE_URL="$SERVER_URL/rtc/validate" + +echo; echo "=== CURL validate (strict TLS) -> $CURL_FILE ===" +# store headers and body +set +e +curl -i -sS --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURL_FILE" 2>&1 +CURL_EXIT=$? +set -e +if [ $CURL_EXIT -ne 0 ]; then + echo "[warn] curl strict failed (exit $CURL_EXIT). See $CURL_FILE" +else + echo "[ok] curl strict completed. See $CURL_FILE" +fi + +# insecure curl (diagnostic) +echo; echo "=== CURL validate (insecure -k) -> $CURLK_FILE ===" +set +e +curl -i -sS -k --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURLK_FILE" 2>&1 +CURLK_EXIT=$? +set -e +if [ $CURLK_EXIT -ne 0 ]; then + echo "[warn] curl -k failed (exit $CURLK_EXIT). See $CURLK_FILE" +else + echo "[ok] curl -k completed. See $CURLK_FILE" +fi + +# 2) WebSocket handshake attempt +WS_HOST=$(echo "$SERVER_URL" | sed -E 's#https?://##' | sed -E 's#/$##') +WS_URL="wss://$WS_HOST/rtc?access_token=$TOKEN" + +echo; echo "=== WS handshake attempt to: $WS_URL (output -> $WS_FILE) ===" +if command -v wscat >/dev/null 2>&1; then + if command -v timeout >/dev/null 2>&1; then + timeout 10s wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true + else + wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true + fi + echo "[info] wscat output saved to $WS_FILE" +elif command -v websocat >/dev/null 2>&1; then + if command -v timeout >/dev/null 2>&1; then + timeout 10s websocat "$WS_URL" > "$WS_FILE" 2>&1 || true + else + websocat "$WS_URL" > "$WS_FILE" 2>&1 || true + fi + echo "[info] websocat output saved to $WS_FILE" +else + echo "[info] wscat/websocat not installed; skipping WS handshake test" +fi + +# 3) decode payload (no signature verification) +echo "$TOKEN" | awk '{print $0}' > /dev/null +node -e 'try{const t=process.argv[1]; const p=t.split(".")[1]; const b=Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString(); console.log(b);}catch(e){console.error("failed decode",e);} ' "$TOKEN" > "$PAYLOAD_FILE" 2>&1 || true + +echo; echo "=== DECODED PAYLOAD SAVED TO: $PAYLOAD_FILE ===" + +# Print short summary and file locations for user to copy +cat < { const room = await connect(serverUrl, token); console.log('Conectado a room:', room.name); })(); + +Recomendaciones de seguridad: +- No subir tokens ni secretos a repositorios públicos. +- Generar tokens en backend y entregar tokens cortos al cliente. +- Usar TTL corto en producción (10-30 min) y permisos mínimos por rol. +- Guardar LIVEKIT_API_SECRET en un gestor de secretos. + diff --git a/scripts/validate-livekit/curl_strict.txt b/scripts/validate-livekit/curl_strict.txt new file mode 100644 index 0000000..3cabe75 --- /dev/null +++ b/scripts/validate-livekit/curl_strict.txt @@ -0,0 +1,4 @@ +HTTP/2 200 + +body: success + diff --git a/scripts/validate-livekit/payload.json b/scripts/validate-livekit/payload.json new file mode 100644 index 0000000..94d95d3 --- /dev/null +++ b/scripts/validate-livekit/payload.json @@ -0,0 +1,2 @@ +{ "name": "test_user", "video": { "room": "test_room", "roomJoin": true, "canPublish": true, "canSubscribe": true }, "iss": "APIBTqTGxf9htMK", "exp": 1763798728, "nbf": 0, "sub": "test_user" } + diff --git a/scripts/validate-livekit/token.txt b/scripts/validate-livekit/token.txt new file mode 100644 index 0000000..c6347d3 --- /dev/null +++ b/scripts/validate-livekit/token.txt @@ -0,0 +1,2 @@ +eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdF91c2VyIiwidmlkZW8iOnsicm9vbSI6InRlc3Rfcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWV9LCJpc3MiOiJBUElCVHFUR3hmOWh0TUsiLCJleHAiOjE3NjM3OTg3MjgsIm5iZiI6MCwic3ViIjoidGVzdF91c2VyIn0.gyu2VR1jAFlbrbJIpsAeocrUSHTWWszED7KfM3lAwZU + diff --git a/tools/request_session.sh b/tools/request_session.sh new file mode 100644 index 0000000..ade04c6 --- /dev/null +++ b/tools/request_session.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# tools/request_session.sh +# Uso: ./tools/request_session.sh [username] +# Hace POST a BACKEND_URL/api/session (usando env BACKEND_URL o VITE_BACKEND_TOKENS_URL) +# e imprime JSON prettified y un snippet para inyectar token en la consola del navegador. + +set -euo pipefail +ROOM=${1:-d13oie2} +USERNAME=${2:-visual-runner} +BACKEND=${BACKEND_URL:-${VITE_BACKEND_TOKENS_URL:-https://avanzacast-servertokens.bfzqqk.easypanel.host}} + +if [ -z "$BACKEND" ]; then + echo "ERROR: BACKEND_URL or VITE_BACKEND_TOKENS_URL must be set in env" >&2 + exit 1 +fi + +echo "POSTing session to $BACKEND/api/session (room=$ROOM username=$USERNAME)" +RESP=$(curl -sS -X POST "$BACKEND/api/session" -H 'Content-Type: application/json' -d "{\"room\":\"$ROOM\",\"username\":\"$USERNAME\",\"ttl\":300}") || true + +if [ -z "$RESP" ]; then + echo "No response from $BACKEND/api/session" >&2 + exit 2 +fi + +# Pretty print JSON if jq available +if command -v jq >/dev/null 2>&1; then + echo "$RESP" | jq . +else + echo "$RESP" +fi + +# Try to extract token and studioUrl +TOKEN=$(echo "$RESP" | grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"token"\s*:\s*"([^"]*)"/\1/' || true) +STUDIOURL=$(echo "$RESP" | grep -o '"studioUrl"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"studioUrl"\s*:\s*"([^"]*)"/\1/' || true) +ID=$(echo "$RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"id"\s*:\s*"([^"]*)"/\1/' || true) + +if [ -n "$TOKEN" ]; then + echo + echo "--- Copy-paste this into your browser console (on the broadcast panel page) to inject the token ---" + echo "window.postMessage({ type: 'LIVEKIT_TOKEN', token: '$TOKEN', room: '$ROOM' }, window.location.origin);" + echo "---" +fi + +if [ -n "$STUDIOURL" ]; then + echo "studioUrl: $STUDIOURL" +fi +if [ -n "$ID" ]; then + echo "session id: $ID" +fi + +exit 0 +