Compare commits

...

10 Commits

Author SHA1 Message Date
a2a2da6586 feat(deploy): add LiveKit and Caddy configuration files, init script, and Docker Compose setup for server deployment
Some checks failed
E2E Playwright - Studio Panel / playwright-e2e (push) Has been cancelled
2025-11-26 15:42:40 -07:00
2a242b35f2 feat(deploy): add LiveKit and Caddy configuration files, init script, and Docker Compose setup for server deployment 2025-11-25 11:41:55 -07:00
08aca81ab1 feat(prejoin): add analyzing/loading screen with illustration and spinner; ensure minimum display time during token validation; update styles for compact controls and improved responsiveness 2025-11-25 11:39:58 -07:00
adbec08f5e feat(prejoin): refactor PreJoin UI and styles; remove mock studio feature; add visual test scripts and update dependencies
- Redesign PreJoin component and CSS for improved template compatibility and deterministic rendering
- Remove mock studio toggle and related runtime logic; update useStudioLauncher to always use real backend
- Add README-MOCK.md to document mock studio deprecation
- Add mock-studio.html for manual popup emulation
- Update environment variable resolution in route.ts for backend API
- Add visual regression test scripts (capture, compare, visual_test_prejoin) using Playwright, Puppeteer, pixelmatch, and pngjs
- Update package.json scripts and devDependencies for visual testing
- Simplify PreJoin.stories.tsx for robust Storybook usage
2025-11-25 09:24:44 -07:00
f8516a5330 feat: add PreJoin UI components, styles, platform utils, and build config; update token validation and refactor PreJoin logic 2025-11-23 21:33:52 -07:00
1e67f1ca36 revert(styles): restore avanza-ui files to parent of c408c281852bee361fa739fc495542c1da8e45c3 (undo PreJoin addition) 2025-11-23 18:44:37 -07:00
3780d75386 chore(prejoin): micro-ajuste badge opacity/padding para pruebas visuales 2025-11-23 00:40:12 -07:00
fd5c9f05e0 feat: agregar archivos de configuración y scripts para la funcionalidad de PreJoin y captura de imágenes 2025-11-22 22:54:07 -07:00
c408c28185 feat: add PreJoin page and components with studio controls, microphone meter, platform utils, and design tokens; update styles for prejoin template compatibility 2025-11-22 00:22:09 -07:00
d162014030 feat: add session API routes, in-memory store, and E2E test utilities; update styles and refactor components for consistency 2025-11-21 21:09:14 -07:00
171 changed files with 15698 additions and 2749 deletions

110
app/api/session/README.md Normal file
View File

@ -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/<roomName>`.
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_<ts>_<rand>", "token": "<JWT>", "studioUrl": "https://mi-dominio/rooms/<room>" }
```
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_<timestamp>/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`**

View File

@ -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' } });
}
}

View File

@ -0,0 +1,21 @@
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.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.BACKEND_URL || process.env.BACKEND || '';
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' } });
}
}

View File

@ -0,0 +1,19 @@
export async function POST(req: Request) {
try {
const body = await req.json();
// Prefer VITE_BACKEND_API_URL (frontend env) then VITE_TOKEN_SERVER_URL then BACKEND_URL / BACKEND
const backend = process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.BACKEND_URL || process.env.BACKEND || '';
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' } });
}
}

44
app/api/session/route.ts Normal file
View File

@ -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' } });
}
}

36
app/api/session/store.ts Normal file
View File

@ -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<string, SessionData>();
export function saveSession(id: string, data: Omit<Partial<SessionData>, '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());
}

View File

@ -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<string | null> {
// 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<string | null>(null);
const [username, setUsername] = useState<string | null>(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 <div aria-live="polite">Solicitando token de sesión para room "{roomName}"...</div>;
if (error) return <div role="alert">Error solicitando token: {error}</div>;
return (
<div>
{username ? <div>Identidad: <strong>{username}</strong></div> : null}
</div>
);
}

View File

@ -0,0 +1,3 @@
// file removed - StudioReceiver replaced by real studio flow
// This file was intentionally removed when reverting mock changes.

View File

@ -0,0 +1,17 @@
import AutoRequestAndInject from './AutoRequestAndInject';
export default function RoomPage({ params }: { params: { roomName: string } }) {
const { roomName } = params;
return (
<div>
<h1>Room: {roomName}</h1>
<AutoRequestAndInject roomName={roomName} />
{/* Aquí va el componente del estudio real (VideoConference) */}
<div id="studio-root">
{/* El estudio debe estar preparado para escuchar postMessage LIVEKIT_TOKEN */}
<p>Esperando token para conectar al studio...</p>
</div>
</div>
);
}

BIN
docs/img_3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
docs/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
docs/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
docs/img_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
docs/img_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
docs/img_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
docs/img_9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

130
docs/mock-studio.html Normal file
View File

@ -0,0 +1,130 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mock Studio — AvanzaCast</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e6eef8}
.card{width:760px;max-width:95%;background:#0b1220;border-radius:12px;padding:20px;box-shadow:0 10px 30px rgba(2,6,23,0.6)}
h1{margin:0 0 8px;font-size:18px}
p{margin:0 0 12px;color:#9fb0d1}
.row{display:flex;gap:8px;margin-bottom:8px}
button{background:#0ea5a3;border:none;padding:8px 12px;border-radius:8px;color:#042024;cursor:pointer}
pre{background:#041025;padding:12px;border-radius:8px;color:#cfe8ff;overflow:auto;max-height:220px}
input{background:#031423;border:1px solid #103247;color:#cfe8ff;padding:8px;border-radius:6px;flex:1}
</style>
</head>
<body>
<div class="card">
<h1>Mock Studio — Emulación popup</h1>
<p>Esta página simula el popup que responde a mensajes postMessage desde la app (LIVEKIT_PING, LIVEKIT_TOKEN). Úsala en local si el host remoto no es accesible.</p>
<div class="row">
<input id="roomInput" placeholder="room (mock-room)" value="mock-room" />
<input id="tokenInput" placeholder="token (mock-token-<room>)" value="mock-token-mock-room" />
<button id="sendToken">Enviar LIVEKIT_TOKEN</button>
</div>
<div class="row">
<button id="sendPing">Enviar LIVEKIT_PING</button>
<button id="openOpener" title="intenta comunicarte con window.opener">Ping opener</button>
<button id="clearLog">Limpiar</button>
</div>
<pre id="log">Log de mensajes:
</pre>
</div>
<script>
const logEl = document.getElementById('log');
function log(...args){
const text = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
logEl.textContent = logEl.textContent + '\n' + new Date().toISOString().slice(11,23) + ' ' + text;
logEl.scrollTop = logEl.scrollHeight;
}
// Mensajes entrantes (desde opener)
window.addEventListener('message', ev => {
try {
const msg = ev.data;
log('RECV', msg);
// Normalizar formato: puede ser string o objeto
if (typeof msg === 'string') {
if (msg === 'LIVEKIT_PING') {
// responder inmediatamente
ev.source.postMessage('LIVEKIT_READY', '*');
log('SENT', 'LIVEKIT_READY');
return;
}
}
if (typeof msg === 'object' && msg !== null) {
if (msg.type === 'LIVEKIT_TOKEN' || msg.type === 'LIVEKIT_GET_TOKEN') {
const room = msg.room || document.getElementById('roomInput').value || 'mock-room';
const token = document.getElementById('tokenInput').value || ('mock-token-' + room);
// enviar ACK con payload
const ack = { type: 'LIVEKIT_ACK', token, room };
// simular pequeña latencia
setTimeout(() => {
ev.source.postMessage(ack, '*');
log('SENT', ack);
// también enviar READY después
setTimeout(() => {
ev.source.postMessage('LIVEKIT_READY', '*');
log('SENT', 'LIVEKIT_READY');
}, 120);
}, 120);
return;
}
}
} catch (err) {
log('ERR', err && err.message ? err.message : err);
}
});
// Botones UI para debug / manual
document.getElementById('sendPing').addEventListener('click', () => {
if (window.opener && !window.opener.closed) {
window.opener.postMessage('LIVEKIT_PING', '*');
log('SENT (opener)', 'LIVEKIT_PING');
} else {
log('NOP', 'No hay opener disponible');
}
});
document.getElementById('sendToken').addEventListener('click', () => {
if (window.opener && !window.opener.closed) {
const room = document.getElementById('roomInput').value || 'mock-room';
const token = document.getElementById('tokenInput').value || ('mock-token-' + room);
const msg = { type: 'LIVEKIT_TOKEN', token, room };
window.opener.postMessage(msg, '*');
log('SENT (opener)', msg);
} else {
log('NOP', 'No hay opener disponible');
}
});
document.getElementById('openOpener').addEventListener('click', () => {
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage('MOCK_HELLO', '*');
log('SENT (opener)', 'MOCK_HELLO');
} else {
log('NOP', 'Opener no encontrado');
}
} catch (e) {
log('ERR', e.message);
}
});
document.getElementById('clearLog').addEventListener('click', () => logEl.textContent = 'Log de mensajes:\n');
// Si quieres abrir esta página desde la app usando window.open(url) y el popup fue bloqueado,
// puedes abrir manualmente y usar el botón 'Enviar LIVEKIT_TOKEN' para simular el handshake.
log('Mock studio listo');
</script>
</body>
</html>

215
docs/portal_livekit.md Normal file
View File

@ -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 `<LiveKitRoom>`.
```tsx
import '@livekit/components-styles';
import { LiveKitRoom, VideoConference } from '@livekit/components-react';
export default function MiSala({ token, serverUrl }) {
return (
<LiveKitRoom
token={token}
serverUrl={serverUrl}
connect={true}
data-lk-theme="default" // Aplica el tema base
>
{/* Aquí van tus componentes personalizados */}
<VideoConference />
</LiveKitRoom>
);
}
```
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 `<GridLayout>` o `<FocusLayout>` para renderizar la vista principal de los participantes. |
| **Controles (Micrófono, Cámara, etc.)** | **ControlBar / TrackToggle** | El componente `<ControlBar>` 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 `<ParticipantLoop>` para iterar sobre los participantes y renderizar un `<ParticipantTile>` para cada uno en la vista inferior o principal. |
| **Comentarios (Derecha)** | **Chat** | El componente `<Chat>` 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`).

544
docs/prejoin_template.html Normal file

File diff suppressed because it is too large Load Diff

863
docs/prejoin_ui.md Normal file

File diff suppressed because it is too large Load Diff

BIN
docs/sequence_livekit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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);
}
})();

34
e2e/run-remote-chrome.sh Normal file
View File

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

116
e2e/run-smoke-local.sh Normal file
View File

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

44
e2e/run-tests.js Normal file
View File

@ -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).');
})();

23
e2e/tests.json Normal file
View File

@ -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"
}
]

View File

@ -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;

View File

@ -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) });
}

View File

@ -0,0 +1,38 @@
logging:
logs:
default:
level: INFO
storage:
"module": "file_system"
"root": "/data"
apps:
tls:
certificates:
automate:
- livekit-server.bfzqqk.easypanel.host
- nextream.sytes.net
layer4:
servers:
main:
listen: [":443"]
routes:
- match:
- tls:
sni:
- "nextream.sytes.net"
handle:
- handler: tls
- handler: proxy
upstreams:
- dial: ["localhost:5349"]
- match:
- tls:
sni:
- "livekit-server.bfzqqk.easypanel.host"
handle:
- handler: tls
connection_policies:
- alpn: ["http/1.1"]
- handler: proxy
upstreams:
- dial: ["localhost:7880"]

View File

@ -0,0 +1,18 @@
version: '3.9'
services:
livekit:
image: livekit/livekit-server:latest
container_name: livekit-server
restart: always
volumes:
# Monta tu archivo de configuración
- ./livekit.yaml:/etc/livekit/livekit.yaml
command:
# Asegura que LiveKit use el archivo de configuración montado
- --config
- /etc/livekit/livekit.yaml
ports:
# Mapeo del puerto TCP para la señalización (Proxy <-- Docker)
- "7880:7880"
# Mapeo del rango de puertos UDP para el tráfico de medios (¡CRUCIAL!)
- "40000-40200:40000-40200/udp"

View File

@ -0,0 +1,18 @@
# This docker-compose requires host networking, which is only available on Linux
# This compose will not function correctly on Mac or Windows
services:
# caddy:
# image: livekit/caddyl4
# command: run --config /etc/caddy.yaml --adapter yaml
# restart: unless-stopped
# network_mode: "host"
# volumes:
# - ./caddy.yaml:/etc/caddy.yaml
# - ./caddy_data:/data
livekit:
image: livekit/livekit-server:latest
command: --config /etc/livekit.yaml
restart: unless-stopped
network_mode: "host"
volumes:
- ./livekit.yaml:/etc/livekit.yaml

View File

@ -0,0 +1,156 @@
#!/bin/sh
# This script will write all of your configurations to /opt/livekit.
# It'll also install LiveKit as a systemd service that will run at startup
# LiveKit will be started automatically at machine startup.
# create directories for LiveKit
mkdir -p /opt/livekit/caddy_data
mkdir -p /usr/local/bin
# Docker & Docker Compose will need to be installed on the machine
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
sh /tmp/get-docker.sh
curl -L "https://github.com/docker/compose/releases/download/v2.20.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod 755 /usr/local/bin/docker-compose
sudo systemctl enable docker
# livekit config
cat << EOF > /opt/livekit/livekit.yaml
port: 7880
bind_addresses:
- ""
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
enable_loopback_candidate: false
redis:
address: <redis-host>:6379
username: ""
password: ""
db: 0
use_tls: false
sentinel_master_name: ""
sentinel_username: ""
sentinel_password: ""
sentinel_addresses: []
cluster_addresses: []
max_redirects: null
turn:
enabled: true
domain: nextream.sytes.net
tls_port: 5349
udp_port: 3478
external_tls: true
keys:
APIBTqTGxf9htMK: 0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
EOF
# caddy config
cat << EOF > /opt/livekit/caddy.yaml
logging:
logs:
default:
level: INFO
storage:
"module": "file_system"
"root": "/data"
apps:
tls:
certificates:
automate:
- livekit-server.bfzqqk.easypanel.host
- nextream.sytes.net
layer4:
servers:
main:
listen: [":443"]
routes:
- match:
- tls:
sni:
- "nextream.sytes.net"
handle:
- handler: tls
- handler: proxy
upstreams:
- dial: ["localhost:5349"]
- match:
- tls:
sni:
- "livekit-server.bfzqqk.easypanel.host"
handle:
- handler: tls
connection_policies:
- alpn: ["http/1.1"]
- handler: proxy
upstreams:
- dial: ["localhost:7880"]
EOF
# update ip script
cat << "EOF" > /opt/livekit/update_ip.sh
#!/usr/bin/env bash
ip=`ip addr show |grep "inet " |grep -v 127.0.0. |head -1|cut -d" " -f6|cut -d/ -f1`
sed -i.orig -r "s/\\\"(.+)(\:5349)/\\\"$ip\2/" /opt/livekit/caddy.yaml
EOF
# docker compose
cat << EOF > /opt/livekit/docker-compose.yaml
# This docker-compose requires host networking, which is only available on Linux
# This compose will not function correctly on Mac or Windows
services:
caddy:
image: livekit/caddyl4
command: run --config /etc/caddy.yaml --adapter yaml
restart: unless-stopped
network_mode: "host"
volumes:
- ./caddy.yaml:/etc/caddy.yaml
- ./caddy_data:/data
livekit:
image: livekit/livekit-server:latest
command: --config /etc/livekit.yaml
restart: unless-stopped
network_mode: "host"
volumes:
- ./livekit.yaml:/etc/livekit.yaml
EOF
# systemd file
cat << EOF > /etc/systemd/system/livekit-docker.service
[Unit]
Description=LiveKit Server Container
After=docker.service
Requires=docker.service
[Service]
LimitNOFILE=500000
Restart=always
WorkingDirectory=/opt/livekit
# Shutdown container (if running) when unit is started
ExecStartPre=/usr/local/bin/docker-compose -f docker-compose.yaml down
ExecStart=/usr/local/bin/docker-compose -f docker-compose.yaml up
ExecStop=/usr/local/bin/docker-compose -f docker-compose.yaml down
[Install]
WantedBy=multi-user.target
EOF
chmod 755 /opt/livekit/update_ip.sh
/opt/livekit/update_ip.sh
systemctl enable livekit-docker
systemctl start livekit-docker

View File

@ -0,0 +1,29 @@
port: 7880
bind_addresses:
- ""
rtc:
tcp_port: 7881
port_range_start: 40000
port_range_end: 40200
use_external_ip: true
enable_loopback_candidate: false
redis:
address: 192.168.1.20:6380
username: "default"
password: "52a4a5b5efdd2ac4a8fd"
db: 0
use_tls: false
sentinel_master_name: ""
sentinel_username: ""
sentinel_password: ""
sentinel_addresses: []
cluster_addresses: []
max_redirects: null
turn:
enabled: true
domain: nextream.sytes.net
tls_port: 5349
udp_port: 3478
external_tls: true
keys:
APIBTqTGxf9htMK: 0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW

View File

@ -0,0 +1,22 @@
# 1. Configuración de API Key/Secret
api:
key: "APIBTqTGxf9htMK"
secret: "0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW"
# 2. Configuración RTC y Medios (¡Crucial!)
rtc:
# La URL que el cliente usará para conectarse (a través de tu proxy)
# Debe ser ws:// o wss:// dependiendo de tu proxy
url: "wss://livekit-server.bfzqqk.easypanel.host"
# La IP interna en la que LiveKit escuchará el tráfico de medios (ICE/TURN)
# Usa 0.0.0.0 para escuchar en todas las interfaces del contenedor
rtc_ip: "0.0.0.0"
# El rango de puertos UDP que LiveKit usará. ¡Debes mapear estos puertos en Docker!
port_range:
start: 40000
end: 40200
# 3. Configuración de HTTP/Websocket (Señalización)
port: 7880 # El puerto interno de LiveKit que expondremos al proxy

660
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,8 @@
],
"scripts": {
"dev": "concurrently \"npm:dev:*\"",
"visual-test:prejoin": "node scripts/visual_test_prejoin.cjs",
"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",
@ -36,7 +38,10 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"playwright": "^1.51.0",
"pixelmatch": "^7.1.0",
"playwright": "^1.56.1",
"pngjs": "^7.0.0",
"puppeteer": "^24.31.0",
"typescript": "^5.2.2"
},
"engines": {
@ -44,7 +49,6 @@
"npm": ">=10.0.0"
},
"dependencies": {
"puppeteer": "^19.11.1",
"puppeteer-core": "^24.30.0",
"react-icons": "^5.5.0"
}

View File

View File

View File

View File

@ -1,19 +1,102 @@
# avanza-ui
Librería de componentes reutilizables para AvanzaCast.
Paquete de componentes UI usado por AvanzaCast.
Componentes añadidos en esta entrega:
Este README explica el flujo de build y las instrucciones rápidas para desarrollar, compilar y depurar `avanza-ui` en este monorepo.
- `ControlButton` - botón redondo con icono y etiqueta opcional (tamaños: sm|md|lg)
- `IconButton` - botón icon-only para acciones rápidas
- `ControlGroup` - contenedor para agrupar controles
- `ControlBar` - barra de controles centrada que usa `ControlGroup`
## Propósito
Importar desde otros paquetes:
`avanza-ui` contiene componentes React reutilizables (Button, Modal, VideoTile, StudioHeader, etc.) y sus estilos. El artefacto compilado queda en `dist/` y es consumido por otras aplicaciones del monorepo (por ejemplo `broadcast-panel`).
```ts
import { ControlButton, IconButton, ControlGroup, ControlBar } from 'avanza-ui'
## Resumen del flujo de build
- Usamos Rollup para compilar el código TS/TSX a `dist/index.cjs.js` y `dist/index.esm.js`.
- `postcss` genera CSS extracted (archivo global) y `rollup-plugin-copy` copia desde `src/components/**/*.module.css` a `dist/components/` y `src/styles/**/*.css` a `dist/styles/` para que las importaciones relativas en los archivos emitidos en `dist` (p. ej. `@import '../styles/globals.css'`) resuelvan correctamente en tiempo de ejecución.
- Se dejó un script `scripts/copy-css.js` como respaldo, pero está marcado como obsoleto; la copia la realiza `rollup-plugin-copy` en el build normal.
## Requisitos
- Node.js (recomendado >= 18)
- npm
## Comandos útiles
Instalar dependencias del paquete:
```bash
cd packages/avanza-ui
npm install
```
Los estilos se importan como efecto secundario al importar `avanza-ui` (archivo `controls.css`).
Construir (producción / CI):
```bash
cd packages/avanza-ui
npm run build
```
- `rollup -c` compila el paquete y `rollup-plugin-copy` copiará automáticamente los CSS modules y estilos globales a `dist`.
Modo desarrollo (watch):
```bash
cd packages/avanza-ui
npm run dev
```
Verificar que los CSS aparezcan en dist:
```bash
ls -la packages/avanza-ui/dist/components | head -n 40
ls -la packages/avanza-ui/dist/styles
```
Levantar el `broadcast-panel` para probar la UI integrada:
```bash
cd packages/broadcast-panel
npm run dev
# abrir http://localhost:5176/ (o el puerto que Vite muestre)
```
## Notas y troubleshooting
- Si ves warnings de Node como `MODULE_TYPELESS_PACKAGE_JSON` al ejecutar `npm run build`, puedes añadir `"type": "module"` a `packages/avanza-ui/package.json` para evitar que Node reanalice el `rollup.config.js` como CommonJS.
- Si aparece un error tipo `ENOENT: no such file or directory, open '../styles/globals.css'` significa que `dist/styles/globals.css` no existe. Ejecuta `npm run build` en `packages/avanza-ui` y verifica que `dist/styles/globals.css` y `dist/components/*.module.css` estén presentes.
- El script `packages/avanza-ui/scripts/copy-css.js` existe como respaldo histórico. Ya está marcado como "deprecated" y no se ejecuta en el flujo por defecto. Si prefieres eliminarlo, puedes borrarlo del repositorio.
## Integración en CI
Asegúrate de que el job que construye la aplicación principal (o que publica paquetes) ejecute:
```bash
cd packages/avanza-ui
npm ci
npm run build
```
Esto garantiza que los artefactos `dist` contienen tanto los JS compilados como los CSS necesarios.
## Limpieza / regeneración de dist
Para forzar una reconstrucción limpia:
```bash
cd packages/avanza-ui
rm -rf dist node_modules
npm ci
npm run build
```
## Contribuciones
Si agregas nuevos componentes que usan `*.module.css` o nuevos archivos en `src/styles/`, asegúrate de que las rutas relativas usadas en los ficheros emitidos en `dist` coincidan con la estructura que `rollup-plugin-copy` genera (`dist/components` y `dist/styles`).
---
Si quieres, puedo:
- añadir una nota al `README.md` de la raíz del repo explicando el cambio, o
- abrir un commit/PR con estos cambios documentados.
Dime qué prefieres y lo hago ahora.

View File

View File

@ -0,0 +1,38 @@
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import postcss from 'rollup-plugin-postcss';
import copy from 'rollup-plugin-copy';
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true,
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
},
],
plugins: [
peerDepsExternal(),
resolve({ extensions: ['.js', '.ts', '.tsx'] }),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
postcss({ extract: true, minimize: true }),
// copy CSS module files and global styles to dist so relative @import paths resolve
copy({
targets: [
{ src: 'src/components/**/*.module.css', dest: 'dist/components' },
{ src: 'src/styles/**/*.css', dest: 'dist/styles' }
],
verbose: true,
flatten: false,
}),
],
};

View File

@ -0,0 +1,5 @@
#!/usr/bin/env node
// Deprecated: rollup-plugin-copy now handles copying CSS files into dist.
// Keeping this script for backward compatibility/history. It will not perform any action.
console.log('scripts/copy-css.js is deprecated; rollup-plugin-copy handles CSS copying now.');
process.exit(0);

View File

@ -8,7 +8,7 @@ export interface ControlBarProps {
export const ControlBar: React.FC<ControlBarProps> = ({ children, className }) => {
return (
<div style={{display:'flex',justifyContent:'center',padding:'8px'}} className={className}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }} className={className}>
<ControlGroup className="controls-inner">
{children}
</ControlGroup>

View File

@ -1,80 +1,30 @@
.controlButton {
display: flex;
flex-direction: column;
/* ...existing code... */
.root {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--au-gray-700) 0%, var(--au-gray-800) 100%);
border: 2px solid var(--au-border-dark);
color: var(--au-text-primary);
gap: 8px;
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--au-radius-full);
transition: all var(--au-transition-fast);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
position: relative;
backdrop-filter: blur(10px);
font-size: 24px;
outline: none;
padding: 8px 12px;
border-radius: 8px;
color: #1f2937;
font-size: 14px;
}
.controlButton:hover:not(:disabled) {
background: linear-gradient(135deg, var(--au-gray-600) 0%, var(--au-gray-700) 100%);
border-color: var(--au-primary);
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
}
.row { flex-direction: row; }
.column { flex-direction: column; }
.controlButton:active:not(:disabled) {
transform: scale(0.98);
}
.default { background: rgba(0,0,0,0.03); }
.studio { background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,0.02); }
.controlButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.active { box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
.danger { color: #ef4444; }
.controlButton.active {
background: linear-gradient(135deg, var(--au-primary) 0%, var(--au-primary-hover) 100%);
border-color: var(--au-primary);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.5);
}
.icon { display: inline-flex; align-items: center; justify-content: center; }
.label { display: inline-block; }
.controlButton.danger {
background: linear-gradient(135deg, var(--au-danger-600) 0%, var(--au-danger-700) 100%);
border-color: var(--au-danger-600);
}
.controlButton.danger:hover:not(:disabled) {
background: linear-gradient(135deg, var(--au-danger-500) 0%, var(--au-danger-600) 100%);
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5);
}
/* Sizes */
.sm {
width: 48px;
height: 48px;
font-size: 20px;
}
.md {
width: 64px;
height: 64px;
font-size: 24px;
}
.lg {
width: 80px;
height: 80px;
font-size: 32px;
}
.controlButtonLabel {
font-size: 10px;
font-weight: var(--au-font-medium);
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sm { padding: 6px 8px; font-size: 12px; }
.md { padding: 8px 12px; font-size: 14px; }
.lg { padding: 10px 14px; font-size: 16px; }

View File

@ -1,55 +1,50 @@
import React from 'react';
import { cn } from '../utils/helpers';
import type { ComponentBaseProps } from '../types';
import styles from './ControlButton.module.css';
// ...existing code...
import React from 'react'
import cx from 'clsx'
import styles from './ControlButton.module.css'
export interface ControlButtonProps extends ComponentBaseProps {
icon?: React.ReactNode;
label?: string;
active?: boolean;
danger?: boolean;
size?: 'sm' | 'md' | 'lg';
onClick?: () => void;
disabled?: boolean;
title?: string;
export type ControlButtonProps = {
className?: string
icon?: React.ReactNode
label?: string
active?: boolean
danger?: boolean
layout?: 'row' | 'column'
variant?: 'studio' | 'default'
onClick?: () => void
hint?: string
size?: 'sm' | 'md' | 'lg'
}
export const ControlButton: React.FC<ControlButtonProps> = (props) => {
const {
icon,
label,
active = false,
danger = false,
size = 'md',
onClick,
disabled = false,
title,
className,
style,
id,
} = props;
export const ControlButton: React.FC<ControlButtonProps> = ({
className,
icon,
label,
active = false,
danger = false,
layout = 'row',
variant = 'default',
onClick,
hint,
size = 'md'
}) => {
return (
<button
className={cn(
styles.controlButton,
styles[size],
active && styles.active,
danger && styles.danger,
className
)}
onClick={onClick}
disabled={disabled}
title={title}
style={style}
id={id}
type="button"
onClick={onClick}
className={cx(styles.root, className, styles[layout], styles[variant], styles[size], {
[styles.active]: active,
[styles.danger]: danger
})}
aria-pressed={active}
title={hint}
>
{icon && <span>{icon}</span>}
{label && <span className={styles.controlButtonLabel}>{label}</span>}
<span className={styles.icon}>{icon}</span>
{label && <span className={styles.label}>{label}</span>}
</button>
);
};
)
}
ControlButton.displayName = 'ControlButton';
// default export for convenience
export default ControlButton

View File

@ -0,0 +1,15 @@
// ...existing code...
import React from 'react'
export type MicrophoneMeterProps = { level?: number }
export const MicrophoneMeter: React.FC<MicrophoneMeterProps> = ({ level = 0 }) => {
return (
<div style={{ width: 48, height: 24, background: '#eee', borderRadius: 6 }} aria-hidden>
<div style={{ width: `${Math.min(100, Math.max(0, level * 100))}%`, height: '100%', background: '#10b981', borderRadius: 6 }} />
</div>
)
}
export default MicrophoneMeter

View File

@ -67,10 +67,13 @@ export type { StudioHeaderProps } from './StudioHeader';
export { ControlButton } from './ControlButton';
export type { ControlButtonProps } from './ControlButton';
export { ControlBar } from './ControlBar';
export { ControlGroup } from './ControlGroup';
export { IconButton } from './IconButton';
export { MicrophoneMeter } from './MicrophoneMeter';
export { SceneCard } from './SceneCard';
export type { SceneCardProps } from './SceneCard';
export { VideoTile } from './VideoTile';
export type { VideoTileProps, ConnectionQuality } from './VideoTile';

View File

@ -1,92 +1,3 @@
// Styles
import './styles/globals.css';
import './styles/controls.css';
// Components
export { Button } from './components/Button';
export type { ButtonProps } from './components/Button';
export { Card, CardHeader, CardBody, CardFooter } from './components/Card';
export type { CardProps, CardSectionProps } from './components/Card';
export { Input } from './components/Input';
export type { InputProps } from './components/Input';
export { Textarea } from './components/Textarea';
export type { TextareaProps } from './components/Textarea';
export { Select } from './components/Select';
export type { SelectProps, SelectOption } from './components/Select';
export { Checkbox } from './components/Checkbox';
export type { CheckboxProps } from './components/Checkbox';
export { Radio } from './components/Radio';
export type { RadioProps } from './components/Radio';
export { Switch } from './components/Switch';
export type { SwitchProps } from './components/Switch';
export { Dropdown, DropdownItem, DropdownDivider, DropdownHeader } from './components/Dropdown';
export type { DropdownProps, DropdownItemProps } from './components/Dropdown';
export { Modal, ModalHeader, ModalBody, ModalFooter } from './components/Modal';
export type { ModalProps, ModalSectionProps, ModalHeaderProps } from './components/Modal';
export { Tooltip } from './components/Tooltip';
export type { TooltipProps } from './components/Tooltip';
export { Avatar } from './components/Avatar';
export type { AvatarProps } from './components/Avatar';
export { Badge } from './components/Badge';
export type { BadgeProps } from './components/Badge';
export { Spinner } from './components/Spinner';
export type { SpinnerProps } from './components/Spinner';
export { Alert } from './components/Alert';
export type { AlertProps } from './components/Alert';
export { Tabs } from './components/Tabs';
export type { TabsProps, Tab } from './components/Tabs';
export { Accordion } from './components/Accordion';
export type { AccordionProps, AccordionItem } from './components/Accordion';
export { Breadcrumb } from './components/Breadcrumb';
export type { BreadcrumbProps, BreadcrumbItem } from './components/Breadcrumb';
export { Progress } from './components/Progress';
export type { ProgressProps } from './components/Progress';
export { Pagination } from './components/Pagination';
export type { PaginationProps } from './components/Pagination';
// Studio Components
export { StudioHeader } from './components/StudioHeader';
export type { StudioHeaderProps } from './components/StudioHeader';
export { ControlButton } from './components/ControlButton';
export type { ControlButtonProps } from './components/ControlButton';
export { ControlGroup } from './components/ControlGroup';
export type { ControlGroupProps } from './components/ControlGroup';
export { ControlBar } from './components/ControlBar';
export type { ControlBarProps } from './components/ControlBar';
export { IconButton } from './components/IconButton';
export type { IconButtonProps } from './components/IconButton';
export { SceneCard } from './components/SceneCard';
export type { SceneCardProps } from './components/SceneCard';
export { VideoTile } from './components/VideoTile';
export type { VideoTileProps, ConnectionQuality } from './components/VideoTile';
// Types
export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './types';
// Utils
export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers';
export * from './components'
export * from './types'
export * from './utils/platform'

View File

@ -1,154 +1,14 @@
/* avanza-ui global tokens and resets */
:root{
/* Colors */
--au-gray-950: #0b1220;
--au-gray-900: #0f172a;
--au-gray-800: #111827;
--au-gray-700: #1f2937;
--au-gray-600: #374151;
--au-gray-600-2: #4b5563;
/* Placeholder globals used by components during build */
:root {
--au-primary: #4f46e5;
--au-primary-hover: #4338ca;
--au-success-500: #10b981;
--au-warning-500: #f59e0b;
--au-danger-500: #ef4444;
--au-gray-900: #0f172a;
--au-gray-950: #0b1220;
--au-radius-md: 8px;
--au-font-bold: 700;
--au-text-primary: #f1f5f9;
--au-text-secondary: #cbd5e1;
/* Radius */
--au-radius-sm: 4px;
--au-radius-md: 8px;
--au-radius-lg: 12px;
--au-radius-full: 9999px;
/* Typography */
--au-font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--au-font-bold: 700;
--au-font-medium: 500;
--au-font-normal: 400;
/* Transitions */
--au-transition-fast: 150ms ease;
--au-transition-medium: 250ms ease;
--au-transition-slow: 400ms ease;
/* Shadows */
--au-shadow-sm: 0 4px 12px rgba(2,6,23,0.18);
--au-shadow-md: 0 8px 24px rgba(2,6,23,0.28);
}
/* Light theme overrides (if used in non-dark mode) */
[data-theme="light"]{
--au-text-primary: #1f2937;
--au-text-secondary: #6b7280;
--au-gray-950: #f8fafc;
--au-gray-900: #ffffff;
--au-gray-800: #f3f4f6;
--au-gray-700: #e5e7eb;
--au-gray-600: #9ca3af;
}
body { font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
/* Basic resets for avanza-ui components */
.au-root, .avanza-ui-root {
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: var(--au-text-primary);
}
button { font-family: inherit }
/* Ensure modal/backdrop stacking works */
.avanza-ui-modal-backdrop { z-index: 9999 }
/* Compatibility tokens for other packages (broadcast-panel / studio-panel)
These map commonly used project tokens to the avanza-ui design tokens so
all packages can import the avanza-ui globals and get consistent values.
*/
:root{
/* Broadcast-style tokens */
--primary-blue: var(--au-primary);
--primary-blue-hover: var(--au-primary-hover);
--background-color: var(--au-gray-950);
--surface-color: var(--au-gray-900);
--text-primary: var(--au-text-primary);
--text-secondary: var(--au-text-secondary);
--border-light: rgba(255,255,255,0.04);
--active-bg-light: rgba(79,70,229,0.06);
--shadow-sm: var(--au-shadow-sm);
--shadow-md: var(--au-shadow-md);
--skeleton-base: #e5e7eb;
--skeleton-highlight: #f3f4f6;
/* Surface tokens used by studio-panel */
--surface-50: #f8fafc;
--surface-900: #0f172a;
/* Studio specific tokens (map to avanza-ui tokens) */
--studio-bg-primary: var(--background-color);
--studio-bg-secondary: var(--surface-color);
--studio-bg-tertiary: var(--active-bg-light);
--studio-bg-elevated: var(--surface-color);
--studio-bg-hover: rgba(255,255,255,0.02);
--studio-border: var(--border-light);
--studio-border-light: rgba(255,255,255,0.02);
--studio-border-subtle: rgba(255,255,255,0.01);
--studio-text-primary: var(--text-primary);
--studio-text-secondary: var(--text-secondary);
--studio-text-muted: #94a3b8;
--studio-text-disabled: #9ca3af;
--studio-accent: var(--primary-blue);
--studio-accent-hover: var(--primary-blue-hover);
--studio-accent-light: rgba(79,70,229,0.08);
--studio-success: var(--au-success-500);
--studio-warning: var(--au-warning-500);
--studio-danger: var(--au-danger-500);
--studio-recording: var(--au-danger-500);
--studio-recording-pulse: rgba(239, 68, 68, 0.12);
--studio-space-xs: 4px;
--studio-space-sm: 8px;
--studio-space-md: 12px;
--studio-space-lg: 16px;
--studio-space-xl: 24px;
--studio-radius-sm: var(--au-radius-sm);
--studio-radius-md: var(--au-radius-md);
--studio-radius-lg: var(--au-radius-lg);
--studio-radius-xl: calc(var(--au-radius-lg) + 4px);
--studio-font-family: var(--au-font-family, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
--studio-text-base: 14px;
--studio-text-sm: 12px;
/* Additional studio tokens required by studio-theme.css and components */
--studio-font-normal: var(--au-font-normal, 400);
--studio-leading-normal: 1.4;
--studio-radius-full: var(--au-radius-full, 9999px);
--studio-shadow-sm: var(--au-shadow-sm);
--studio-shadow-md: var(--au-shadow-md);
--studio-shadow-lg: 0 12px 40px rgba(2,6,23,0.32);
--studio-transition: 200ms ease;
--studio-transition-fast: 120ms ease;
--studio-transition-slow: 320ms ease;
}
/* Light theme compatibility mapping */
[data-theme="light"]{
--primary-blue: var(--au-primary);
--background-color: #f7f8fa;
--surface-color: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--surface-50: #f8fafc;
--surface-900: #0f172a;
}
/* End of compatibility tokens */

View File

@ -0,0 +1,10 @@
// ...existing code...
export function isMacPlatform() {
if (typeof navigator === 'undefined') return false
return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
}
export function modifierKeyLabel() {
return isMacPlatform() ? { key: 'Meta', display: '⌘' } : { key: 'Control', display: 'Ctrl' }
}

View File

@ -8,10 +8,10 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noEmit": false,
"jsx": "react-jsx",
/* Linting */
@ -26,9 +26,10 @@
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
"sourceMap": true,
"outDir": "dist",
"declarationDir": "dist/types"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

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

View File

@ -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/<id>/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.

View File

@ -301,10 +301,30 @@ async function createLivekitTokenFor(room: string, username: string) {
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');
let h = decoded;
try { h = JSON.parse(decoded); }
catch (e) { /* keep raw */ }
// `decoded` may be a string or JSON; use `any` to avoid strict TS errors when inspecting header fields
let h: any = decoded;
try { h = JSON.parse(decoded); } catch (e) { /* keep raw string if parsing fails */ }
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 as any).alg && String((h as any).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,60 @@ 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: any = await validateTokenWithLiveKit(token);
// validateTokenWithLiveKit may return { ok: false, error: '...' } without numeric status
const statusCode: number = (result && typeof result.status === 'number') ? result.status : (result && result.ok === false ? 502 : 200);
return res.status(statusCode).json({ ok: !!result.ok, status: result.status ?? statusCode, body: result.body ?? result.error });
} catch (err) {
console.error('[backend-api] validate proxy failed', err);
return res.status(500).json({ error: 'validate_failed', details: String(err) });
@ -413,14 +461,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 +539,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 +779,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 +825,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 +883,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) });
}
});

View File

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

View File

@ -0,0 +1,18 @@
# Broadcast Panel — Mock Studio (deprecated)
La funcionalidad de "mock studio" integrada (toggle runtime y variable de entorno `VITE_MOCK_STUDIO`) ha sido eliminada del flujo principal de la aplicación.
Motivo
- El modo mock introducía complejidad en el código de producción y causaba confusiones al depurar flujos reales. Para asegurar comportamiento consistente, el panel ahora usa siempre el `backend-api` real para crear sesiones y tokens.
Qué cambió
- Se eliminó el toggle `MockToggle` del UI y la detección de `VITE_MOCK_STUDIO` en runtime.
- `useStudioLauncher` ya no genera sesiones mock; siempre usa la API real (`/api/session` / `connection-details`) para crear/obtener tokens.
- Las referencias a `localStorage['avz:mock_studio']` fueron retiradas del flujo principal.
Pruebas y E2E
- Si necesitas ejecutar pruebas E2E o flujos aislados con un servidor mock, existen utilidades en la carpeta `e2e/`:
- `e2e/mock_server.js` y `e2e/run_e2e_with_mock.js` siguen disponibles para pruebas locales y no forman parte del flujo de la aplicación.
- Usa esos scripts explícitamente cuando quieras simular la infra (no se cargan por defecto en el dev server).
Si necesitas que vuelva a habilitarse un modo mock controlado (documentado y con feature flag), puedo preparar un PR con una implementación aislada y conmutador que no afecte el código en producción: dime si quieres que lo haga.

View File

@ -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_<timestamp>/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.

View File

@ -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); });

View File

@ -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/<timestamp>/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/<room>
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);
}
})();

View File

@ -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'] } },
],
});

View File

@ -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 <<EOF
Usage: run_e2e_auto.sh [--local] [--show] [--room NAME] [--ws URL] [--token TOKEN] [--token-server URL] [--broadcast-url URL]
Options:
--local Use run_local_e2e.js (con --ws o REMOTE_WS para apuntar a Chrome remoto)
--show Pass flag --show to the local runner (visual mode)
--room NAME Room name to create (env ROOM)
--ws URL Browserless/remote ws base (env BROWSERLESS_WS / REMOTE_WS)
--token TOKEN Browserless token (env BROWSERLESS_TOKEN)
--token-server URL Token server (env TOKEN_SERVER)
--broadcast-url URL Broadcast panel URL (env BROADCAST_URL)
--out DIR Directory where logs/screenshots will be stored (overrides default)
-h, --help Show this help
Examples:
# Run against browserless remote (recommended)
BROWSERLESS_TOKEN=xxx ./run_e2e_auto.sh --room iuqiw-aksjka
# Run local remote-debugging chrome
./run_e2e_auto.sh --local --ws http://localhost:9222 --show
EOF
}
# simple arg loop
while [[ $# -gt 0 ]]; do
case "$1" in
--local) LOCAL_MODE=1; shift ;;
--show) SHOW=1; shift ;;
--room) ROOM="$2"; shift 2 ;;
--ws) BROWSERLESS_WS="$2"; shift 2 ;;
--token) BROWSERLESS_TOKEN="$2"; shift 2 ;;
--token-server) TOKEN_SERVER="$2"; shift 2 ;;
--broadcast-url) BROADCAST_URL="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1"; usage; exit 2 ;;
esac
done
# prepare outdir with timestamp if not provided
if [[ -z "${OUT_DIR}" ]]; then
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
OUT_DIR="./e2e/out/$TIMESTAMP"
fi
mkdir -p "$OUT_DIR"
# decide runner
if [[ "$LOCAL_MODE" -eq 1 ]]; then
RUNNER_JS="e2e/run_local_e2e.js"
# If SHOW requested, pass flag through env to the runner via --show
RUN_CMD=(node "$RUNNER_JS")
if [[ "$SHOW" -eq 1 ]]; then RUN_CMD+=(--show); fi
else
RUNNER_JS="e2e/run_browserless_e2e.js"
RUN_CMD=(node "$RUNNER_JS")
fi
echo "[run_e2e_auto] Starting E2E runner"
echo " Runner: $RUNNER_JS"
echo " OUT_DIR: $OUT_DIR"
echo " ROOM: $ROOM"
if [[ -n "$BROWSERLESS_TOKEN" ]]; then
echo " BROWSERLESS_TOKEN: (present)"
else
echo " BROWSERLESS_TOKEN: (empty)"
fi
# Build environment for the child process
export BROWSERLESS_WS BROWSERLESS_TOKEN ROOM TOKEN_SERVER BROADCAST_URL OUT_DIR
# Show final command (for debugging)
echo "[run_e2e_auto] Executing: ${RUN_CMD[@]}"
# Run and tee output
LOGFILE="$OUT_DIR/e2e.log"
(
echo "=== START $(date -u) ==="
echo "Command: ${RUN_CMD[@]}"
echo "Environment: BROWSERLESS_WS=$BROWSERLESS_WS ROOM=$ROOM TOKEN_SERVER=$TOKEN_SERVER BROADCAST_URL=$BROADCAST_URL"
echo "--- OUTPUT ---"
) > "$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

View File

@ -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 /<sessionId>
// - 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 /<id>
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);
})();

91
packages/broadcast-panel/e2e/start-chrome-remote.sh Normal file → Executable file
View File

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

View File

@ -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);
}
})();

View File

@ -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) {}
}
});

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,83 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Crear sesión — Broadcast Panel</title>
<style>
body { font-family: Inter, system-ui, Arial; background:#0b0b0d; color:#eee; display:flex; align-items:center; justify-content:center; height:100vh; }
.card { background:#0f1724; padding:20px;border-radius:8px; width:520px; box-shadow:0 6px 18px rgba(2,6,23,0.6); }
label{display:block;margin-top:12px;font-size:13px;color:#cbd5e1}
input{width:100%;padding:8px;border-radius:6px;border:1px solid #334155;background:#020617;color:#fff}
button{margin-top:14px;padding:10px 14px;border-radius:6px;border:none;background:#2563eb;color:#fff;cursor:pointer}
pre{background:#020617;padding:10px;border-radius:6px;overflow:auto;color:#9aa8ff}
</style>
</head>
<body>
<div class="card">
<h2>Crear sesión (Broadcast Panel)</h2>
<p>Genera una sessionId en el token-server y abre la ruta del broadcast panel: <code>https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/&lt;sessionId&gt;</code></p>
<label>Room (opcional)</label>
<input id="room" placeholder="broadcast-room-name (default: random)" />
<label>Username</label>
<input id="username" placeholder="Xesar" value="Guest" />
<label>Token server (opcional)</label>
<input id="backend" placeholder="https://avanzacast-servertokens.bfzqqk.easypanel.host" value="https://avanzacast-servertokens.bfzqqk.easypanel.host" />
<div style="display:flex;gap:8px">
<button id="createBtn">Crear sesión y abrir Studio</button>
<button id="createOnly">Crear sin abrir</button>
</div>
<div id="log" style="margin-top:12px;display:none">
<div style="font-size:13px;color:#9ca3af;margin-bottom:6px">Resultado (JSON):</div>
<pre id="out"></pre>
</div>
<div style="margin-top:12px;color:#94a3b8;font-size:13px">Nota: este script intenta POST `/api/session` al token-server y abrir la URL del broadcast panel producida.</div>
</div>
<script>
async function createSession(openAfter = false) {
const room = document.getElementById('room').value || undefined;
const username = document.getElementById('username').value || 'Guest';
let backend = document.getElementById('backend').value || '';
if (!backend) backend = 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
const url = backend.replace(/\/$/, '') + '/api/session';
const out = document.getElementById('out');
const log = document.getElementById('log');
log.style.display = 'block';
out.textContent = 'Enviando petición...';
try {
const body = { username };
if (room) body.room = room;
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const txt = await resp.text();
let json;
try { json = JSON.parse(txt); } catch (e) { json = { raw: txt } }
out.textContent = JSON.stringify({ status: resp.status, body: json }, null, 2);
if (!resp.ok) return;
const id = json && (json.id || json.sessionId) ? (json.id || json.sessionId) : null;
const studioHost = (new URL(window.location.href)).origin || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
const sessionUrl = json && json.studioUrl ? json.studioUrl : (studioHost.replace(/\/$/, '') + '/' + encodeURIComponent(id || ''));
out.textContent += '\n\nOpen URL:\n' + sessionUrl;
if (openAfter && sessionUrl) {
window.location.href = sessionUrl;
}
} catch (err) {
out.textContent = 'Error: ' + String(err);
}
}
document.getElementById('createBtn').addEventListener('click', () => createSession(true));
document.getElementById('createOnly').addEventListener('click', () => createSession(false));
</script>
</body>
</html>

View File

@ -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);
}
})();

View File

@ -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();

View File

@ -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<BannerProps> = ({ 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' ? (
<svg className="w-5 h-5 mr-3 text-white/90" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01"></path></svg>
) : null
const Banner: React.FC<BannerProps> = ({
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" ? (
<svg
className="w-5 h-5 mr-3 text-white/90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
></path>
<path
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v4m0 4h.01"
></path>
</svg>
) : null;
return (
<div className={`${bg} text-white px-4 py-3 rounded-md mb-4 shadow-sm flex items-center justify-between`}>
<div
className={`${bg} text-white px-4 py-3 rounded-md mb-4 shadow-sm flex items-center justify-between`}
>
<div className="flex items-center">
{icon}
<div className="text-sm font-medium">{children}</div>
@ -26,17 +59,22 @@ const Banner: React.FC<BannerProps> = ({ children, type = 'info', onClose, actio
<button
onClick={onAction}
disabled={actionDisabled}
className={`px-3 py-1 rounded-md text-sm font-semibold ${actionDisabled ? 'bg-white/20 text-white/60 cursor-not-allowed' : 'bg-white/10 hover:bg-white/20 text-white'}`}
className={`px-3 py-1 rounded-md text-sm font-semibold ${actionDisabled ? "bg-white/20 text-white/60 cursor-not-allowed" : "bg-white/10 hover:bg-white/20 text-white"}`}
>
{actionLabel}
</button>
)}
{onClose && (
<button onClick={onClose} className="text-white/80 underline ml-2 text-sm">Cerrar</button>
<button
onClick={onClose}
className="text-white/80 underline ml-2 text-sm"
>
Cerrar
</button>
)}
</div>
</div>
)
}
);
};
export default Banner
export default Banner;

View File

@ -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;
}

View File

@ -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<DropdownProps> = ({ 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 (
<div className={styles.dropdown} ref={dropdownRef}>
<div onClick={() => setIsOpen(!isOpen)}>
{trigger}
</div>
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
{isOpen && (
<div className={styles.dropdownMenu}>
{items.map((item, index) => (
@ -52,17 +53,31 @@ export const Dropdown: React.FC<DropdownProps> = ({ 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 (
<div className={headerClasses} {...cpRest}>
{item.icon && <span className={styles.icon}>{item.icon}</span>}
{item.icon && (
<span className={styles.icon}>{item.icon}</span>
)}
{
// 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 <span className={classes} {...lpOther}>{item.label}</span>;
const { className: lpClassName, ...lpOther } =
lp as any;
const classes = [
styles.dropdownHeaderLabel,
lpClassName,
]
.filter(Boolean)
.join(" ");
return (
<span className={classes} {...lpOther}>
{item.label}
</span>
);
})()
}
</div>
@ -72,7 +87,9 @@ export const Dropdown: React.FC<DropdownProps> = ({ 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 (
<button
className={btnClasses}
@ -82,15 +99,20 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
}}
{...cpRest}
>
{item.icon && <span className={styles.icon}>{item.icon}</span>}
{
(() => {
const lp = item.labelProps || {};
const { className: lpClassName, ...lpOther } = lp as any;
const classes = [lpClassName].filter(Boolean).join(' ');
return <span className={classes} {...lpOther}>{item.label}</span>;
})()
}
{item.icon && (
<span className={styles.icon}>{item.icon}</span>
)}
{(() => {
const lp = item.labelProps || {};
const { className: lpClassName, ...lpOther } =
lp as any;
const classes = [lpClassName].filter(Boolean).join(" ");
return (
<span className={classes} {...lpOther}>
{item.label}
</span>
);
})()}
</button>
);
})()

View File

@ -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<Props> = ({ open, onClose }) => {
const [enabled, setEnabled] = useState(true)
const [enabled, setEnabled] = useState(true);
return (
<Modal
@ -21,14 +27,14 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
width="md"
footer={
<>
<button
<button
onClick={onClose}
style={{
padding: '8px 16px',
background: 'transparent',
border: '1px solid #dadce0',
borderRadius: '4px',
cursor: 'pointer'
padding: "8px 16px",
background: "transparent",
border: "1px solid #dadce0",
borderRadius: "4px",
cursor: "pointer",
}}
>
Cancelar
@ -36,12 +42,12 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
<button
onClick={onClose}
style={{
padding: '8px 24px',
background: '#1a73e8',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
padding: "8px 24px",
background: "#1a73e8",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Aceptar
@ -49,21 +55,25 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
</>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<p style={{ margin: 0, color: '#5f6368', fontSize: '14px' }}>
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer más en{' '}
<ModalLink href="https://example.com/docs">nuestra documentación</ModalLink>.
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p style={{ margin: 0, color: "#5f6368", fontSize: "14px" }}>
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer
más en{" "}
<ModalLink href="https://example.com/docs">
nuestra documentación
</ModalLink>
.
</p>
<ModalCopyInput
<ModalCopyInput
value="https://example.com/invite/abc123"
buttonText="Copiar enlace"
/>
<ModalShareButtons
onGmail={() => console.log('Gmail')}
onEmail={() => console.log('Email')}
onMessenger={() => console.log('Messenger')}
onGmail={() => console.log("Gmail")}
onEmail={() => console.log("Email")}
onMessenger={() => console.log("Messenger")}
/>
<ModalToggle
@ -74,7 +84,7 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
/>
</div>
</Modal>
)
}
);
};
export default ExampleModal
export default ExampleModal;

View File

@ -9,7 +9,9 @@
position: sticky;
top: 0;
z-index: 50;
transition: background-color 0.3s ease, border-color 0.3s ease;
transition:
background-color 0.3s ease,
border-color 0.3s ease;
}
.headerActions {
@ -36,7 +38,7 @@
.segmentControl {
display: inline-flex;
background-color: rgba(0,0,0,0.06);
background-color: rgba(0, 0, 0, 0.06);
border-radius: 999px;
padding: 4px;
gap: 6px;
@ -62,8 +64,12 @@
}
.activeSegment {
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.04));
box-shadow: 0 6px 14px rgba(2,6,23,0.4);
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.02),
rgba(0, 0, 0, 0.04)
);
box-shadow: 0 6px 14px rgba(2, 6, 23, 0.4);
color: var(--surface-color);
}
@ -114,7 +120,7 @@
}
.userEmail {
opacity: 0.90;
opacity: 0.9;
font-size: 14px;
color: var(--text-secondary);
}
@ -126,7 +132,7 @@
/* Dropdown trigger style polished */
.userMenu:after {
content: '';
content: "";
}
.userMenu svg {

View File

@ -1,52 +1,76 @@
import React from 'react'
import { MdLightMode, MdDarkMode, MdNotifications, MdPerson, MdLogout, MdHelpOutline } from 'react-icons/md'
import { useTheme } from './ThemeProvider'
import { Tooltip } from './Tooltip'
import { Dropdown } from './Dropdown'
import styles from './Header.module.css'
import React from "react";
import {
MdLightMode,
MdDarkMode,
MdNotifications,
MdPerson,
MdLogout,
MdHelpOutline,
} from "react-icons/md";
import { useTheme } from "./ThemeProvider";
import { Tooltip } from "./Tooltip";
import { Dropdown } from "./Dropdown";
import styles from "./Header.module.css";
const Header: React.FC = () => {
const { theme, resolvedTheme, setThemeMode } = useTheme()
const { theme, resolvedTheme, setThemeMode } = useTheme();
const handleLogout = () => {
localStorage.removeItem('mock_user')
window.location.href = '/auth/login'
}
localStorage.removeItem("mock_user");
window.location.href = "/auth/login";
};
// Read mock user from localStorage (if set elsewhere in the app)
const storedUser = typeof window !== 'undefined' ? localStorage.getItem('mock_user') : null
let email = 'Usuario'
let avatarUrl: string | null = null
let displayName: string | null = null
const storedUser =
typeof window !== "undefined" ? localStorage.getItem("mock_user") : null;
let email = "Usuario";
let avatarUrl: string | null = null;
let displayName: string | null = null;
try {
if (storedUser) {
const parsed = JSON.parse(storedUser)
email = parsed.email || storedUser || 'Usuario'
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null
displayName = parsed.name || parsed.fullname || null
const parsed = JSON.parse(storedUser);
email = parsed.email || storedUser || "Usuario";
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null;
displayName = parsed.name || parsed.fullname || null;
}
} catch (e) {
email = storedUser || 'Usuario'
email = storedUser || "Usuario";
}
const computeInitials = (nameOrEmail: string) => {
if (!nameOrEmail) return 'U'
const name = nameOrEmail.split('@')[0]
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return nameOrEmail.slice(0, 2).toUpperCase()
}
if (!nameOrEmail) return "U";
const name = nameOrEmail.split("@")[0];
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return nameOrEmail.slice(0, 2).toUpperCase();
};
const initials = computeInitials(displayName || email)
const initials = computeInitials(displayName || email);
const avatarElement = avatarUrl ? (
<img src={avatarUrl} alt="avatar" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
<img
src={avatarUrl}
alt="avatar"
style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover" }}
/>
) : (
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--primary-blue)', color: 'white', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 700 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: "var(--primary-blue)",
color: "white",
display: "grid",
placeItems: "center",
fontSize: 12,
fontWeight: 700,
}}
>
{initials}
</div>
)
);
const userMenuItems = [
// non-interactive header with email
@ -54,56 +78,53 @@ const Header: React.FC = () => {
label: email,
icon: avatarElement,
disabled: true,
containerProps: { 'data-test': 'user-email', id: 'user-email' },
labelProps: { style: { opacity: 0.7 } }
containerProps: { "data-test": "user-email", id: "user-email" },
labelProps: { style: { opacity: 0.7 } },
},
{
label: 'Mi perfil',
label: "Mi perfil",
icon: <MdPerson size={18} />,
onClick: () => console.log('Ir a perfil'),
divider: true // separator after the header
onClick: () => console.log("Ir a perfil"),
divider: true, // separator after the header
},
{
label: 'Ayuda',
label: "Ayuda",
icon: <MdHelpOutline size={18} />,
onClick: () => console.log('Ir a ayuda')
onClick: () => console.log("Ir a ayuda"),
},
{
label: 'Cerrar sesión',
label: "Cerrar sesión",
icon: <MdLogout size={18} />,
onClick: handleLogout,
divider: true
}
]
divider: true,
},
];
return (
<header className={styles.header}>
<div></div> {/* Spacer */}
<div className={styles.headerActions}>
<button className={styles.planButton}>
Mejora tu plan
</button>
<button className={styles.planButton}>Mejora tu plan</button>
{/* Segmented theme control: Sistema / Claro / Oscuro */}
<div className={styles.segmentControl} role="tablist" aria-label="Tema">
<button
className={`${styles.segmentButton} ${theme === 'system' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('system')}
<button
className={`${styles.segmentButton} ${theme === "system" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("system")}
title="Usar tema del sistema"
>
<span className={styles.segmentIcon}></span>
</button>
<button
className={`${styles.segmentButton} ${theme === 'light' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('light')}
<button
className={`${styles.segmentButton} ${theme === "light" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("light")}
title="Modo claro"
>
<MdLightMode size={16} />
</button>
<button
className={`${styles.segmentButton} ${theme === 'dark' ? styles.activeSegment : ''}`}
onClick={() => setThemeMode('dark')}
<button
className={`${styles.segmentButton} ${theme === "dark" ? styles.activeSegment : ""}`}
onClick={() => setThemeMode("dark")}
title="Modo oscuro"
>
<MdDarkMode size={16} />
@ -116,7 +137,7 @@ const Header: React.FC = () => {
<span className={styles.notificationDot}></span>
</button>
</Tooltip>
<Dropdown
trigger={
<div className={styles.userMenu}>
@ -127,7 +148,7 @@ const Header: React.FC = () => {
/>
</div>
</header>
)
}
);
};
export default Header
export default Header;

View File

@ -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;
}

View File

@ -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<Props> = ({ 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 (
<Modal
@ -32,19 +41,21 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
>
<div className={styles.content}>
<p className={styles.helpText}>
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{" "}
<ModalLink href="https://support.streamyard.com/hc/en-us/articles/360043291612">
instrucciones para invitados
</ModalLink>.
</ModalLink>
.
</p>
<p className={styles.planText}>
Puedes tener hasta 6 personas en pantalla a la vez. {' '}
<ModalLink href="/pricing">Mejora tu plan</ModalLink> {' '}
si necesitas más.
Puedes tener hasta 6 personas en pantalla a la vez.{" "}
<ModalLink href="/pricing">Mejora tu plan</ModalLink> si necesitas
más.
</p>
<ModalCopyInput
<ModalCopyInput
value={link}
buttonText="Copiar"
className={styles.copyInput}
@ -66,7 +77,7 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
/>
</div>
</Modal>
)
}
);
};
export default InviteGuestsModal
export default InviteGuestsModal;

View File

@ -0,0 +1,4 @@
// MockToggle removed: mock studio feature is disabled in this codebase.
// This file was intentionally left blank to avoid build errors from leftover imports.
export default function MockToggle() { return null as any }

View File

@ -65,7 +65,7 @@
}
.selectedDestination::before {
content: '';
content: "";
position: absolute;
top: 0;
left: 50%;

View File

@ -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 {

View File

@ -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<Transmission[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [currentPage, setCurrentPage] = useState<string>('inicio')
const [transmissions, setTransmissions] = useState<Transmission[]>([]);
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<string>(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 <StudioConnector />
return <Studio />;
}
return (
@ -76,8 +95,16 @@ const PageContainer: React.FC = () => {
<div className={styles.wrapContent}>
<div className={styles.leftStack}>
{/* Sección Crear */}
<section style={{ marginBottom: '0' }}>
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '18px' }}>Crear</h2>
<section style={{ marginBottom: "0" }}>
<h2
style={{
fontSize: "22px",
fontWeight: 600,
marginBottom: "18px",
}}
>
Crear
</h2>
{isLoading ? (
<div className={styles.createGrid}>
<SkeletonCard />
@ -91,41 +118,63 @@ const PageContainer: React.FC = () => {
className={`${styles.createCard} ${styles.cardBlue}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(26,115,232,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(26,115,232,0.08)" }}
>
<MdVideocam size={20} />
</div>
<span>Transmisión en vivo</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(26,115,232,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(26,115,232,0.08)" }}
>
<PlusLarge size={20} color="var(--primary-blue)" />
</div>
</span>
</button>
<button className={`${styles.createCard} ${styles.cardRed}`}>
<button
className={`${styles.createCard} ${styles.cardRed}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(234,67,53,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(234,67,53,0.08)" }}
>
<MdFiberManualRecord size={20} />
</div>
<span>Grabación</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(234,67,53,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(234,67,53,0.08)" }}
>
<PlusLarge size={20} color="#ea4335" />
</div>
</span>
</button>
<button className={`${styles.createCard} ${styles.cardGreen}`}>
<button
className={`${styles.createCard} ${styles.cardGreen}`}
>
<div className={styles.createCardInner}>
<div className={styles.createIconBox} style={{ background: 'rgba(52,168,83,0.08)' }}>
<div
className={styles.createIconBox}
style={{ background: "rgba(52,168,83,0.08)" }}
>
<MdSchool size={20} />
</div>
<span>Seminario web On-Air</span>
</div>
<span className={styles.createPlus}>
<div className={styles.plusBadge} style={{ background: 'rgba(52,168,83,0.08)' }}>
<div
className={styles.plusBadge}
style={{ background: "rgba(52,168,83,0.08)" }}
>
<PlusLarge size={20} color="#34a853" />
</div>
</span>
@ -136,7 +185,13 @@ const PageContainer: React.FC = () => {
{/* Sección Transmisiones y grabaciones */}
<section>
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '24px' }}>
<h2
style={{
fontSize: "22px",
fontWeight: 600,
marginBottom: "24px",
}}
>
Transmisiones y grabaciones
</h2>
<TransmissionsTable
@ -162,7 +217,7 @@ const PageContainer: React.FC = () => {
</div>
</div>
</ThemeProvider>
)
}
);
};
export default PageContainer
export default PageContainer;

Some files were not shown because too many files have changed in this diff Show More