feat: add session API routes, in-memory store, and E2E test utilities; update styles and refactor components for consistency
This commit is contained in:
parent
8b458a3ddf
commit
d162014030
110
app/api/session/README.md
Normal file
110
app/api/session/README.md
Normal 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`**
|
||||
|
||||
18
app/api/session/[id]/route.ts
Normal file
18
app/api/session/[id]/route.ts
Normal 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' } });
|
||||
}
|
||||
}
|
||||
22
app/api/session/proxy/[id]/route.ts
Normal file
22
app/api/session/proxy/[id]/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split('/');
|
||||
const id = parts[parts.length - 1] || '';
|
||||
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
|
||||
if (!backend) {
|
||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const resp = await fetch(`${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(id)}`);
|
||||
const text = await resp.text();
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
return new Response(text, { status: resp.status, headers });
|
||||
} catch (err) {
|
||||
console.error('proxy GET /api/session/:id error', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
19
app/api/session/proxy/route.ts
Normal file
19
app/api/session/proxy/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
|
||||
if (!backend) {
|
||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const url = `${backend.replace(/\/$/, '')}/api/session`;
|
||||
const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const text = await resp.text();
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
return new Response(text, { status: resp.status, headers });
|
||||
} catch (err) {
|
||||
console.error('proxy /api/session error', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
|
||||
44
app/api/session/route.ts
Normal file
44
app/api/session/route.ts
Normal 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
36
app/api/session/store.ts
Normal 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());
|
||||
}
|
||||
|
||||
97
app/rooms/[roomName]/AutoRequestAndInject.tsx
Normal file
97
app/rooms/[roomName]/AutoRequestAndInject.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/rooms/[roomName]/page.tsx
Normal file
18
app/rooms/[roomName]/page.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
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
BIN
docs/img_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/img_4.png
Normal file
BIN
docs/img_4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
215
docs/portal_livekit.md
Normal file
215
docs/portal_livekit.md
Normal 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`).
|
||||
BIN
docs/sequence_livekit.png
Normal file
BIN
docs/sequence_livekit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 237 KiB |
@ -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`.
|
||||
|
||||
26
e2e/_launch_chrome_puppeteer.js
Normal file
26
e2e/_launch_chrome_puppeteer.js
Normal 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
34
e2e/run-remote-chrome.sh
Normal 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
116
e2e/run-smoke-local.sh
Normal 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
44
e2e/run-tests.js
Normal 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
23
e2e/tests.json
Normal 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"
|
||||
}
|
||||
]
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) });
|
||||
}
|
||||
|
||||
3
package-lock.json
generated
3
package-lock.json
generated
@ -31279,7 +31279,8 @@
|
||||
"puppeteer-core": "^20.9.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.5.0"
|
||||
"react-icons": "^5.5.0",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:dev:*\"",
|
||||
"e2e:remote-chrome": "bash e2e/run-remote-chrome.sh",
|
||||
"dev:landing": "npm run dev --workspace=packages/landing-page",
|
||||
"dev:api": "npm run dev --workspace=packages/backend-api",
|
||||
"dev:studio": "npm run dev --workspace=packages/broadcast-studio",
|
||||
|
||||
7
packages/backend-api/.env.debug
Normal file
7
packages/backend-api/.env.debug
Normal 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
|
||||
|
||||
52
packages/backend-api/README.DEBUG.md
Normal file
52
packages/backend-api/README.DEBUG.md
Normal 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.
|
||||
|
||||
@ -305,6 +305,26 @@ async function createLivekitTokenFor(room: string, username: string) {
|
||||
try { h = JSON.parse(decoded); }
|
||||
catch (e) { /* keep raw */ }
|
||||
console.log('[LIVEKIT] token header:', h);
|
||||
|
||||
// If SDK produced a token with non-HS256 alg, and we have LIVEKIT_API_SECRET, re-sign payload with HS256
|
||||
try {
|
||||
const secretForResign = LIVEKIT_API_SECRET || process.env.LIVEKIT_API_SECRET;
|
||||
if (secretForResign && typeof h === 'object' && h.alg && String(h.alg).toUpperCase() !== 'HS256') {
|
||||
try {
|
||||
const parts = (token as string).split('.');
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
const payloadRaw = parts[1] || '';
|
||||
const payloadJson = JSON.parse(Buffer.from(pad(payloadRaw).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
||||
// preserve iat/exp if present, stringify payload as-is and sign with HS256
|
||||
const jwtLib = require('jsonwebtoken');
|
||||
const newToken = jwtLib.sign(payloadJson, secretForResign, { algorithm: 'HS256' });
|
||||
console.log('[LIVEKIT] SDK token used alg=' + (h.alg || 'unknown') + ' — re-signed with HS256 fallback');
|
||||
return { token: newToken, url: LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880') };
|
||||
} catch (e) {
|
||||
console.warn('[LIVEKIT] failed to re-sign token with HS256 fallback', String(e));
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore fallback errors */ }
|
||||
}
|
||||
} catch (e) { console.warn('[LIVEKIT] failed to decode token header', String(e)); }
|
||||
// Prefer explicit websocket URL env, else derive from LIVEKIT_URL (http(s) -> ws(s))
|
||||
@ -349,32 +369,58 @@ app.get('/api/token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// New endpoint: connection-details
|
||||
app.get('/api/connection-details', async (req, res) => {
|
||||
try {
|
||||
// Accept either 'room' or 'roomName' for compatibility with frontend
|
||||
const room = (req.query.room && typeof req.query.room === 'string') ? req.query.room : undefined;
|
||||
const roomName = (req.query.roomName && typeof req.query.roomName === 'string') ? req.query.roomName : undefined;
|
||||
const participantName = (req.query.participantName && typeof req.query.participantName === 'string') ? req.query.participantName : undefined;
|
||||
const finalRoom = room || roomName;
|
||||
if (!finalRoom) return res.status(400).json({ error: 'room or roomName query param is required' });
|
||||
if (!participantName) return res.status(400).json({ error: 'participantName query param is required' });
|
||||
|
||||
const { token, url } = await createLivekitTokenFor(finalRoom, participantName);
|
||||
|
||||
// create a session id to store the token for short-lived retrieval if needed
|
||||
const sessionId = generateShortId(9);
|
||||
await saveSession(sessionId, { token, url, room: finalRoom, username: participantName }, SESSION_TTL);
|
||||
|
||||
// Return roomName for frontend compatibility
|
||||
return res.json({ serverUrl: url, participantToken: token, sessionId, roomName: finalRoom });
|
||||
} catch (err) {
|
||||
console.error('[backend-api] /api/connection-details failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
async function validateTokenWithLiveKit(token: string) {
|
||||
try {
|
||||
// derive http(s) origin from LIVEKIT_WS_URL or LIVEKIT_URL
|
||||
const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host';
|
||||
let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
|
||||
try { const u = new URL(httpUrl); httpUrl = `${u.protocol}//${u.host}`; } catch (e) {}
|
||||
const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`;
|
||||
const resp = await fetch(validateUrl, { method: 'GET' });
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
const text = await resp.text();
|
||||
if (ct.includes('application/json')) {
|
||||
try { return { status: resp.status, ok: resp.ok, body: JSON.parse(text) }; } catch (e) { return { status: resp.status, ok: resp.ok, body: text }; }
|
||||
}
|
||||
return { status: resp.status, ok: resp.ok, body: text };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// Debug/validate endpoint: validate a token against LiveKit from the backend
|
||||
app.all('/api/session/validate', async (req, res) => {
|
||||
try {
|
||||
const token = (req.method === 'GET' ? req.query.token : req.body?.token) || req.query.token || req.body?.token;
|
||||
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
|
||||
|
||||
// derive http(s) origin from LIVEKIT_WS_URL
|
||||
const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host';
|
||||
let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://');
|
||||
try {
|
||||
const u = new URL(httpUrl);
|
||||
httpUrl = `${u.protocol}//${u.host}`;
|
||||
} catch (e) {
|
||||
// keep as-is
|
||||
}
|
||||
|
||||
const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`;
|
||||
console.log('[backend-api] proxy validate to', validateUrl.slice(0, 200));
|
||||
|
||||
const resp = await fetch(validateUrl, { method: 'GET' });
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
const text = await resp.text();
|
||||
if (ct.includes('application/json')) {
|
||||
try { const json = JSON.parse(text); return res.status(resp.status).json({ ok: resp.ok, status: resp.status, body: json }); } catch (e) {}
|
||||
}
|
||||
return res.status(resp.status).send(text);
|
||||
const result = await validateTokenWithLiveKit(token);
|
||||
return res.status(result.status).json({ ok: result.ok, status: result.status, body: result.body });
|
||||
} catch (err) {
|
||||
console.error('[backend-api] validate proxy failed', err);
|
||||
return res.status(500).json({ error: 'validate_failed', details: String(err) });
|
||||
@ -413,14 +459,55 @@ app.post('/api/session', async (req, res) => {
|
||||
? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`
|
||||
: `${studioBase}/?session=${encodeURIComponent(id)}`;
|
||||
|
||||
return res.json({
|
||||
// Prepare base response
|
||||
const responsePayload: any = {
|
||||
id,
|
||||
studioUrl: `${studioBase}/${id}`,
|
||||
// Ruta amigable para el frontend: /studio/:id
|
||||
studioUrl: `${studioBase}/studio/${id}`,
|
||||
redirectUrl,
|
||||
ttlSeconds: ttlSec,
|
||||
room,
|
||||
username,
|
||||
});
|
||||
};
|
||||
|
||||
// In dev or when explicitly enabled, attach decoded header of generated token to help debugging
|
||||
try {
|
||||
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
if (token && token.split('.').length >= 2) {
|
||||
const header = token.split('.')[0];
|
||||
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
|
||||
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
||||
try { responsePayload.debugHeader = JSON.parse(decoded); } catch (e) { responsePayload.debugHeader = decoded }
|
||||
|
||||
// Extra debug: attempt to verify HS256 signature locally using LIVEKIT_API_SECRET (only for debug)
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
// do a best-effort verify using jsonwebtoken
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
let verified = null;
|
||||
try {
|
||||
verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
responsePayload.debugVerifyHS256 = { ok: true, payload: verified };
|
||||
} catch (verifyErr) {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: (verifyErr && (verifyErr as any).message) || String(verifyErr) };
|
||||
}
|
||||
} catch (e) {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: 'jsonwebtoken_not_available' };
|
||||
}
|
||||
} else {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow debug verification errors
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore debug attach errors */ }
|
||||
|
||||
return res.json(responsePayload);
|
||||
} catch (err) {
|
||||
console.error('Failed to create session', err);
|
||||
return res.status(500).json({ error: String(err) });
|
||||
@ -450,7 +537,19 @@ app.get('/api/session/:id/token', async (req, res) => {
|
||||
const s = await getSession(id);
|
||||
if (!s) return res.status(404).json({ error: 'not_found' });
|
||||
const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000));
|
||||
return res.json({ token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url });
|
||||
const payload: any = { token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url };
|
||||
try {
|
||||
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
const token = s.token || '';
|
||||
if (typeof token === 'string' && token.split('.').length >= 2) {
|
||||
const header = token.split('.')[0];
|
||||
const padded = header + '='.repeat((4 - (header.length % 4)) % 4);
|
||||
const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
|
||||
try { payload.debugHeader = JSON.parse(decoded); } catch (e) { payload.debugHeader = decoded }
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore debug attach errors */ }
|
||||
return res.json(payload);
|
||||
} catch (err) {
|
||||
console.error('GET /api/session/:id/token failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
@ -678,7 +777,7 @@ app.post('/api/broadcasts/:id/session', async (req, res) => {
|
||||
|
||||
const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
|
||||
const redirectUrl = `${studioBase}/?session=${encodeURIComponent(sid)}`;
|
||||
return res.json({ id: sid, studioUrl: `${studioBase}/${sid}`, redirectUrl, ttlSeconds: ttlSec });
|
||||
return res.json({ id: sid, studioUrl: `${studioBase}/studio/${sid}`, redirectUrl, ttlSeconds: ttlSec });
|
||||
} catch (err) {
|
||||
console.error('Failed to create broadcast session', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
@ -724,7 +823,7 @@ app.post('/api/internal/session', async (req, res) => {
|
||||
const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
|
||||
const redirectUrl = `${studioBase}/?session=${encodeURIComponent(id)}`;
|
||||
|
||||
return res.json({ id, studioUrl: `${studioBase}/${id}`, redirectUrl, ttlSeconds: ttlSec, token });
|
||||
return res.json({ id, studioUrl: `${studioBase}/studio/${id}`, redirectUrl, ttlSeconds: ttlSec, token });
|
||||
} catch (err) {
|
||||
console.error('Failed to create internal session', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
@ -782,3 +881,138 @@ app.get('/debug/session/:id/header', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint: verify a token locally (only enabled in development or when ALLOW_DEBUG_TOKEN_HEADER=1)
|
||||
app.post('/debug/verify-token', async (req, res) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') {
|
||||
return res.status(403).json({ error: 'not_allowed' });
|
||||
}
|
||||
const token = (req.body && req.body.token) || req.query.token || '';
|
||||
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
|
||||
|
||||
const out: any = { tokenPreview: token.slice(0, 32) + '...' };
|
||||
// decode header and payload
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const header = parts[0];
|
||||
const payload = parts[1];
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
try { out.header = JSON.parse(Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
try { out.payload = JSON.parse(Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
} else {
|
||||
out.header = null; out.payload = null;
|
||||
}
|
||||
} catch (e) {
|
||||
out.decodeError = String(e);
|
||||
}
|
||||
|
||||
// Local verify using configured secret (HS256) if available
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
out.localVerify = { ok: true, payload: verified };
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
|
||||
}
|
||||
} else {
|
||||
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
|
||||
}
|
||||
|
||||
// Proxy to LiveKit validate endpoint to get server-side validation result
|
||||
try {
|
||||
const result = await validateTokenWithLiveKit(token);
|
||||
out.livekit = { status: result.status, ok: result.ok, body: result.body };
|
||||
} catch (e) {
|
||||
out.livekit = { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] /debug/verify-token failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint: verify and return full session info (token, decoded parts, local and LiveKit verification)
|
||||
app.get('/debug/session/:id/full', async (req, res) => {
|
||||
try {
|
||||
// Only allow in development or when explicitly enabled
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') {
|
||||
return res.status(403).json({ error: 'not_allowed' });
|
||||
}
|
||||
const id = String(req.params.id || '');
|
||||
if (!id) return res.status(400).json({ error: 'missing_id' });
|
||||
const s = await getSession(id);
|
||||
if (!s) return res.status(404).json({ error: 'not_found' });
|
||||
|
||||
const token = s.token || '';
|
||||
const out: any = {
|
||||
id,
|
||||
room: s.room,
|
||||
username: s.username,
|
||||
url: s.url,
|
||||
expiresAt: s.expiresAt,
|
||||
ttlSeconds: Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)),
|
||||
tokenPreview: typeof token === 'string' ? (token.length > 64 ? token.slice(0, 32) + '...' + token.slice(-16) : token) : token,
|
||||
};
|
||||
|
||||
// decode header/payload if JWT-like
|
||||
try {
|
||||
if (typeof token === 'string' && token.split('.').length >= 2) {
|
||||
const parts = token.split('.');
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
try { out.header = JSON.parse(Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
try { out.payload = JSON.parse(Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
} else {
|
||||
out.header = null; out.payload = null;
|
||||
}
|
||||
} catch (e) {
|
||||
out.decodeError = String(e);
|
||||
}
|
||||
|
||||
// Local verify using configured secret (HS256) if available
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
out.localVerify = { ok: true, payload: verified };
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
|
||||
}
|
||||
} else {
|
||||
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
|
||||
}
|
||||
|
||||
// Proxy to LiveKit validate endpoint to get server-side validation result
|
||||
try {
|
||||
const result = await validateTokenWithLiveKit(token);
|
||||
out.livekit = { status: result.status, ok: result.ok, body: result.body };
|
||||
} catch (e) {
|
||||
out.livekit = { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
// Attach raw token only in development to avoid leaking secrets in other envs
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
out.token = token;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] /debug/session/:id/full failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
155
packages/broadcast-panel/e2e/e2e_remote_9222.js
Normal file
155
packages/broadcast-panel/e2e/e2e_remote_9222.js
Normal 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); });
|
||||
|
||||
222
packages/broadcast-panel/e2e/generate_visual_baseline.js
Normal file
222
packages/broadcast-panel/e2e/generate_visual_baseline.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
18
packages/broadcast-panel/e2e/playwright.config.ts
Normal file
18
packages/broadcast-panel/e2e/playwright.config.ts
Normal 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'] } },
|
||||
],
|
||||
});
|
||||
|
||||
124
packages/broadcast-panel/e2e/run_e2e_auto.sh
Executable file
124
packages/broadcast-panel/e2e/run_e2e_auto.sh
Executable 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
|
||||
115
packages/broadcast-panel/e2e/session_loader.e2e.js
Normal file
115
packages/broadcast-panel/e2e/session_loader.e2e.js
Normal 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
91
packages/broadcast-panel/e2e/start-chrome-remote.sh
Normal file → Executable 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
|
||||
|
||||
17
packages/broadcast-panel/e2e/test_cdp_connect.js
Normal file
17
packages/broadcast-panel/e2e/test_cdp_connect.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
132
packages/broadcast-panel/e2e/visual-studio.spec.ts
Normal file
132
packages/broadcast-panel/e2e/visual-studio.spec.ts
Normal 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) {}
|
||||
}
|
||||
});
|
||||
@ -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",
|
||||
|
||||
83
packages/broadcast-panel/public/create_session.html
Normal file
83
packages/broadcast-panel/public/create_session.html
Normal 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/<sessionId></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>
|
||||
|
||||
82
packages/broadcast-panel/scripts/connect-via-backend.js
Normal file
82
packages/broadcast-panel/scripts/connect-via-backend.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
67
packages/broadcast-panel/scripts/create_session.js
Normal file
67
packages/broadcast-panel/scripts/create_session.js
Normal 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();
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.selectedDestination::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -10,7 +10,10 @@
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
transition: transform 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.logoSection {
|
||||
@ -57,7 +60,10 @@
|
||||
/* move slightly to the left (-1 spacing ≈ 4px) for a tighter alignment */
|
||||
width: calc(100% + 26px);
|
||||
margin-left: -10px;
|
||||
transition: background-color 0.15s ease, color 0.15s ease, transform 0.12s ease;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
|
||||
@ -1,34 +1,61 @@
|
||||
import React from 'react'
|
||||
import { MdHome, MdVideoLibrary, MdLink, MdPeople, MdCardGiftcard, MdSettings, MdAssessment, MdVideocam } from 'react-icons/md'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { Logo } from '../../../../shared/components/Logo'
|
||||
import styles from './Sidebar.module.css'
|
||||
import React from "react";
|
||||
import {
|
||||
MdHome,
|
||||
MdVideoLibrary,
|
||||
MdLink,
|
||||
MdPeople,
|
||||
MdCardGiftcard,
|
||||
MdSettings,
|
||||
MdAssessment,
|
||||
MdVideocam,
|
||||
} from "react-icons/md";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { Logo } from "../../../../shared/components/Logo";
|
||||
import styles from "./Sidebar.module.css";
|
||||
|
||||
interface SidebarProps {
|
||||
activeLink?: string
|
||||
onNavigate?: (page: string) => void
|
||||
activeLink?: string;
|
||||
onNavigate?: (page: string) => void;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate }) => {
|
||||
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
activeLink = "inicio",
|
||||
onNavigate,
|
||||
}) => {
|
||||
const handleNavClick = (
|
||||
e: React.MouseEvent<HTMLAnchorElement>,
|
||||
id: string,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
if (onNavigate) {
|
||||
onNavigate(id)
|
||||
onNavigate(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ id: 'inicio', label: 'Inicio', icon: <MdHome size={20} /> },
|
||||
{ id: 'biblioteca', label: 'Biblioteca', icon: <MdVideoLibrary size={20} /> },
|
||||
{ id: 'destinos', label: 'Destinos', icon: <MdLink size={20} /> },
|
||||
{ id: 'miembros', label: 'Miembros', icon: <MdPeople size={20} /> },
|
||||
]
|
||||
{ id: "inicio", label: "Inicio", icon: <MdHome size={20} /> },
|
||||
{
|
||||
id: "biblioteca",
|
||||
label: "Biblioteca",
|
||||
icon: <MdVideoLibrary size={20} />,
|
||||
},
|
||||
{ id: "destinos", label: "Destinos", icon: <MdLink size={20} /> },
|
||||
{ id: "miembros", label: "Miembros", icon: <MdPeople size={20} /> },
|
||||
];
|
||||
|
||||
const secondaryNavItems = [
|
||||
{ id: 'referidos', label: 'Referidos', icon: <MdCardGiftcard size={20} /> },
|
||||
{ id: 'configuracion', label: 'Configuración del equipo', icon: <MdSettings size={20} /> },
|
||||
{ id: 'sistema', label: 'Estado del sistema', icon: <MdAssessment size={20} /> },
|
||||
]
|
||||
{ id: "referidos", label: "Referidos", icon: <MdCardGiftcard size={20} /> },
|
||||
{
|
||||
id: "configuracion",
|
||||
label: "Configuración del equipo",
|
||||
icon: <MdSettings size={20} />,
|
||||
},
|
||||
{
|
||||
id: "sistema",
|
||||
label: "Estado del sistema",
|
||||
icon: <MdAssessment size={20} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
@ -40,38 +67,47 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate })
|
||||
{/* Main Navigation */}
|
||||
<nav className={styles.navMenu}>
|
||||
<ul className={styles.navList}>
|
||||
{navItems.map(item => (
|
||||
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className={styles.navLink}
|
||||
onClick={(e) => handleNavClick(e, item.id)}
|
||||
>
|
||||
<span className={styles.navIcon}>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{navItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={
|
||||
activeLink === item.id ? styles.activeLink : styles.navItem
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className={styles.navLink}
|
||||
onClick={(e) => handleNavClick(e, item.id)}
|
||||
>
|
||||
<span className={styles.navIcon}>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</nav>
|
||||
{/* Secondary Navigation moved closer to storage */}
|
||||
<div className={styles.secondaryNavGroup}>
|
||||
<ul className={styles.navList}>
|
||||
{secondaryNavItems.map(item => (
|
||||
<li key={item.id} className={activeLink === item.id ? styles.activeLink : styles.navItem}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className={styles.navLink}
|
||||
onClick={(e) => handleNavClick(e, item.id)}
|
||||
>
|
||||
<span className={styles.navIcon}>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Secondary Navigation moved closer to storage */}
|
||||
<div className={styles.secondaryNavGroup}>
|
||||
<ul className={styles.navList}>
|
||||
{secondaryNavItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={
|
||||
activeLink === item.id ? styles.activeLink : styles.navItem
|
||||
}
|
||||
>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
className={styles.navLink}
|
||||
onClick={(e) => handleNavClick(e, item.id)}
|
||||
>
|
||||
<span className={styles.navIcon}>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Storage Info */}
|
||||
<div className={styles.storageInfo}>
|
||||
<div className={styles.storageTitle}>
|
||||
@ -79,13 +115,15 @@ const Sidebar: React.FC<SidebarProps> = ({ activeLink = 'inicio', onNavigate })
|
||||
<span className={styles.infoIcon}>?</span>
|
||||
</div>
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div className={styles.progressBarFill} style={{ width: '0%' }}></div>
|
||||
<div className={styles.progressBarFill} style={{ width: "0%" }}></div>
|
||||
</div>
|
||||
<div className={styles.storageUsage}>0 de 5 horas</div>
|
||||
<a href="#agregar" className={styles.addMoreLink}>Agregar más</a>
|
||||
<a href="#agregar" className={styles.addMoreLink}>
|
||||
Agregar más
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar
|
||||
export default Sidebar;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import styles from './Skeleton.module.css';
|
||||
import React from "react";
|
||||
import styles from "./Skeleton.module.css";
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string;
|
||||
@ -8,14 +8,14 @@ interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width = '100%',
|
||||
height = '20px',
|
||||
borderRadius = '4px',
|
||||
className = ''
|
||||
export const Skeleton: React.FC<SkeletonProps> = ({
|
||||
width = "100%",
|
||||
height = "20px",
|
||||
borderRadius = "4px",
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`${styles.skeleton} ${className}`}
|
||||
style={{ width, height, borderRadius }}
|
||||
/>
|
||||
|
||||
@ -98,7 +98,7 @@
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
.devNote a {
|
||||
@ -240,7 +240,8 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
@ -451,7 +452,7 @@
|
||||
.studioContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.studioSidebar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@ -462,16 +463,16 @@
|
||||
z-index: 10;
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
|
||||
.connectionForm {
|
||||
margin: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
.controlBar {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
|
||||
.controlsLeft,
|
||||
.controlsRight {
|
||||
display: none;
|
||||
|
||||
@ -1,54 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ThemeProvider } from './ThemeProvider'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import styles from './Studio.module.css'
|
||||
import { StudioPortal } from '../features/studio'
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ThemeProvider } from "./ThemeProvider";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./Header";
|
||||
import styles from "./Studio.module.css";
|
||||
import { StudioPortal } from "../features/studio";
|
||||
|
||||
const Studio: React.FC = () => {
|
||||
const [tokenData, setTokenData] = useState<{ token?: string; url?: string } | null>(null)
|
||||
const [tokenData, setTokenData] = useState<{
|
||||
token?: string;
|
||||
url?: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const userName = localStorage.getItem('avanzacast_user') || 'Usuario'
|
||||
const roomName = localStorage.getItem('avanzacast_room') || 'avanzacast-studio'
|
||||
localStorage.setItem('avanzacast_user', userName)
|
||||
localStorage.setItem('avanzacast_room', roomName)
|
||||
const userName = localStorage.getItem("avanzacast_user") || "Usuario";
|
||||
const roomName =
|
||||
localStorage.getItem("avanzacast_room") || "avanzacast-studio";
|
||||
localStorage.setItem("avanzacast_user", userName);
|
||||
localStorage.setItem("avanzacast_room", roomName);
|
||||
|
||||
// Request session token via backend API: POST /api/session then GET /api/session/:id
|
||||
;(async () => {
|
||||
(async () => {
|
||||
try {
|
||||
// Create session (POST)
|
||||
const createResp = await fetch('/api/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const createResp = await fetch("/api/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ room: roomName, username: userName }),
|
||||
credentials: 'include',
|
||||
})
|
||||
credentials: "include",
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
console.warn('studio session create failed', createResp.status)
|
||||
return
|
||||
console.warn("studio session create failed", createResp.status);
|
||||
return;
|
||||
}
|
||||
const created = await createResp.json()
|
||||
const sessionId = created && created.id
|
||||
const created = await createResp.json();
|
||||
const sessionId = created && created.id;
|
||||
if (!sessionId) {
|
||||
console.warn('studio session create returned no id', created)
|
||||
return
|
||||
console.warn("studio session create returned no id", created);
|
||||
return;
|
||||
}
|
||||
// Fetch full session data (token and url)
|
||||
const fetchResp = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { method: 'GET', credentials: 'include' })
|
||||
const fetchResp = await fetch(
|
||||
`/api/session/${encodeURIComponent(sessionId)}`,
|
||||
{ method: "GET", credentials: "include" },
|
||||
);
|
||||
if (!fetchResp.ok) {
|
||||
console.warn('failed fetching session data', fetchResp.status)
|
||||
return
|
||||
console.warn("failed fetching session data", fetchResp.status);
|
||||
return;
|
||||
}
|
||||
const data = await fetchResp.json()
|
||||
const data = await fetchResp.json();
|
||||
// expected { token, url }
|
||||
setTokenData({ token: data.token, url: data.url })
|
||||
return
|
||||
setTokenData({ token: data.token, url: data.url });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('failed requesting studio session', e)
|
||||
console.warn("failed requesting studio session", e);
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// New: optionally use the LiveKit React components wrapper (dynamically imported)
|
||||
const useLiveKitComponents =
|
||||
(import.meta.env.VITE_USE_LIVEKIT_COMPONENTS as string) === "1";
|
||||
const [LKPortal, setLKPortal] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useLiveKitComponents)
|
||||
return; // dynamic import so bundler only includes it when requested
|
||||
(async () => {
|
||||
try {
|
||||
const mod = await import(
|
||||
"../features/studio/StudioPortalWithComponents"
|
||||
);
|
||||
setLKPortal(mod.default || null);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load StudioPortalWithComponents", e);
|
||||
}
|
||||
})();
|
||||
}, [useLiveKitComponents]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
@ -58,13 +85,35 @@ const Studio: React.FC = () => {
|
||||
<Header />
|
||||
<main className={styles.studioMain}>
|
||||
{tokenData ? (
|
||||
<StudioPortal serverUrl={tokenData.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''} token={tokenData.token || ''} />
|
||||
useLiveKitComponents && LKPortal ? (
|
||||
<LKPortal
|
||||
serverUrl={
|
||||
tokenData.url ||
|
||||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
|
||||
""
|
||||
}
|
||||
token={tokenData.token || ""}
|
||||
/>
|
||||
) : (
|
||||
<StudioPortal
|
||||
serverUrl={
|
||||
tokenData.url ||
|
||||
(import.meta.env.VITE_LIVEKIT_WS_URL as string) ||
|
||||
""
|
||||
}
|
||||
token={tokenData.token || ""}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent mb-4"></div>
|
||||
<h2 className="text-xl font-semibold">Preparando tu estudio...</h2>
|
||||
<p className="text-gray-500 mt-2">Solicitando credenciales seguras</p>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Preparando tu estudio...
|
||||
</h2>
|
||||
<p className="text-gray-500 mt-2">
|
||||
Solicitando credenciales seguras
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -72,7 +121,7 @@ const Studio: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Studio
|
||||
export default Studio;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,50 +1,56 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
resolvedTheme: "light" | "dark";
|
||||
setThemeMode: (t: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('avanzacast-theme-mode');
|
||||
return (saved as Theme) || 'system';
|
||||
const saved = localStorage.getItem("avanzacast-theme-mode");
|
||||
return (saved as Theme) || "system";
|
||||
});
|
||||
|
||||
const getSystem = () => (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
const getSystem = () =>
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
const apply = (mode: Theme) => {
|
||||
const root = document.documentElement;
|
||||
const resolved = mode === 'system' ? getSystem() : mode;
|
||||
root.setAttribute('data-theme', resolved);
|
||||
const resolved = mode === "system" ? getSystem() : mode;
|
||||
root.setAttribute("data-theme", resolved);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
apply(theme);
|
||||
localStorage.setItem('avanzacast-theme-mode', theme);
|
||||
localStorage.setItem("avanzacast-theme-mode", theme);
|
||||
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
// only react if mode is system
|
||||
if (theme === 'system') apply('system');
|
||||
if (theme === "system") apply("system");
|
||||
};
|
||||
if (mq.addEventListener) mq.addEventListener('change', handler);
|
||||
if (mq.addEventListener) mq.addEventListener("change", handler);
|
||||
else mq.addListener(handler);
|
||||
|
||||
return () => {
|
||||
if (mq.removeEventListener) mq.removeEventListener('change', handler);
|
||||
if (mq.removeEventListener) mq.removeEventListener("change", handler);
|
||||
else mq.removeListener(handler);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
const setThemeMode = (t: Theme) => setTheme(t);
|
||||
|
||||
const resolvedTheme = theme === 'system' ? getSystem() : theme;
|
||||
const resolvedTheme = theme === "system" ? getSystem() : theme;
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setThemeMode }}>
|
||||
@ -56,7 +62,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme debe usarse dentro de ThemeProvider');
|
||||
throw new Error("useTheme debe usarse dentro de ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@ -1,20 +1,43 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
export type ToastVariant = 'info'|'success'|'error'|'warning'
|
||||
export type ToastVariant = "info" | "success" | "error" | "warning";
|
||||
|
||||
export function Toast({ message, variant = 'info' }: { message: string, variant?: ToastVariant }) {
|
||||
const bg = variant === 'success' ? '#d1fae5' : variant === 'error' ? '#fee2e2' : variant === 'warning' ? '#fff7ed' : '#eef2ff'
|
||||
const color = variant === 'success' ? '#065f46' : variant === 'error' ? '#991b1b' : variant === 'warning' ? '#92400e' : '#3730a3'
|
||||
export function Toast({
|
||||
message,
|
||||
variant = "info",
|
||||
}: {
|
||||
message: string;
|
||||
variant?: ToastVariant;
|
||||
}) {
|
||||
const bg =
|
||||
variant === "success"
|
||||
? "#d1fae5"
|
||||
: variant === "error"
|
||||
? "#fee2e2"
|
||||
: variant === "warning"
|
||||
? "#fff7ed"
|
||||
: "#eef2ff";
|
||||
const color =
|
||||
variant === "success"
|
||||
? "#065f46"
|
||||
: variant === "error"
|
||||
? "#991b1b"
|
||||
: variant === "warning"
|
||||
? "#92400e"
|
||||
: "#3730a3";
|
||||
return (
|
||||
<div style={{
|
||||
background: bg,
|
||||
color,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 6px 18px rgba(15,23,42,0.08)'
|
||||
}}>{message}</div>
|
||||
)
|
||||
<div
|
||||
style={{
|
||||
background: bg,
|
||||
color,
|
||||
padding: "10px 14px",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 6px 18px rgba(15,23,42,0.08)",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Toast
|
||||
|
||||
export default Toast;
|
||||
|
||||
@ -1,17 +1,21 @@
|
||||
import React, { useState } from 'react';
|
||||
import styles from './Tooltip.module.css';
|
||||
import React, { useState } from "react";
|
||||
import styles from "./Tooltip.module.css";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
children: React.ReactNode;
|
||||
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({ content, children, position = 'bottom' }) => {
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
content,
|
||||
children,
|
||||
position = "bottom",
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={styles.tooltipWrapper}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
@ -130,7 +130,7 @@
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
/* subtle elevation to match mock */
|
||||
box-shadow: 0 6px 18px rgba(2,6,23,0.04);
|
||||
box-shadow: 0 6px 18px rgba(2, 6, 23, 0.04);
|
||||
/* constrain width to visually match the Create cards and center */
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
@ -204,20 +204,28 @@
|
||||
|
||||
/* Spinner used in the Entrar button */
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
background: rgba(0,0,0,0.8);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 6px 20px rgba(2,6,23,0.2);
|
||||
box-shadow: 0 6px 20px rgba(2, 6, 23, 0.2);
|
||||
}
|
||||
.toast.error {
|
||||
background: rgba(160, 40, 40, 0.95);
|
||||
}
|
||||
.toast.info {
|
||||
background: rgba(30, 120, 255, 0.95);
|
||||
}
|
||||
.toast.error { background: rgba(160, 40, 40, 0.95); }
|
||||
.toast.info { background: rgba(30, 120, 255, 0.95); }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
size?: number
|
||||
color?: string
|
||||
}
|
||||
className?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const PlusLarge: React.FC<Props> = ({ className = '', size = 20, color = 'var(--primary-blue)' }) => {
|
||||
const half = size / 2
|
||||
const stroke = Math.max(1, Math.round(size * 0.12))
|
||||
export const PlusLarge: React.FC<Props> = ({
|
||||
className = "",
|
||||
size = 20,
|
||||
color = "var(--primary-blue)",
|
||||
}) => {
|
||||
const half = size / 2;
|
||||
const stroke = Math.max(1, Math.round(size * 0.12));
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
@ -19,11 +23,34 @@ export const PlusLarge: React.FC<Props> = ({ className = '', size = 20, color =
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="0" y="0" width={size} height={size} rx={Math.round(size * 0.18)} fill="none" />
|
||||
<line x1={half} y1={size * 0.18} x2={half} y2={size * 0.82} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
|
||||
<line x1={size * 0.18} y1={half} x2={size * 0.82} y2={half} stroke={color} strokeWidth={stroke} strokeLinecap="round" />
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width={size}
|
||||
height={size}
|
||||
rx={Math.round(size * 0.18)}
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={half}
|
||||
y1={size * 0.18}
|
||||
x2={half}
|
||||
y2={size * 0.82}
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1={size * 0.18}
|
||||
y1={half}
|
||||
x2={size * 0.82}
|
||||
y2={half}
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default PlusLarge
|
||||
export default PlusLarge;
|
||||
|
||||
68
packages/broadcast-panel/src/components/icons/Spinner.tsx
Normal file
68
packages/broadcast-panel/src/components/icons/Spinner.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = { size?: number; color?: string; className?: string };
|
||||
|
||||
// Stylized spinner: circular arc with smooth rotation; uses <animateTransform>
|
||||
export const Spinner: React.FC<Props> = ({
|
||||
size = 18,
|
||||
color = "#0b5cff",
|
||||
className = "",
|
||||
}) => {
|
||||
const stroke = Math.max(2, Math.round(size * 0.12));
|
||||
const r = (size - stroke) / 2;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const dashArray = Math.round(Math.PI * r * 1.5);
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
style={{ display: "inline-block", verticalAlign: "middle" }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="spinnerGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(0,0)`}>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
stroke="#e6eefc"
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
stroke="url(#spinnerGrad)"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
strokeDasharray={`${dashArray} ${Math.round(Math.PI * r * 2)}`}
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
from={`0 ${cx} ${cy}`}
|
||||
to={`360 ${cx} ${cy}`}
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
8
packages/broadcast-panel/src/env.d.ts
vendored
8
packages/broadcast-panel/src/env.d.ts
vendored
@ -1,10 +1,10 @@
|
||||
// Local env declarations for broadcast-panel (Vite-style import.meta.env)
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TOKEN_SERVER_URL?: string
|
||||
readonly VITE_STUDIO_URL?: string
|
||||
[key: string]: string | undefined
|
||||
readonly VITE_TOKEN_SERVER_URL?: string;
|
||||
readonly VITE_STUDIO_URL?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type SessionResp = {
|
||||
id?: string;
|
||||
token?: string;
|
||||
studioUrl?: string;
|
||||
username?: string;
|
||||
[k: string]: any;
|
||||
};
|
||||
|
||||
async function resolveIdentity(): Promise<string | null> {
|
||||
try {
|
||||
const r = await fetch("/api/auth/session");
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j && j.user) return j.user.name || j.user.email || null;
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window !== "undefined" && (window as any).__AVANZACAST_USER) {
|
||||
// @ts-ignore
|
||||
const u = (window as any).__AVANZACAST_USER;
|
||||
if (typeof u === "string") return u;
|
||||
if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email;
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
const answer =
|
||||
typeof window !== "undefined"
|
||||
? window.prompt(
|
||||
"Identifícate (nombre de usuario) para ligar con la room",
|
||||
"visual-runner",
|
||||
)
|
||||
: null;
|
||||
if (answer && answer.trim()) return answer.trim();
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AutoRequestAndInject({
|
||||
roomName,
|
||||
}: {
|
||||
roomName: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<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();
|
||||
|
||||
if (!json.token) {
|
||||
if (json.id) {
|
||||
const getResp = await fetch(
|
||||
`/api/session/proxy/${encodeURIComponent(json.id)}`,
|
||||
);
|
||||
if (getResp.ok) {
|
||||
const getJson = await getResp.json();
|
||||
if (getJson && getJson.token) json.token = getJson.token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!json.token)
|
||||
throw new Error("No token returned from session proxy");
|
||||
|
||||
window.postMessage(
|
||||
{
|
||||
type: "LIVEKIT_TOKEN",
|
||||
token: json.token,
|
||||
room: roomName,
|
||||
username: identity,
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
console.log("AutoRequestAndInject: posted LIVEKIT_TOKEN to window", {
|
||||
room: roomName,
|
||||
username: identity,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("AutoRequestAndInject error", err);
|
||||
if (mounted) setError(String(err));
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [roomName]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import React, { useState, useContext } from 'react'
|
||||
import { RoomContext } from '@livekit/components-react'
|
||||
import { Room } from 'livekit-client'
|
||||
import { ControlButton, ControlGroup, IconButton } from 'avanza-ui'
|
||||
import IconCameraOn from './icons/IconCameraOn'
|
||||
import IconMicOff from './icons/IconMicOff'
|
||||
import React, { useState, useContext } from "react";
|
||||
import { RoomContext } from "@livekit/components-react";
|
||||
import { Room } from "livekit-client";
|
||||
import { ControlButton, ControlGroup, IconButton } from "avanza-ui";
|
||||
import IconCameraOn from "./icons/IconCameraOn";
|
||||
import IconMicOff from "./icons/IconMicOff";
|
||||
|
||||
interface BottomControlsProps {
|
||||
onToggleMute?: (muted: boolean) => void;
|
||||
@ -11,129 +11,166 @@ interface BottomControlsProps {
|
||||
onToggleRecording?: (recording: boolean) => void;
|
||||
}
|
||||
|
||||
let idCounter = 0
|
||||
function uniqueId(prefix = 'id'){
|
||||
idCounter += 1
|
||||
return `${prefix}-${idCounter}`
|
||||
let idCounter = 0;
|
||||
function uniqueId(prefix = "id") {
|
||||
idCounter += 1;
|
||||
return `${prefix}-${idCounter}`;
|
||||
}
|
||||
|
||||
export default function BottomControls({ onToggleMute, onToggleCamera, onToggleRecording }: BottomControlsProps){
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [cameraOn, setCameraOn] = useState(true)
|
||||
const [recording, setRecording] = useState(false)
|
||||
export default function BottomControls({
|
||||
onToggleMute,
|
||||
onToggleCamera,
|
||||
onToggleRecording,
|
||||
}: BottomControlsProps) {
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [cameraOn, setCameraOn] = useState(true);
|
||||
const [recording, setRecording] = useState(false);
|
||||
|
||||
const ctxRoom = useContext(RoomContext) as Room | null
|
||||
const ctxRoom = useContext(RoomContext) as Room | null;
|
||||
|
||||
React.useEffect(() => {
|
||||
function onGoLive(e: any) {
|
||||
try {
|
||||
const d = e?.detail || {};
|
||||
if (d.action === 'start') setRecording(true);
|
||||
else if (d.action === 'stop') setRecording(false);
|
||||
} catch (err) { console.warn('go-live handler error', err) }
|
||||
if (d.action === "start") setRecording(true);
|
||||
else if (d.action === "stop") setRecording(false);
|
||||
} catch (err) {
|
||||
console.warn("go-live handler error", err);
|
||||
}
|
||||
}
|
||||
window.addEventListener('avz:request:go-live', onGoLive as EventListener);
|
||||
return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener);
|
||||
window.addEventListener("avz:request:go-live", onGoLive as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"avz:request:go-live",
|
||||
onGoLive as EventListener,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const muteTipId = React.useMemo(() => uniqueId('tip-mute'), [])
|
||||
const camTipId = React.useMemo(() => uniqueId('tip-cam'), [])
|
||||
const recTipId = React.useMemo(() => uniqueId('tip-rec'), [])
|
||||
const muteTipId = React.useMemo(() => uniqueId("tip-mute"), []);
|
||||
const camTipId = React.useMemo(() => uniqueId("tip-cam"), []);
|
||||
const recTipId = React.useMemo(() => uniqueId("tip-rec"), []);
|
||||
|
||||
const safeSetMic = async (enabled: boolean) => {
|
||||
try {
|
||||
const r = ctxRoom as any
|
||||
if (!r) return
|
||||
const lp = r.localParticipant
|
||||
if (!lp) return
|
||||
if (typeof lp.setMicrophoneEnabled === 'function') {
|
||||
await lp.setMicrophoneEnabled(enabled)
|
||||
return
|
||||
const r = ctxRoom as any;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
if (!lp) return;
|
||||
if (typeof lp.setMicrophoneEnabled === "function") {
|
||||
await lp.setMicrophoneEnabled(enabled);
|
||||
return;
|
||||
}
|
||||
if (lp.audioTracks && Array.isArray(lp.audioTracks)) {
|
||||
for (const tpub of lp.audioTracks) {
|
||||
try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){}
|
||||
try {
|
||||
tpub.track &&
|
||||
typeof tpub.track.enable === "function" &&
|
||||
tpub.track.enable(enabled);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('safeSetMic failed', e)
|
||||
console.warn("safeSetMic failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const safeSetCamera = async (enabled: boolean) => {
|
||||
try {
|
||||
const r = ctxRoom as any
|
||||
if (!r) return
|
||||
const lp = r.localParticipant
|
||||
if (!lp) return
|
||||
if (typeof lp.setCameraEnabled === 'function') {
|
||||
await lp.setCameraEnabled(enabled)
|
||||
return
|
||||
const r = ctxRoom as any;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
if (!lp) return;
|
||||
if (typeof lp.setCameraEnabled === "function") {
|
||||
await lp.setCameraEnabled(enabled);
|
||||
return;
|
||||
}
|
||||
if (lp.videoTracks && Array.isArray(lp.videoTracks)) {
|
||||
for (const tpub of lp.videoTracks) {
|
||||
try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){}
|
||||
try {
|
||||
tpub.track &&
|
||||
typeof tpub.track.enable === "function" &&
|
||||
tpub.track.enable(enabled);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('safeSetCamera failed', e)
|
||||
console.warn("safeSetCamera failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const safeToggleRecording = async (start: boolean) => {
|
||||
try {
|
||||
const r = ctxRoom as any
|
||||
if (!r) return
|
||||
const lp = r.localParticipant
|
||||
if (!lp) return
|
||||
if (typeof lp.publishData === 'function') {
|
||||
const payload = JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop', ts: Date.now() })
|
||||
const enc = new TextEncoder().encode(payload)
|
||||
try { await lp.publishData(enc, { reliable: true }) } catch(e) { console.warn('publishData failed', e) }
|
||||
return
|
||||
const r = ctxRoom as any;
|
||||
if (!r) return;
|
||||
const lp = r.localParticipant;
|
||||
if (!lp) return;
|
||||
if (typeof lp.publishData === "function") {
|
||||
const payload = JSON.stringify({
|
||||
type: "RECORDING",
|
||||
action: start ? "start" : "stop",
|
||||
ts: Date.now(),
|
||||
});
|
||||
const enc = new TextEncoder().encode(payload);
|
||||
try {
|
||||
await lp.publishData(enc, { reliable: true });
|
||||
} catch (e) {
|
||||
console.warn("publishData failed", e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof r.sendData === 'function') {
|
||||
try { r.sendData(JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop' })) } catch(e) {}
|
||||
if (typeof r.sendData === "function") {
|
||||
try {
|
||||
r.sendData(
|
||||
JSON.stringify({
|
||||
type: "RECORDING",
|
||||
action: start ? "start" : "stop",
|
||||
}),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('safeToggleRecording failed', e)
|
||||
console.warn("safeToggleRecording failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
const next = !muted
|
||||
setMuted(next)
|
||||
onToggleMute?.(next)
|
||||
await safeSetMic(!next ? true : false)
|
||||
}
|
||||
const next = !muted;
|
||||
setMuted(next);
|
||||
onToggleMute?.(next);
|
||||
await safeSetMic(!next ? true : false);
|
||||
};
|
||||
|
||||
const handleToggleCamera = async () => {
|
||||
const next = !cameraOn
|
||||
setCameraOn(next)
|
||||
onToggleCamera?.(next)
|
||||
await safeSetCamera(next)
|
||||
}
|
||||
const next = !cameraOn;
|
||||
setCameraOn(next);
|
||||
onToggleCamera?.(next);
|
||||
await safeSetCamera(next);
|
||||
};
|
||||
|
||||
const handleToggleRecording = async () => {
|
||||
const next = !recording
|
||||
setRecording(next)
|
||||
onToggleRecording?.(next)
|
||||
}
|
||||
const next = !recording;
|
||||
setRecording(next);
|
||||
onToggleRecording?.(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 left-4 right-4">
|
||||
<ControlGroup className="controls-inner" style={{boxShadow: '0 10px 30px rgba(0,0,0,0.35)'}}>
|
||||
|
||||
<ControlGroup
|
||||
className="controls-inner"
|
||||
style={{ boxShadow: "0 10px 30px rgba(0,0,0,0.35)" }}
|
||||
>
|
||||
<div className="control-wrapper">
|
||||
<IconButton
|
||||
id={`btn-mic`}
|
||||
icon={<IconMicOff />}
|
||||
active={!muted}
|
||||
title={muted ? 'Activar micrófono' : 'Silenciar'}
|
||||
title={muted ? "Activar micrófono" : "Silenciar"}
|
||||
onClick={handleToggleMute}
|
||||
size="sm"
|
||||
/>
|
||||
<span id={muteTipId} role="tooltip" className="tooltip">{muted ? 'Activar micrófono' : 'Silenciar'}</span>
|
||||
<span id={muteTipId} role="tooltip" className="tooltip">
|
||||
{muted ? "Activar micrófono" : "Silenciar"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="control-wrapper">
|
||||
@ -141,31 +178,35 @@ export default function BottomControls({ onToggleMute, onToggleCamera, onToggleR
|
||||
id={`btn-cam`}
|
||||
icon={<IconCameraOn />}
|
||||
active={cameraOn}
|
||||
title={cameraOn ? 'Apagar cámara' : 'Encender cámara'}
|
||||
title={cameraOn ? "Apagar cámara" : "Encender cámara"}
|
||||
onClick={handleToggleCamera}
|
||||
size="sm"
|
||||
/>
|
||||
<span id={camTipId} role="tooltip" className="tooltip">{cameraOn ? 'Apagar cámara' : 'Encender cámara'}</span>
|
||||
<span id={camTipId} role="tooltip" className="tooltip">
|
||||
{cameraOn ? "Apagar cámara" : "Encender cámara"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="control-wrapper">
|
||||
<ControlButton
|
||||
id="recBtn"
|
||||
icon={recording ? <span className="record-dot" /> : undefined}
|
||||
label={recording ? 'Stop' : 'Start'}
|
||||
label={recording ? "Stop" : "Start"}
|
||||
active={recording}
|
||||
danger={true}
|
||||
title={recording ? 'Detener grabación' : 'Iniciar grabación'}
|
||||
title={recording ? "Detener grabación" : "Iniciar grabación"}
|
||||
onClick={handleToggleRecording}
|
||||
size="md"
|
||||
/>
|
||||
<span id={recTipId} role="tooltip" className="tooltip">{recording ? 'Detener grabación' : 'Iniciar grabación'}</span>
|
||||
<span id={recTipId} role="tooltip" className="tooltip">
|
||||
{recording ? "Detener grabación" : "Iniciar grabación"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="visually-hidden" aria-live="polite">{recording ? 'Grabación iniciada' : 'Grabación detenida'}</span>
|
||||
|
||||
<span className="visually-hidden" aria-live="polite">
|
||||
{recording ? "Grabación iniciada" : "Grabación detenida"}
|
||||
</span>
|
||||
</ControlGroup>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,184 @@
|
||||
// moved from studio-panel
|
||||
.studio-portal{display:flex;height:100vh;gap:12px;background:var(--studio-bg-primary)}
|
||||
.studio-portal__left{width:280px;background:var(--studio-bg-secondary);padding:12px;border-right:1px solid var(--studio-border)}
|
||||
.studio-portal__right{width:320px;background:var(--studio-bg-secondary);padding:12px;border-left:1px solid var(--studio-border)}
|
||||
.studio-portal__center{flex:1;display:flex;flex-direction:column;align-items:stretch;padding:12px}
|
||||
.preview-wrapper{flex:1;background:#0f0f0f;border-radius:8px;display:flex;align-items:center;justify-content:center}
|
||||
.controls-bar{display:flex;justify-content:space-between;align-items:center;padding:12px 0}
|
||||
.scenes-header{font-weight:700;margin-bottom:8px}
|
||||
.scenes-list{display:flex;flex-direction:column;gap:8px}
|
||||
.scene-item{padding:10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)}
|
||||
.scene-item.active{background:var(--studio-accent);color:#fff}
|
||||
.layout-presets{display:flex;gap:8px}
|
||||
.layout-btn{padding:8px 10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)}
|
||||
.layout-btn.active{background:var(--studio-accent);color:#fff}
|
||||
.actions .btn-record{background:#2563eb;color:#fff;padding:10px 16px;border-radius:8px;border:none}
|
||||
.actions .btn-stop{background:#dc2626;color:#fff;padding:10px 16px;border-radius:8px;border:none}
|
||||
/* moved from studio-panel */
|
||||
.studio-portal {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
gap: 12px;
|
||||
background: var(--studio-bg-primary);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Left column: Scenes */
|
||||
.studio-portal__left {
|
||||
width: 280px;
|
||||
background: var(--studio-bg-secondary);
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--studio-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scenes-header {
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.scenes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.scene-item {
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--studio-bg-primary);
|
||||
border: 1px solid var(--studio-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.scene-item.active {
|
||||
background: var(--studio-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-new-scene {
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Center column: main studio area */
|
||||
.studio-portal__center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.preview-wrapper {
|
||||
flex: 1;
|
||||
background: #0b0b0b;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Large video stage area */
|
||||
.studio-room__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
.studio-room__grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Thumbnails / presenter row */
|
||||
.preview-thumbs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.preview-thumb {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
background: #111;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.preview-thumb .name {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Controls bar below central area */
|
||||
.controls-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.layout-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.layout-btn {
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--studio-bg-primary);
|
||||
border: 1px solid var(--studio-border);
|
||||
}
|
||||
.layout-btn.active {
|
||||
background: var(--studio-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.actions .btn-record {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
}
|
||||
.actions .btn-stop {
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Right column: comments / assets */
|
||||
.studio-portal__right {
|
||||
width: 320px;
|
||||
background: var(--studio-bg-secondary);
|
||||
padding: 12px;
|
||||
border-left: 1px solid var(--studio-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar-section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.02);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive behavior */
|
||||
@media (max-width: 1100px) {
|
||||
.studio-portal {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.studio-portal__left,
|
||||
.studio-portal__right {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.studio-portal__center {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small utility */
|
||||
.scene-item img {
|
||||
max-width: 36px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import StudioRoom from './StudioRoom';
|
||||
import './StudioPortal.css';
|
||||
import { Room } from 'livekit-client';
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import StudioRoom from "./StudioRoom";
|
||||
import "./StudioPortal.css";
|
||||
import { Room } from "livekit-client";
|
||||
import AutoRequestAndInject from "./AutoRequestAndInject";
|
||||
|
||||
export interface StudioPortalProps {
|
||||
serverUrl: string;
|
||||
@ -15,17 +16,27 @@ export interface StudioPortalProps {
|
||||
}
|
||||
|
||||
const LAYOUTS = [
|
||||
{ id: 'layout-1', label: 'Individual' },
|
||||
{ id: 'layout-2', label: 'Gallery' },
|
||||
{ id: 'layout-3', label: 'Speaker' },
|
||||
{ id: 'layout-4', label: 'Wide' },
|
||||
{ id: "layout-1", label: "Individual" },
|
||||
{ id: "layout-2", label: "Gallery" },
|
||||
{ id: "layout-3", label: "Speaker" },
|
||||
{ id: "layout-4", label: "Wide" },
|
||||
];
|
||||
|
||||
export default function StudioPortal({ serverUrl, token, roomName, onRoomConnected, onRoomDisconnected, onRoomConnectError, room }: StudioPortalProps) {
|
||||
export default function StudioPortal({
|
||||
serverUrl,
|
||||
token,
|
||||
roomName,
|
||||
onRoomConnected,
|
||||
onRoomDisconnected,
|
||||
onRoomConnectError,
|
||||
room,
|
||||
}: StudioPortalProps) {
|
||||
const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id);
|
||||
const [live, setLive] = useState(false);
|
||||
// allow override of serverUrl via postMessage (useful for e2e)
|
||||
const [serverUrlOverride, setServerUrlOverride] = useState<string | null>(null);
|
||||
const [serverUrlOverride, setServerUrlOverride] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Local room management when App does not provide a room prop
|
||||
const localRoomRef = useRef<Room | null>(null);
|
||||
@ -47,7 +58,9 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
setIsConnecting(true);
|
||||
// cleanup previous
|
||||
if (localRoomRef.current) {
|
||||
try { localRoomRef.current.disconnect(); } catch(e) {}
|
||||
try {
|
||||
localRoomRef.current.disconnect();
|
||||
} catch (e) {}
|
||||
localRoomRef.current = null;
|
||||
}
|
||||
const r = new Room();
|
||||
@ -56,11 +69,13 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
setIsConnected(true);
|
||||
onRoomConnected && onRoomConnected();
|
||||
} catch (err) {
|
||||
console.error('StudioPortal: failed to connect local room', err);
|
||||
console.error("StudioPortal: failed to connect local room", err);
|
||||
setIsConnected(false);
|
||||
const msg = (err as any)?.message ?? String(err);
|
||||
setConnectError(msg);
|
||||
try { onRoomConnectError && onRoomConnectError(err) } catch(e){}
|
||||
try {
|
||||
onRoomConnectError && onRoomConnectError(err);
|
||||
} catch (e) {}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
@ -72,14 +87,22 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
localRoomRef.current.disconnect();
|
||||
localRoomRef.current = null;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
setIsConnected(false);
|
||||
onRoomDisconnected && onRoomDisconnected();
|
||||
};
|
||||
|
||||
// Auto-connect when token becomes available and there is no external room
|
||||
useEffect(() => {
|
||||
if (!isExternalRoom && (tokenFromMessage || token) && (tokenFromMessage || token).trim() && !isConnected && !isConnecting) {
|
||||
if (
|
||||
!isExternalRoom &&
|
||||
(tokenFromMessage || token) &&
|
||||
(tokenFromMessage || token).trim() &&
|
||||
!isConnected &&
|
||||
!isConnecting
|
||||
) {
|
||||
connectWithToken();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -91,46 +114,79 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
try {
|
||||
const data = e.data || {};
|
||||
// respond to ping from launcher so it knows we are ready
|
||||
if (data && data.type === 'LIVEKIT_PING') {
|
||||
try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch(e) { /* ignore */ }
|
||||
return
|
||||
if (data && data.type === "LIVEKIT_PING") {
|
||||
try {
|
||||
(e.source as Window)?.postMessage?.(
|
||||
{ type: "LIVEKIT_READY" },
|
||||
e.origin || "*",
|
||||
);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
// accept object messages with type LIVEKIT_TOKEN and token
|
||||
if (data && data.type === 'LIVEKIT_TOKEN' && data.token) {
|
||||
console.info('StudioPortal received token via postMessage', { origin: e.origin })
|
||||
setTokenFromMessage(String(data.token))
|
||||
if (data && data.type === "LIVEKIT_TOKEN" && data.token) {
|
||||
console.info("StudioPortal received token via postMessage", {
|
||||
origin: e.origin,
|
||||
});
|
||||
setTokenFromMessage(String(data.token));
|
||||
// optionally accept serverUrl override for e2e flows
|
||||
if (data.serverUrl) setServerUrlOverride(String(data.serverUrl));
|
||||
// reply ack to sender
|
||||
try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_ACK', room: data.room || '' }, e.origin || '*') } catch(e) { /* ignore */ }
|
||||
try {
|
||||
(e.source as Window)?.postMessage?.(
|
||||
{ type: "LIVEKIT_ACK", room: data.room || "" },
|
||||
e.origin || "*",
|
||||
);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (err) { console.warn('postMessage handler error', err) }
|
||||
} catch (err) {
|
||||
console.warn("postMessage handler error", err);
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => { window.removeEventListener('message', onMessage) }
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => {
|
||||
window.removeEventListener("message", onMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try { if (localRoomRef.current) { localRoomRef.current.disconnect(); localRoomRef.current = null; } } catch (e) {}
|
||||
try {
|
||||
if (localRoomRef.current) {
|
||||
localRoomRef.current.disconnect();
|
||||
localRoomRef.current = null;
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStartLive = () => {
|
||||
window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'start' } }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("avz:request:go-live", { detail: { action: "start" } }),
|
||||
);
|
||||
setLive(true);
|
||||
};
|
||||
const handleStopLive = () => {
|
||||
window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'stop' } }));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("avz:request:go-live", { detail: { action: "stop" } }),
|
||||
);
|
||||
setLive(false);
|
||||
};
|
||||
|
||||
const changeLayout = (id: string) => {
|
||||
setActiveLayout(id);
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('avz:layout:change', { detail: { layoutId: id } }));
|
||||
} catch (e) { console.warn('layout dispatch failed', e); }
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("avz:layout:change", { detail: { layoutId: id } }),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("layout dispatch failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which room to pass into StudioRoom: external first, fallback to local
|
||||
@ -149,49 +205,97 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
</aside>
|
||||
|
||||
<main className="studio-portal__center">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14 }}>
|
||||
<strong>LiveKit:</strong> {serverUrlOverride || serverUrl}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{!isExternalRoom && (
|
||||
<>
|
||||
<button className="btn-small" onClick={() => connectWithToken()} disabled={isConnecting || isConnected}>
|
||||
{isConnecting ? 'Conectando...' : isConnected ? 'Conectado' : 'Conectar'}
|
||||
<button
|
||||
className="btn-small"
|
||||
onClick={() => connectWithToken()}
|
||||
disabled={isConnecting || isConnected}
|
||||
>
|
||||
{isConnecting
|
||||
? "Conectando..."
|
||||
: isConnected
|
||||
? "Conectado"
|
||||
: "Conectar"}
|
||||
</button>
|
||||
<button className="btn-small" onClick={disconnectLocalRoom} disabled={!isConnected}>
|
||||
<button
|
||||
className="btn-small"
|
||||
onClick={disconnectLocalRoom}
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Desconectar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isExternalRoom && (
|
||||
<div style={{ fontSize: 13, color: '#6b7280' }}>Usando Room externo</div>
|
||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
Usando Room externo
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* show token status / errors for E2E debugging */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{/* Optional auto-inject helper for integrated flows (controlled via Vite env VITE_ENABLE_AUTO_INJECT=1) */}
|
||||
{(import.meta.env.VITE_ENABLE_AUTO_INJECT as string) === "1" && (
|
||||
<AutoRequestAndInject roomName={roomName || "avanzacast-studio"} />
|
||||
)}
|
||||
{tokenFromMessage ? (
|
||||
<div style={{ fontSize: 13, color: isConnected ? '#10b981' : '#6b7280' }}>Token recibido desde Broadcast Panel (length {tokenFromMessage.length})</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: isConnected ? "#10b981" : "#6b7280",
|
||||
}}
|
||||
>
|
||||
Token recibido desde Broadcast Panel (length{" "}
|
||||
{tokenFromMessage.length})
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 13, color: '#9ca3af' }}>Esperando token...</div>
|
||||
<div style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||
Esperando token...
|
||||
</div>
|
||||
)}
|
||||
{connectError && (
|
||||
<div style={{ marginTop: 6, color: '#ef4444', fontSize: 13 }} className="studio-error-modal">Error de conexión: {connectError}</div>
|
||||
<div
|
||||
style={{ marginTop: 6, color: "#ef4444", fontSize: 13 }}
|
||||
className="studio-error-modal"
|
||||
>
|
||||
Error de conexión: {connectError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`preview-wrapper ${activeLayout}`}>
|
||||
<StudioRoom serverUrl={serverUrl} token={tokenFromMessage || token} roomName={roomName} onConnected={onRoomConnected} onDisconnected={onRoomDisconnected} room={effectiveRoom} />
|
||||
<StudioRoom
|
||||
serverUrl={serverUrl}
|
||||
token={tokenFromMessage || token}
|
||||
roomName={roomName}
|
||||
onConnected={onRoomConnected}
|
||||
onDisconnected={onRoomDisconnected}
|
||||
room={effectiveRoom}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="controls-bar">
|
||||
<div className="layout-presets">
|
||||
{LAYOUTS.map(l => (
|
||||
{LAYOUTS.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
className={`layout-btn ${activeLayout === l.id ? 'active' : ''}`}
|
||||
className={`layout-btn ${activeLayout === l.id ? "active" : ""}`}
|
||||
onClick={() => changeLayout(l.id)}
|
||||
>
|
||||
{l.label}
|
||||
@ -201,9 +305,19 @@ export default function StudioPortal({ serverUrl, token, roomName, onRoomConnect
|
||||
|
||||
<div className="actions">
|
||||
{!live ? (
|
||||
<button className="btn-record btn-go-live" onClick={handleStartLive}>Ir en vivo</button>
|
||||
<button
|
||||
className="btn-record btn-go-live"
|
||||
onClick={handleStartLive}
|
||||
>
|
||||
Ir en vivo
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn-stop btn-end-live" onClick={handleStopLive}>Finalizar transmisión</button>
|
||||
<button
|
||||
className="btn-stop btn-end-live"
|
||||
onClick={handleStopLive}
|
||||
>
|
||||
Finalizar transmisión
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { LiveKitRoom, VideoConference } from "@livekit/components-react";
|
||||
|
||||
export interface StudioPortalWithComponentsProps {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
connect?: boolean;
|
||||
roomName?: string;
|
||||
}
|
||||
|
||||
const StudioPortalWithComponents: React.FC<StudioPortalWithComponentsProps> = ({
|
||||
serverUrl,
|
||||
token,
|
||||
connect = true,
|
||||
}) => {
|
||||
// This component is a thin wrapper around LiveKit components. It expects a valid token and serverUrl.
|
||||
// Usage: <StudioPortalWithComponents serverUrl={URL} token={TOKEN} connect={true} />
|
||||
|
||||
if (!token || !serverUrl) {
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ fontWeight: 600 }}>Studio (LiveKit)</div>
|
||||
<div style={{ color: "#6b7280", marginTop: 8 }}>
|
||||
Esperando token y/o URL del servidor...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
<LiveKitRoom token={token} serverUrl={serverUrl} connect={connect}>
|
||||
<VideoConference />
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioPortalWithComponents;
|
||||
@ -1,7 +1,110 @@
|
||||
/* Minimal placeholder styles for StudioRoom used in tests */
|
||||
.studio-room { font-family: sans-serif; }
|
||||
.studio-room__header { display:flex; justify-content:space-between; }
|
||||
.studio-room__content { height: 400px; }
|
||||
.controls-inner { display:flex; gap:8px; }
|
||||
.tooltip { display:none; }
|
||||
.studio-room {
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.studio-room__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
.studio-room__title h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.studio-room__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.studio-room__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.preview-wrapper .studio-room__grid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Grid layout for participants */
|
||||
.studio-room__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-auto-rows: minmax(200px, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Participant tile overrides to make them occupy full area */
|
||||
.lk-participant-tile {
|
||||
background: #0b1220;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lk-participant-name {
|
||||
font-size: 13px;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.studio-room__controls {
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.control-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Thumbnails row under main stage */
|
||||
.preview-thumbs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.preview-thumb {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
background: #0f1724;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview-thumb .name {
|
||||
font-size: 13px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* Overlay svg lines */
|
||||
.connections-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Small helper for connecting state */
|
||||
.studio-room__connecting {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import React from "react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
// Ensure livekit UI lib is mocked before any module imports that may load it
|
||||
vi.mock('@livekit/components-react', () => {
|
||||
const React = require('react');
|
||||
const noop = () => React.createElement('div', null);
|
||||
vi.mock("@livekit/components-react", () => {
|
||||
const React = require("react");
|
||||
const noop = () => React.createElement("div", null);
|
||||
return {
|
||||
GridLayout: noop,
|
||||
ParticipantTile: noop,
|
||||
@ -21,69 +21,106 @@ vi.mock('@livekit/components-react', () => {
|
||||
usePrefetchRoom: () => ({}),
|
||||
};
|
||||
});
|
||||
vi.mock('@livekit/components-styles', () => ({}));
|
||||
vi.mock("@livekit/components-styles", () => ({}));
|
||||
|
||||
// Stub the actual StudioRoom implementation in the studio-panel package to avoid heavy hooks
|
||||
vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => React.createElement('div', { id: 'studio-room-mock' })
|
||||
}))
|
||||
vi.mock(
|
||||
"../../../../studio-panel/src/components/StudioRoom/StudioRoom",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (props: any) =>
|
||||
React.createElement("div", { id: "studio-room-mock" }),
|
||||
}),
|
||||
);
|
||||
|
||||
// Also stub the local re-export module to be safe
|
||||
vi.mock('../StudioRoom/StudioRoom', () => ({
|
||||
vi.mock("../StudioRoom/StudioRoom", () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => React.createElement('div', { id: 'studio-room-mock' })
|
||||
}))
|
||||
default: (props: any) =>
|
||||
React.createElement("div", { id: "studio-room-mock" }),
|
||||
}));
|
||||
|
||||
import StudioPortal from '../StudioPortal'
|
||||
const livekitMock: any = require('livekit-client');
|
||||
import StudioPortal from "../StudioPortal";
|
||||
const livekitMock: any = require("livekit-client");
|
||||
|
||||
describe('StudioPortal', () => {
|
||||
describe("StudioPortal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllMocks();
|
||||
// reset instances array
|
||||
if (livekitMock && livekitMock.__mocks && Array.isArray(livekitMock.__mocks.instances)) {
|
||||
if (
|
||||
livekitMock &&
|
||||
livekitMock.__mocks &&
|
||||
Array.isArray(livekitMock.__mocks.instances)
|
||||
) {
|
||||
livekitMock.__mocks.instances.length = 0;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('creates a local Room and connects when token is provided and no external room', async () => {
|
||||
render(<StudioPortal serverUrl="wss://example" token="FAKE_TOKEN" roomName="r" />)
|
||||
it("creates a local Room and connects when token is provided and no external room", async () => {
|
||||
render(
|
||||
<StudioPortal
|
||||
serverUrl="wss://example"
|
||||
token="FAKE_TOKEN"
|
||||
roomName="r"
|
||||
/>,
|
||||
);
|
||||
|
||||
// wait for the connect to be called
|
||||
await waitFor(() => {
|
||||
expect(livekitMock.__mocks.instances[0]).toBeDefined()
|
||||
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://example', 'FAKE_TOKEN')
|
||||
})
|
||||
})
|
||||
expect(livekitMock.__mocks.instances[0]).toBeDefined();
|
||||
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith(
|
||||
"wss://example",
|
||||
"FAKE_TOKEN",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not create a local Room when external room is provided', async () => {
|
||||
const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() }
|
||||
render(<StudioPortal serverUrl="wss://example" token="FAKE_TOKEN" roomName="r" room={fakeRoom} />)
|
||||
it("does not create a local Room when external room is provided", async () => {
|
||||
const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() };
|
||||
render(
|
||||
<StudioPortal
|
||||
serverUrl="wss://example"
|
||||
token="FAKE_TOKEN"
|
||||
roomName="r"
|
||||
room={fakeRoom}
|
||||
/>,
|
||||
);
|
||||
|
||||
// local constructor should not be called
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(livekitMock.__mocks.instances.length).toBe(0)
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(livekitMock.__mocks.instances.length).toBe(0);
|
||||
});
|
||||
|
||||
it('connect/disconnect buttons call connectWithToken and disconnect', async () => {
|
||||
it("connect/disconnect buttons call connectWithToken and disconnect", async () => {
|
||||
// render without auto token to test manual connect: pass empty token first
|
||||
const { rerender } = render(<StudioPortal serverUrl="wss://example" token="" roomName="r" />)
|
||||
const { rerender } = render(
|
||||
<StudioPortal serverUrl="wss://example" token="" roomName="r" />,
|
||||
);
|
||||
|
||||
// Click connect button -> nothing happens since token empty, ensure no constructor called
|
||||
const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { exact: false })
|
||||
fireEvent.click(connectBtn)
|
||||
expect(livekitMock.__mocks.instances.length).toBe(0)
|
||||
const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, {
|
||||
exact: false,
|
||||
});
|
||||
fireEvent.click(connectBtn);
|
||||
expect(livekitMock.__mocks.instances.length).toBe(0);
|
||||
|
||||
// Rerender with token to enable connect via button
|
||||
rerender(<StudioPortal serverUrl="wss://example" token="MANUAL_TOKEN" roomName="r" />)
|
||||
rerender(
|
||||
<StudioPortal
|
||||
serverUrl="wss://example"
|
||||
token="MANUAL_TOKEN"
|
||||
roomName="r"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for auto connect (effect) or click button to trigger connect
|
||||
await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined())
|
||||
await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined());
|
||||
|
||||
// Now test disconnect button triggers disconnect
|
||||
const disconnectBtn = screen.getByText('Desconectar')
|
||||
fireEvent.click(disconnectBtn)
|
||||
await waitFor(() => expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
const disconnectBtn = screen.getByText("Desconectar");
|
||||
fireEvent.click(disconnectBtn);
|
||||
await waitFor(() =>
|
||||
expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,126 +1,181 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { render, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import useStudioLauncher from '../../../hooks/useStudioLauncher'
|
||||
import useStudioLauncher from "../../../hooks/useStudioLauncher";
|
||||
|
||||
// Ensure we don't import the heavy StudioRoom implementation that requires ResizeObserver/WebRTC
|
||||
vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({
|
||||
vi.mock(
|
||||
"../../../../studio-panel/src/components/StudioRoom/StudioRoom",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (_props: any) =>
|
||||
React.createElement("div", { id: "studio-room-mock" }),
|
||||
}),
|
||||
);
|
||||
vi.mock("../StudioRoom/StudioRoom", () => ({
|
||||
__esModule: true,
|
||||
default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' })
|
||||
}))
|
||||
vi.mock('../StudioRoom/StudioRoom', () => ({
|
||||
__esModule: true,
|
||||
default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' })
|
||||
}))
|
||||
default: (_props: any) =>
|
||||
React.createElement("div", { id: "studio-room-mock" }),
|
||||
}));
|
||||
|
||||
import StudioPortal from '../StudioPortal'
|
||||
import StudioPortal from "../StudioPortal";
|
||||
|
||||
// Simple component that exposes openStudio via a button using the hook
|
||||
function LauncherButton({ room = 'sim-room', username = 'tester' }: { room?: string; username?: string }) {
|
||||
const { openStudio } = useStudioLauncher()
|
||||
function LauncherButton({
|
||||
room = "sim-room",
|
||||
username = "tester",
|
||||
}: {
|
||||
room?: string;
|
||||
username?: string;
|
||||
}) {
|
||||
const { openStudio } = useStudioLauncher();
|
||||
return (
|
||||
<button onClick={() => openStudio({ room, username })} data-testid="open-studio">
|
||||
<button
|
||||
onClick={() => openStudio({ room, username })}
|
||||
data-testid="open-studio"
|
||||
>
|
||||
Open Studio
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// App side that listens for LIVEKIT_TOKEN and renders StudioPortal when token arrives
|
||||
function TestApp() {
|
||||
const [token, setToken] = useState<string>('')
|
||||
const [serverUrl, setServerUrl] = useState<string>('')
|
||||
const [token, setToken] = useState<string>("");
|
||||
const [serverUrl, setServerUrl] = useState<string>("");
|
||||
useEffect(() => {
|
||||
function onMessage(e: MessageEvent) {
|
||||
try {
|
||||
const d = e.data || {}
|
||||
if (d?.type === 'LIVEKIT_TOKEN' && d.token) {
|
||||
const d = e.data || {};
|
||||
if (d?.type === "LIVEKIT_TOKEN" && d.token) {
|
||||
// set token and optional url
|
||||
setToken(String(d.token))
|
||||
if (d.url) setServerUrl(String(d.url))
|
||||
setToken(String(d.token));
|
||||
if (d.url) setServerUrl(String(d.url));
|
||||
// Reply ACK via the message source if available
|
||||
try {
|
||||
const ack = { type: 'LIVEKIT_ACK', status: 'connected', room: d.room }
|
||||
if (e.source && typeof (e.source as any).postMessage === 'function') {
|
||||
try { (e.source as any).postMessage(ack, e.origin || '*') } catch (err) {}
|
||||
const ack = {
|
||||
type: "LIVEKIT_ACK",
|
||||
status: "connected",
|
||||
room: d.room,
|
||||
};
|
||||
if (
|
||||
e.source &&
|
||||
typeof (e.source as any).postMessage === "function"
|
||||
) {
|
||||
try {
|
||||
(e.source as any).postMessage(ack, e.origin || "*");
|
||||
} catch (err) {}
|
||||
}
|
||||
// also post to opener/parent just in case
|
||||
try { window.postMessage(ack, e.origin || '*') } catch (err) {}
|
||||
try {
|
||||
window.postMessage(ack, e.origin || "*");
|
||||
} catch (err) {}
|
||||
} catch (err) {}
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => window.removeEventListener('message', onMessage)
|
||||
}, [])
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="status">{token ? 'token:' + token : 'no-token'}</div>
|
||||
{token ? <StudioPortal serverUrl={serverUrl || 'wss://example'} token={token} roomName={'sim-room'} /> : null}
|
||||
<div data-testid="status">{token ? "token:" + token : "no-token"}</div>
|
||||
{token ? (
|
||||
<StudioPortal
|
||||
serverUrl={serverUrl || "wss://example"}
|
||||
token={token}
|
||||
roomName={"sim-room"}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe('E2E simulated flow: Broadcast -> Studio', () => {
|
||||
let originalOpen: any
|
||||
let popupMock: any
|
||||
describe("E2E simulated flow: Broadcast -> Studio", () => {
|
||||
let originalOpen: any;
|
||||
let popupMock: any;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalOpen = (window as any).open
|
||||
vi.clearAllMocks();
|
||||
originalOpen = (window as any).open;
|
||||
|
||||
// popup mock that simply dispatches message events to window when postMessage is called
|
||||
// make popupMock a callable function object so TS doesn't complain if it's invoked
|
||||
const pm: any = function() { /* noop callable */ };
|
||||
pm.location = { href: '' };
|
||||
const pm: any = function () {
|
||||
/* noop callable */
|
||||
};
|
||||
pm.location = { href: "" };
|
||||
pm.closed = false;
|
||||
pm.postMessage = (message: any, targetOrigin: string) => {
|
||||
// simulate asynchronous arrival in the popup (studio)
|
||||
setTimeout(() => {
|
||||
// Message arrives to the studio (which in our test is the same window)
|
||||
const ev = new MessageEvent('message', { data: message, origin: targetOrigin, source: pm })
|
||||
window.dispatchEvent(ev)
|
||||
}, 20)
|
||||
const ev = new MessageEvent("message", {
|
||||
data: message,
|
||||
origin: targetOrigin,
|
||||
source: pm,
|
||||
});
|
||||
window.dispatchEvent(ev);
|
||||
}, 20);
|
||||
};
|
||||
popupMock = pm
|
||||
|
||||
// Replace window.open to return our popup mock
|
||||
(window as any).open = vi.fn(() => popupMock)
|
||||
popupMock = pm(
|
||||
// Replace window.open to return our popup mock
|
||||
window as any,
|
||||
).open = vi.fn(() => popupMock);
|
||||
|
||||
// mock fetch to token server to return a session with token and studioUrl
|
||||
globalThis.fetch = vi.fn(() => Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: 'SIM_TOKEN', room: 'sim-room', studioUrl: window.location.origin + '/studio', url: 'wss://livekit-server.example' })
|
||||
})) as any
|
||||
})
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
token: "SIM_TOKEN",
|
||||
room: "sim-room",
|
||||
studioUrl: window.location.origin + "/studio",
|
||||
url: "wss://livekit-server.example",
|
||||
}),
|
||||
}),
|
||||
) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).open = originalOpen
|
||||
(window as any).open = originalOpen;
|
||||
// @ts-ignore
|
||||
globalThis.fetch = undefined
|
||||
})
|
||||
globalThis.fetch = undefined;
|
||||
});
|
||||
|
||||
it('opens popup, sends token, studio receives it and StudioPortal connects (mock Room)', async () => {
|
||||
it("opens popup, sends token, studio receives it and StudioPortal connects (mock Room)", async () => {
|
||||
// render both launcher and app
|
||||
render(
|
||||
<div>
|
||||
<LauncherButton />
|
||||
<TestApp />
|
||||
</div>
|
||||
)
|
||||
</div>,
|
||||
);
|
||||
|
||||
// click launcher which calls openStudio -> will call fetch and popup.postMessage repeatedly
|
||||
const btn = screen.getByTestId('open-studio')
|
||||
fireEvent.click(btn)
|
||||
const btn = screen.getByTestId("open-studio");
|
||||
fireEvent.click(btn);
|
||||
|
||||
// Wait for TestApp to receive token and render StudioPortal status
|
||||
await waitFor(() => expect(screen.getByTestId('status').textContent).toContain('SIM_TOKEN'), { timeout: 2000 })
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(screen.getByTestId("status").textContent).toContain("SIM_TOKEN"),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
// Now assert that the mocked livekit Room was instantiated and connect called.
|
||||
// The project-level vitest setup provides a mock for 'livekit-client' with __mocks.instances
|
||||
const livekitMock: any = require('livekit-client')
|
||||
await waitFor(() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0), { timeout: 2000 })
|
||||
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://livekit-server.example', 'SIM_TOKEN')
|
||||
})
|
||||
})
|
||||
const livekitMock: any = require("livekit-client");
|
||||
await waitFor(
|
||||
() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith(
|
||||
"wss://livekit-server.example",
|
||||
"SIM_TOKEN",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// load the postMessage util from broadcast-panel
|
||||
import * as pm from '../../../utils/postMessage';
|
||||
import * as pm from "../../../utils/postMessage";
|
||||
|
||||
describe('postMessage utils', () => {
|
||||
it('exposes environment parsing helpers', () => {
|
||||
describe("postMessage utils", () => {
|
||||
it("exposes environment parsing helpers", () => {
|
||||
// basic existence
|
||||
expect(typeof pm.getAllowedOriginsFromEnv === 'function' || typeof pm.isAllowedOrigin === 'function').toBeTruthy();
|
||||
})
|
||||
})
|
||||
expect(
|
||||
typeof pm.getAllowedOriginsFromEnv === "function" ||
|
||||
typeof pm.isAllowedOrigin === "function",
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,32 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
||||
import React from "react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
// Stub Studio implementation to a minimal status element to avoid heavy dependencies
|
||||
vi.mock('../../../components/Studio', () => ({
|
||||
vi.mock("../../../components/Studio", () => ({
|
||||
__esModule: true,
|
||||
default: () => React.createElement('div', { id: 'status' }, 'Conectado')
|
||||
}))
|
||||
default: () => React.createElement("div", { id: "status" }, "Conectado"),
|
||||
}));
|
||||
|
||||
import Studio from '../../../components/Studio'
|
||||
import Studio from "../../../components/Studio";
|
||||
|
||||
describe('smoke test - Broadcast Studio integration', () => {
|
||||
describe("smoke test - Broadcast Studio integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it('renders Studio and auto connects via proxy token', async () => {
|
||||
it("renders Studio and auto connects via proxy token", async () => {
|
||||
// Mock fetch to return token
|
||||
(globalThis as any).fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ token: 'SMOKE', url: 'wss://example' }) }))
|
||||
(globalThis as any).fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: "SMOKE", url: "wss://example" }),
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Studio />)
|
||||
render(<Studio />);
|
||||
|
||||
await waitFor(() => {
|
||||
const el = document.getElementById('status')
|
||||
expect(el).toBeTruthy()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
await waitFor(
|
||||
() => {
|
||||
const el = document.getElementById("status");
|
||||
expect(el).toBeTruthy();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,22 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
export default function IconCameraOn(){
|
||||
export default function IconCameraOn() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 7H5L7 4H17L19 7H21C22.1046 7 23 7.89543 23 9V17C23 18.1046 22.1046 19 21 19H3C1.89543 19 1 18.1046 1 17V9C1 7.89543 1.89543 7 3 7Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 7H5L7 4H17L19 7H21C22.1046 7 23 7.89543 23 9V17C23 18.1046 22.1046 19 21 19H3C1.89543 19 1 18.1046 1 17V9C1 7.89543 1.89543 7 3 7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,35 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
export default function IconMicOff(){
|
||||
export default function IconMicOff() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 1V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8 11V7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 11C19 14.3137 16.3137 17 13 17H11C7.68629 17 5 14.3137 5 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 1V11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 11V7C8 4.79086 9.79086 3 12 3C14.2091 3 16 4.79086 16 7V11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 11C19 14.3137 16.3137 17 13 17H11C7.68629 17 5 14.3137 5 11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export { default as StudioPortal } from './StudioPortal';
|
||||
export { default as StudioRoom } from './StudioRoom';
|
||||
|
||||
export { default as StudioPortal } from "./StudioPortal";
|
||||
export { default as StudioRoom } from "./StudioRoom";
|
||||
|
||||
6
packages/broadcast-panel/src/hooks/index.ts
Normal file
6
packages/broadcast-panel/src/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// Barrel exports for hooks
|
||||
export * from "./useStudioSession";
|
||||
export { default as useStudioLauncher } from "./useStudioLauncher";
|
||||
export { default as useStudioMessageListener } from "./useStudioMessageListener";
|
||||
export { useToast, ToastProvider } from "./useToast";
|
||||
// Note: `useLayouts.ts` currently has no exports; add it here once it exports symbols
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,48 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect } from "react";
|
||||
|
||||
export type LivekitMessage = {
|
||||
type: 'LIVEKIT_TOKEN'
|
||||
token?: string
|
||||
room?: string
|
||||
url?: string
|
||||
}
|
||||
type: "LIVEKIT_TOKEN";
|
||||
token?: string;
|
||||
room?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export default function useStudioMessageListener(onReceive: (msg: LivekitMessage) => void) {
|
||||
export default function useStudioMessageListener(
|
||||
onReceive: (msg: LivekitMessage) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
// Build allowed origins list from env or default to current origin
|
||||
const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS || import.meta.env.VITE_BROADCASTPANEL_URL || '') as string
|
||||
const allowedOrigins = envAllowed ? envAllowed.split(',').map(s => s.trim()) : [window.location.origin]
|
||||
const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS ||
|
||||
import.meta.env.VITE_BROADCASTPANEL_URL ||
|
||||
"") as string;
|
||||
const allowedOrigins = envAllowed
|
||||
? envAllowed.split(",").map((s) => s.trim())
|
||||
: [window.location.origin];
|
||||
|
||||
function handler(e: MessageEvent) {
|
||||
try {
|
||||
// Validate origin: allow same-window messages (origin may equal window.location.origin)
|
||||
const origin = e.origin || window.location.origin
|
||||
if (!allowedOrigins.includes(origin) && origin !== window.location.origin) return
|
||||
const origin = e.origin || window.location.origin;
|
||||
if (
|
||||
!allowedOrigins.includes(origin) &&
|
||||
origin !== window.location.origin
|
||||
)
|
||||
return;
|
||||
|
||||
const data = e.data || {}
|
||||
if (data && data.type === 'LIVEKIT_TOKEN') {
|
||||
onReceive({ type: 'LIVEKIT_TOKEN', token: data.token, room: data.room, url: data.url })
|
||||
const data = e.data || {};
|
||||
if (data && data.type === "LIVEKIT_TOKEN") {
|
||||
onReceive({
|
||||
type: "LIVEKIT_TOKEN",
|
||||
token: data.token,
|
||||
room: data.room,
|
||||
url: data.url,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [onReceive])
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [onReceive]);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// - GET /api/session/:id/token
|
||||
// - Returns { token, url, room, username } or error
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type StudioSession = {
|
||||
token: string;
|
||||
@ -24,17 +24,28 @@ export function useStudioSession(sessionId?: string | null) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const base = import.meta.env.VITE_BACKEND_TOKENS_URL || import.meta.env.VITE_BROADCASTPANEL_URL || '';
|
||||
const base =
|
||||
import.meta.env.VITE_BACKEND_TOKENS_URL ||
|
||||
import.meta.env.VITE_BROADCASTPANEL_URL ||
|
||||
"";
|
||||
// Prefer absolute backend URL env var; else assume same origin + port 4000
|
||||
const backend = import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`;
|
||||
const url = `${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/token`;
|
||||
const backend =
|
||||
import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`;
|
||||
const url = `${backend.replace(/\/$/, "")}/api/session/${encodeURIComponent(sessionId)}/token`;
|
||||
|
||||
// Helpful debug information for E2E and development
|
||||
console.debug('[useStudioSession] fetching token from', url, { sessionId, backend, envBase: base });
|
||||
console.debug("[useStudioSession] fetching token from", url, {
|
||||
sessionId,
|
||||
backend,
|
||||
envBase: base,
|
||||
});
|
||||
|
||||
const resp = await fetch(url, { method: 'GET', credentials: 'include' });
|
||||
const resp = await fetch(url, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => '(<no body>)');
|
||||
const txt = await resp.text().catch(() => "(<no body>)");
|
||||
const msg = `fetch failed status=${resp.status} body=${txt}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
@ -45,10 +56,17 @@ export function useStudioSession(sessionId?: string | null) {
|
||||
// Enriquecer el mensaje para ayudar a diagnosticar 'Failed to fetch'
|
||||
let enriched = String(err?.message || err);
|
||||
// If it's a network error (TypeError: Failed to fetch) give common causes hints
|
||||
if (enriched.includes('Failed to fetch') || enriched.includes('NetworkError') || enriched.includes('TypeError')) {
|
||||
if (
|
||||
enriched.includes("Failed to fetch") ||
|
||||
enriched.includes("NetworkError") ||
|
||||
enriched.includes("TypeError")
|
||||
) {
|
||||
enriched = `${enriched} — posible causa: backend no accesible, CORS bloqueando la petición, mixed-content (https página -> http backend), o error de red. Comprueba que VITE_BACKEND_TOKENS_URL apunta al backend correcto y que el backend está levantado en esa URL.`;
|
||||
}
|
||||
console.error('[useStudioSession] error fetching token', { sessionId, error: enriched });
|
||||
console.error("[useStudioSession] error fetching token", {
|
||||
sessionId,
|
||||
error: enriched,
|
||||
});
|
||||
setError(enriched);
|
||||
}
|
||||
} finally {
|
||||
@ -56,7 +74,9 @@ export function useStudioSession(sessionId?: string | null) {
|
||||
}
|
||||
}
|
||||
fetchToken();
|
||||
return () => { aborted = true };
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { loading, data, error };
|
||||
|
||||
@ -1,44 +1,63 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
import Toast from '../components/Toast'
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import Toast from "../components/Toast";
|
||||
|
||||
type ToastItem = { id: string, message: string, variant?: 'info'|'success'|'error'|'warning' }
|
||||
type ToastItem = {
|
||||
id: string;
|
||||
message: string;
|
||||
variant?: "info" | "success" | "error" | "warning";
|
||||
};
|
||||
|
||||
const ToastContext = createContext<{ show: (m: string, v?: ToastItem['variant']) => void } | undefined>(undefined)
|
||||
const ToastContext = createContext<
|
||||
{ show: (m: string, v?: ToastItem["variant"]) => void } | undefined
|
||||
>(undefined);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [list, setList] = useState<ToastItem[]>([])
|
||||
const [list, setList] = useState<ToastItem[]>([]);
|
||||
|
||||
function show(message: string, variant: ToastItem['variant'] = 'info') {
|
||||
const id = Math.random().toString(36).slice(2,9)
|
||||
setList(l => [...l, { id, message, variant }])
|
||||
setTimeout(() => setList(l => l.filter(x => x.id !== id)), 5000)
|
||||
function show(message: string, variant: ToastItem["variant"] = "info") {
|
||||
const id = Math.random().toString(36).slice(2, 9);
|
||||
setList((l) => [...l, { id, message, variant }]);
|
||||
setTimeout(() => setList((l) => l.filter((x) => x.id !== id)), 5000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onGlobal(e: Event) {
|
||||
try {
|
||||
const ce = e as CustomEvent
|
||||
if (!ce?.detail) return
|
||||
const { message, variant } = ce.detail as any
|
||||
if (message) show(message, variant)
|
||||
const ce = e as CustomEvent;
|
||||
if (!ce?.detail) return;
|
||||
const { message, variant } = ce.detail as any;
|
||||
if (message) show(message, variant);
|
||||
} catch (err) {}
|
||||
}
|
||||
window.addEventListener('AVZ_TOAST', onGlobal as EventListener)
|
||||
return () => window.removeEventListener('AVZ_TOAST', onGlobal as EventListener)
|
||||
}, [])
|
||||
window.addEventListener("AVZ_TOAST", onGlobal as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener("AVZ_TOAST", onGlobal as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ show }}>
|
||||
{children}
|
||||
<div style={{ position: 'fixed', right: 20, top: 20, zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{list.map(i => <Toast key={i.id} message={i.message} variant={i.variant} />)}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 20,
|
||||
top: 20,
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{list.map((i) => (
|
||||
<Toast key={i.id} message={i.message} variant={i.variant} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext)
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider')
|
||||
return ctx
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within ToastProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export { default as PageContainer } from './components/PageContainer'
|
||||
export { default as Sidebar } from './components/Sidebar'
|
||||
export { default as Header } from './components/Header'
|
||||
export { default as TransmissionsTable } from './components/TransmissionsTable'
|
||||
export { default as PageContainer } from "./components/PageContainer";
|
||||
export { default as Sidebar } from "./components/Sidebar";
|
||||
export { default as Header } from "./components/Header";
|
||||
export { default as TransmissionsTable } from "./components/TransmissionsTable";
|
||||
|
||||
17
packages/broadcast-panel/src/lib/client-utils.ts
Normal file
17
packages/broadcast-panel/src/lib/client-utils.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export function encodePassphrase(passphrase: string) {
|
||||
return encodeURIComponent(passphrase);
|
||||
}
|
||||
|
||||
export function decodePassphrase(base64String: string) {
|
||||
return decodeURIComponent(base64String);
|
||||
}
|
||||
|
||||
export function randomString(length: number): string {
|
||||
let result = "";
|
||||
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -42,12 +42,22 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export type { Transmission } from '@shared/types'
|
||||
|
||||
export type { Transmission } from "@shared/types";
|
||||
|
||||
@ -2,21 +2,33 @@
|
||||
export function getAllowedOriginsFromEnv(): string[] {
|
||||
const allowed = new Set<string>();
|
||||
try {
|
||||
const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || '';
|
||||
const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || "";
|
||||
if (raw) {
|
||||
raw.split(',').map(s => s.trim()).filter(Boolean).forEach(o => allowed.add(o));
|
||||
raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((o) => allowed.add(o));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || '';
|
||||
const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || "";
|
||||
if (studioUrl) {
|
||||
try {
|
||||
const u = new URL(studioUrl);
|
||||
allowed.add(u.origin);
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try { allowed.add(window.location.origin); } catch (e) {}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
allowed.add(window.location.origin);
|
||||
} catch (e) {}
|
||||
return Array.from(allowed);
|
||||
}
|
||||
|
||||
@ -26,7 +38,11 @@ export function isAllowedOrigin(origin: string | null | undefined): boolean {
|
||||
return list.includes(origin);
|
||||
}
|
||||
|
||||
export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) {
|
||||
export function safePostMessage(
|
||||
target: Window | null | undefined,
|
||||
message: any,
|
||||
targetOrigin: string,
|
||||
) {
|
||||
if (!target) return false;
|
||||
try {
|
||||
target.postMessage(message, targetOrigin);
|
||||
@ -36,4 +52,3 @@ export function safePostMessage(target: Window | null | undefined, message: any,
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,100 +1,172 @@
|
||||
export interface StudioTokenPayload {
|
||||
token: string
|
||||
serverUrl?: string
|
||||
room?: string
|
||||
user?: string
|
||||
token: string;
|
||||
serverUrl?: string;
|
||||
room?: string;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export async function openStudioWithToken(tokenData: StudioTokenPayload, opts?: { studioUrl?: string, shortIdLength?: number, onAck?: (ack: any) => void, forceRedirect?: boolean }) {
|
||||
const STUDIO_URL = opts?.studioUrl || (import.meta.env.VITE_STUDIO_URL as string) || 'https://avanzacast-studio.bfzqqk.easypanel.host'
|
||||
const shortId = Math.random().toString(36).slice(2, 2 + (opts?.shortIdLength || 8))
|
||||
const studioBase = STUDIO_URL.replace(/\/$/, '')
|
||||
const studioPath = `${studioBase}/${shortId}`
|
||||
export async function openStudioWithToken(
|
||||
tokenData: StudioTokenPayload,
|
||||
opts?: {
|
||||
studioUrl?: string;
|
||||
shortIdLength?: number;
|
||||
onAck?: (ack: any) => void;
|
||||
forceRedirect?: boolean;
|
||||
},
|
||||
) {
|
||||
const STUDIO_URL =
|
||||
opts?.studioUrl ||
|
||||
(import.meta.env.VITE_STUDIO_URL as string) ||
|
||||
"https://avanzacast-studio.bfzqqk.easypanel.host";
|
||||
const shortId = Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 2 + (opts?.shortIdLength || 8));
|
||||
const studioBase = STUDIO_URL.replace(/\/$/, "");
|
||||
const studioPath = `${studioBase}/${shortId}`;
|
||||
|
||||
const payload = {
|
||||
type: 'LIVEKIT_TOKEN',
|
||||
type: "LIVEKIT_TOKEN",
|
||||
token: tokenData.token,
|
||||
url: tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || '',
|
||||
room: tokenData.room || '',
|
||||
user: tokenData.user || '',
|
||||
}
|
||||
url:
|
||||
tokenData.serverUrl || (import.meta.env.VITE_LIVEKIT_URL as string) || "",
|
||||
room: tokenData.room || "",
|
||||
user: tokenData.user || "",
|
||||
};
|
||||
|
||||
const paramsForFallback = () => {
|
||||
const p = new URLSearchParams({ token: tokenData.token || '', room: tokenData.room || '', username: tokenData.user || '' })
|
||||
if (tokenData.serverUrl) p.set('serverUrl', tokenData.serverUrl)
|
||||
return p.toString()
|
||||
}
|
||||
const p = new URLSearchParams({
|
||||
token: tokenData.token || "",
|
||||
room: tokenData.room || "",
|
||||
username: tokenData.user || "",
|
||||
});
|
||||
if (tokenData.serverUrl) p.set("serverUrl", tokenData.serverUrl);
|
||||
return p.toString();
|
||||
};
|
||||
|
||||
const forceRedirectEnv = (import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || ''
|
||||
const forceRedirect = opts?.forceRedirect !== undefined ? opts.forceRedirect : (forceRedirectEnv === '0' ? false : true)
|
||||
const forceRedirectEnv =
|
||||
(import.meta.env.VITE_FORCE_STUDIO_REDIRECT as string) || "";
|
||||
const forceRedirect =
|
||||
opts?.forceRedirect !== undefined
|
||||
? opts.forceRedirect
|
||||
: forceRedirectEnv === "0"
|
||||
? false
|
||||
: true;
|
||||
|
||||
try {
|
||||
if (forceRedirect) {
|
||||
const q = paramsForFallback()
|
||||
window.location.href = `${studioPath}?${q}`
|
||||
return
|
||||
const q = paramsForFallback();
|
||||
window.location.href = `${studioPath}?${q}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const originAllowed = (() => {
|
||||
try { return new URL(STUDIO_URL).origin } catch { return '*' }
|
||||
})()
|
||||
try {
|
||||
return new URL(STUDIO_URL).origin;
|
||||
} catch {
|
||||
return "*";
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const win = window.open(studioPath, '_blank')
|
||||
const win = window.open(studioPath, "_blank");
|
||||
if (win) {
|
||||
let ackTimeout: number | null = null
|
||||
let ackTimeout: number | null = null;
|
||||
const ackListener = (e: MessageEvent) => {
|
||||
try {
|
||||
if (!e?.data) return
|
||||
const d = e.data
|
||||
if (d?.type === 'LIVEKIT_ACK') {
|
||||
try { opts?.onAck?.(d) } catch (err) { console.error('onAck callback error', err) }
|
||||
if (!opts?.onAck) {
|
||||
window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: d.status === 'connected' ? 'Studio conectado' : `Studio error: ${d?.error || d?.status}`, variant: d.status === 'connected' ? 'success' : 'error' } }))
|
||||
if (!e?.data) return;
|
||||
const d = e.data;
|
||||
if (d?.type === "LIVEKIT_ACK") {
|
||||
try {
|
||||
opts?.onAck?.(d);
|
||||
} catch (err) {
|
||||
console.error("onAck callback error", err);
|
||||
}
|
||||
if (ackTimeout) { clearTimeout(ackTimeout); ackTimeout = null }
|
||||
window.removeEventListener('message', ackListener as unknown as EventListener)
|
||||
if (!opts?.onAck) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("AVZ_TOAST", {
|
||||
detail: {
|
||||
message:
|
||||
d.status === "connected"
|
||||
? "Studio conectado"
|
||||
: `Studio error: ${d?.error || d?.status}`,
|
||||
variant: d.status === "connected" ? "success" : "error",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (ackTimeout) {
|
||||
clearTimeout(ackTimeout);
|
||||
ackTimeout = null;
|
||||
}
|
||||
window.removeEventListener(
|
||||
"message",
|
||||
ackListener as unknown as EventListener,
|
||||
);
|
||||
}
|
||||
} catch (err) { console.error('[studioLauncher] ackListener error', err) }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[studioLauncher] ackListener error", err);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', ackListener as unknown as EventListener)
|
||||
window.addEventListener(
|
||||
"message",
|
||||
ackListener as unknown as EventListener,
|
||||
);
|
||||
|
||||
ackTimeout = window.setTimeout(() => {
|
||||
try {
|
||||
window.removeEventListener('message', ackListener as unknown as EventListener)
|
||||
window.removeEventListener(
|
||||
"message",
|
||||
ackListener as unknown as EventListener,
|
||||
);
|
||||
if (!opts?.onAck) {
|
||||
window.dispatchEvent(new CustomEvent('AVZ_TOAST', { detail: { message: 'No se recibió confirmación del Studio', variant: 'warning' } }))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("AVZ_TOAST", {
|
||||
detail: {
|
||||
message: "No se recibió confirmación del Studio",
|
||||
variant: "warning",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) { console.error('[studioLauncher] ack timeout cleanup error', err) }
|
||||
}, 20000)
|
||||
} catch (err) {
|
||||
console.error("[studioLauncher] ack timeout cleanup error", err);
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
const post = () => {
|
||||
try {
|
||||
win.postMessage(payload, originAllowed)
|
||||
console.debug('[studioLauncher] postMessage ->', originAllowed)
|
||||
win.postMessage(payload, originAllowed);
|
||||
console.debug("[studioLauncher] postMessage ->", originAllowed);
|
||||
} catch (err) {
|
||||
try { (win as any).postMessage(payload, '*') } catch (err2) { console.error('[studioLauncher] postMessage failed', err2) }
|
||||
try {
|
||||
(win as any).postMessage(payload, "*");
|
||||
} catch (err2) {
|
||||
console.error("[studioLauncher] postMessage failed", err2);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(post, 300)
|
||||
setTimeout(post, 800)
|
||||
setTimeout(post, 1500)
|
||||
return
|
||||
setTimeout(post, 300);
|
||||
setTimeout(post, 800);
|
||||
setTimeout(post, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// popup blocked -> fallback to redirect
|
||||
const q = paramsForFallback()
|
||||
window.location.href = `${studioPath}?${q}`
|
||||
const q = paramsForFallback();
|
||||
window.location.href = `${studioPath}?${q}`;
|
||||
} catch (err) {
|
||||
console.error('[studioLauncher] Error opening studio panel, fallback to redirect', err)
|
||||
const q = paramsForFallback()
|
||||
window.location.href = `${studioPath}?${q}`
|
||||
console.error(
|
||||
"[studioLauncher] Error opening studio panel, fallback to redirect",
|
||||
err,
|
||||
);
|
||||
const q = paramsForFallback();
|
||||
window.location.href = `${studioPath}?${q}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[studioLauncher] Unexpected error', err)
|
||||
console.error("[studioLauncher] Unexpected error", err);
|
||||
}
|
||||
}
|
||||
|
||||
export default openStudioWithToken
|
||||
export default openStudioWithToken;
|
||||
|
||||
6
packages/broadcast-panel/src/vite-env.d.ts
vendored
6
packages/broadcast-panel/src/vite-env.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string }
|
||||
export default classes
|
||||
declare module "*.module.css" {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
189
scripts/restart-livekit-server.sh
Executable file
189
scripts/restart-livekit-server.sh
Executable file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env zsh
|
||||
# ...existing code...
|
||||
# scripts/restart-livekit-server.sh
|
||||
# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos.
|
||||
# Uso típico:
|
||||
# ./scripts/restart-livekit-server.sh --api-key devkey --api-secret secret --method dev --logs /tmp/livekit.log
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
API_KEY="${LIVEKIT_API_KEY:-devkey}"
|
||||
API_SECRET="${LIVEKIT_API_SECRET:-secret}"
|
||||
METHOD="auto" # auto | systemd | docker | dev
|
||||
SERVICE_NAME="livekit-server" # nombre del servicio para systemd / docker-compose
|
||||
COMPOSE_PATH="./docker-compose.yml"
|
||||
BINARY="${LIVEKIT_BINARY:-livekit-server}"
|
||||
LOGS="${LOGS_FILE:-livekit-server.log}"
|
||||
HTTP_URL="${LIVEKIT_HTTP_URL:-http://localhost:7880}"
|
||||
WS_URL="${LIVEKIT_WS_URL:-ws://localhost:7880}"
|
||||
SKIP_TESTS=false
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 [options]
|
||||
Options:
|
||||
--api-key KEY LiveKit API key (default: $API_KEY)
|
||||
--api-secret SECRET LiveKit API secret (default: $API_SECRET)
|
||||
--method METHOD Restart method: auto|systemd|docker|dev (default: auto)
|
||||
--service-name NAME Systemd / compose service name (default: $SERVICE_NAME)
|
||||
--compose-path PATH docker-compose.yml path (default: $COMPOSE_PATH)
|
||||
--binary PATH livekit-server binary (default: $BINARY)
|
||||
--logs PATH File to append logs (default: $LOGS)
|
||||
--http-url URL Base HTTP URL for health/validate checks (default: $HTTP_URL)
|
||||
--ws-url URL WebSocket URL for handshake test (default: $WS_URL)
|
||||
--skip-tests Skip curl/wscat tests
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--api-key) API_KEY="$2"; shift 2;;
|
||||
--api-secret) API_SECRET="$2"; shift 2;;
|
||||
--method) METHOD="$2"; shift 2;;
|
||||
--service-name) SERVICE_NAME="$2"; shift 2;;
|
||||
--compose-path) COMPOSE_PATH="$2"; shift 2;;
|
||||
--binary) BINARY="$2"; shift 2;;
|
||||
--logs) LOGS="$2"; shift 2;;
|
||||
--http-url) HTTP_URL="$2"; shift 2;;
|
||||
--ws-url) WS_URL="$2"; shift 2;;
|
||||
--skip-tests) SKIP_TESTS=true; shift 1;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) echo "Unknown option: $1"; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "[restart-livekit] método=$METHOD service=$SERVICE_NAME binary=$BINARY logs=$LOGS"
|
||||
|
||||
# Helpers
|
||||
has_systemd_service() {
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
sudo systemctl list-unit-files --type=service --no-legend | grep -q "$SERVICE_NAME" || return 1
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
restart_systemd() {
|
||||
echo "[restart-livekit] intentando reiniciar servicio systemd: $SERVICE_NAME"
|
||||
if ! sudo systemctl restart "$SERVICE_NAME"; then
|
||||
echo "[restart-livekit] fallo al reiniciar systemd service $SERVICE_NAME" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[restart-livekit] systemd restart pedido correctamente. Esperando 2s para estabilizar..."
|
||||
sleep 2
|
||||
return 0
|
||||
}
|
||||
|
||||
restart_docker_compose() {
|
||||
if [ -f "$COMPOSE_PATH" ]; then
|
||||
echo "[restart-livekit] intentando restart con docker-compose ($COMPOSE_PATH)"
|
||||
if command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME"
|
||||
sleep 2
|
||||
return 0
|
||||
elif command -v docker >/dev/null 2>&1 && command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose -f "$COMPOSE_PATH" restart "$SERVICE_NAME" || docker-compose -f "$COMPOSE_PATH" up -d "$SERVICE_NAME"
|
||||
sleep 2
|
||||
return 0
|
||||
else
|
||||
echo "[restart-livekit] docker-compose no encontrado" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
start_dev_process() {
|
||||
echo "[restart-livekit] arrancando '$BINARY --dev' con LIVEKIT_KEYS='${API_KEY}:${API_SECRET}'"
|
||||
# Matar procesos previos (intento seguro)
|
||||
if pgrep -f "$BINARY" >/dev/null 2>&1; then
|
||||
echo "[restart-livekit] matando procesos previos de $BINARY"
|
||||
pkill -f "$BINARY" || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOGS")" || true
|
||||
echo "[restart-livekit] lanzando en background; logs -> $LOGS"
|
||||
nohup env LIVEKIT_KEYS="${API_KEY}:${API_SECRET}" "$BINARY" --dev > "$LOGS" 2>&1 &
|
||||
sleep 2
|
||||
}
|
||||
|
||||
# Decide el método
|
||||
if [ "$METHOD" = "auto" ]; then
|
||||
if has_systemd_service; then
|
||||
CHOSEN=systemd
|
||||
elif [ -f "$COMPOSE_PATH" ]; then
|
||||
CHOSEN=docker
|
||||
else
|
||||
CHOSEN=dev
|
||||
fi
|
||||
else
|
||||
CHOSEN="$METHOD"
|
||||
fi
|
||||
|
||||
echo "[restart-livekit] método elegido: $CHOSEN"
|
||||
|
||||
case "$CHOSEN" in
|
||||
systemd)
|
||||
if ! restart_systemd; then
|
||||
echo "[restart-livekit] systemd falló, intentando método dev como fallback"
|
||||
start_dev_process
|
||||
fi
|
||||
;;
|
||||
docker)
|
||||
if ! restart_docker_compose; then
|
||||
echo "[restart-livekit] docker-compose falló, intentando método dev como fallback"
|
||||
start_dev_process
|
||||
fi
|
||||
;;
|
||||
dev)
|
||||
start_dev_process
|
||||
;;
|
||||
*)
|
||||
echo "Método desconocido: $CHOSEN"; exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Wait breve y realizar checks
|
||||
if [ "$SKIP_TESTS" = true ]; then
|
||||
echo "[restart-livekit] skip tests activado, terminado. Revisa logs: $LOGS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[restart-livekit] comprobaciones básicas: curl y wscat (si está instalado)"
|
||||
|
||||
# 1) curl a /rtc/validate (si el usuario lo desea, la URL es configurable)
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
echo "\n--- curl ${HTTP_URL}/rtc/validate (timeout 5s) ---"
|
||||
set +e
|
||||
HTTP_OUT=$(curl -sS --max-time 5 -w "HTTP_STATUS:%{http_code}" "${HTTP_URL}/rtc/validate" 2>&1)
|
||||
CURL_STATUS=$?
|
||||
set -e
|
||||
if [ $CURL_STATUS -ne 0 ]; then
|
||||
echo "[restart-livekit] curl fallo (exit $CURL_STATUS). Salida:\n$HTTP_OUT"
|
||||
else
|
||||
echo "$HTTP_OUT" | sed -n '1,200p'
|
||||
fi
|
||||
else
|
||||
echo "[restart-livekit] curl no está instalado; omitiendo check HTTP"
|
||||
fi
|
||||
|
||||
# 2) intento rápido con wscat para ver handshake (no envía token)
|
||||
if command -v wscat >/dev/null 2>&1; then
|
||||
echo "\n--- wscat -c ${WS_URL} (intento corto 4s) ---"
|
||||
# usar timeout para no colgar
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 4s wscat -c "${WS_URL}" || true
|
||||
else
|
||||
echo "(nota: no se encontró 'timeout', wscat puede esperar indefinidamente)"
|
||||
wscat -c "${WS_URL}" || true
|
||||
fi
|
||||
else
|
||||
echo "[restart-livekit] wscat no instalado; para una prueba WebSocket instálalo con 'npm i -g wscat' o utilice websocat"
|
||||
fi
|
||||
|
||||
echo "[restart-livekit] FIN. Revisa $LOGS para logs completos (tail -n 200 $LOGS)"
|
||||
# ...existing code...
|
||||
|
||||
171
scripts/validate-livekit-token.sh
Executable file
171
scripts/validate-livekit-token.sh
Executable file
@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env zsh
|
||||
# scripts/validate-livekit-token.sh
|
||||
# Reinicia/arranca livekit-server con la clave API/SECRET adecuada, redirige logs y realiza checks básicos.
|
||||
# Uso:
|
||||
# ./scripts/validate-livekit-token.sh --api-key KEY --api-secret SECRET --server https://example.com --room test_room --identity test_user
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
API_KEY="${LIVEKIT_API_KEY:-}"
|
||||
API_SECRET="${LIVEKIT_API_SECRET:-}"
|
||||
SERVER_URL="${LIVEKIT_SERVER_URL:-https://livekit-server.bfzqqk.easypanel.host}"
|
||||
ROOM="test_room"
|
||||
IDENTITY="test_user"
|
||||
|
||||
usage(){
|
||||
cat <<EOF
|
||||
Usage: $0 [options]
|
||||
Options:
|
||||
--api-key KEY LiveKit API key (or set LIVEKIT_API_KEY env)
|
||||
--api-secret SECRET LiveKit API secret (or set LIVEKIT_API_SECRET env)
|
||||
--server URL Base server URL (default: $SERVER_URL)
|
||||
--room NAME Room name (default: test_room)
|
||||
--identity ID Participant identity (default: test_user)
|
||||
-h, --help Show this help
|
||||
|
||||
This script requires node and livekit-server-sdk installed (npm i livekit-server-sdk).
|
||||
It will: generate a token, call $SERVER_URL/rtc/validate with Authorization: Bearer <token>,
|
||||
try a WebSocket handshake to wss://<host>/rtc?access_token=<token> (if wscat/websocat available),
|
||||
and print decoded JWT payload.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--api-key) API_KEY="$2"; shift 2;;
|
||||
--api-secret) API_SECRET="$2"; shift 2;;
|
||||
--server) SERVER_URL="$2"; shift 2;;
|
||||
--room) ROOM="$2"; shift 2;;
|
||||
--identity) IDENTITY="$2"; shift 2;;
|
||||
-h|--help) usage; exit 0;;
|
||||
*) echo "Unknown arg: $1"; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$API_KEY" ] || [ -z "$API_SECRET" ]; then
|
||||
echo "ERROR: API_KEY and API_SECRET must be set (via args or env)." >&2
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[validate-livekit-token] api_key=${API_KEY} server=${SERVER_URL} room=${ROOM} identity=${IDENTITY}"
|
||||
|
||||
# Temp files for outputs
|
||||
OUT_DIR="/tmp/validate-livekit"
|
||||
mkdir -p "$OUT_DIR"
|
||||
TOKEN_FILE="$OUT_DIR/token.txt"
|
||||
CURL_FILE="$OUT_DIR/curl_strict.txt"
|
||||
CURLK_FILE="$OUT_DIR/curl_insecure.txt"
|
||||
WS_FILE="$OUT_DIR/ws_attempt.txt"
|
||||
PAYLOAD_FILE="$OUT_DIR/payload.json"
|
||||
|
||||
# Generar token via Node inline
|
||||
# Pass credentials and params via environment to avoid issues with heredoc args
|
||||
TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" IDENTITY="$IDENTITY" ROOM="$ROOM" node - <<'NODE'
|
||||
try{
|
||||
const sdk = require('livekit-server-sdk');
|
||||
const AccessToken = sdk.AccessToken || (sdk.default && sdk.default.AccessToken);
|
||||
const VideoGrant = sdk.VideoGrant || (sdk.default && sdk.default.VideoGrant);
|
||||
const identity = process.env.IDENTITY || 'anon';
|
||||
const room = process.env.ROOM || 'default';
|
||||
const apiKey = process.env.API_KEY;
|
||||
const apiSecret = process.env.API_SECRET;
|
||||
if (!apiKey || !apiSecret) {
|
||||
console.error('missing API_KEY/API_SECRET in env');
|
||||
process.exit(2);
|
||||
}
|
||||
const at = new AccessToken(apiKey, apiSecret, { identity: identity, name: identity });
|
||||
if (VideoGrant) at.addGrant(new VideoGrant({ room: room }));
|
||||
else if (typeof at.addGrant === 'function') at.addGrant({ room: room, roomJoin: true, canPublish: true, canSubscribe: true });
|
||||
(async ()=>{ const t = (typeof at.toJwt === 'function') ? await at.toJwt() : at.jwt; console.log(t); })();
|
||||
}catch(e){ console.error('ERRGEN', e && e.stack ? e.stack : e); process.exit(2); }
|
||||
NODE
|
||||
) || { echo "[error] token generation failed" >&2; exit 3; }
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "[error] empty token" >&2; exit 4
|
||||
fi
|
||||
|
||||
# Save token
|
||||
echo "$TOKEN" > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
|
||||
echo; echo "=== Token generated and saved to: $TOKEN_FILE ==="
|
||||
echo "Token preview: ${TOKEN:0:140}..."
|
||||
|
||||
VALIDATE_URL="$SERVER_URL/rtc/validate"
|
||||
|
||||
echo; echo "=== CURL validate (strict TLS) -> $CURL_FILE ==="
|
||||
# store headers and body
|
||||
set +e
|
||||
curl -i -sS --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURL_FILE" 2>&1
|
||||
CURL_EXIT=$?
|
||||
set -e
|
||||
if [ $CURL_EXIT -ne 0 ]; then
|
||||
echo "[warn] curl strict failed (exit $CURL_EXIT). See $CURL_FILE"
|
||||
else
|
||||
echo "[ok] curl strict completed. See $CURL_FILE"
|
||||
fi
|
||||
|
||||
# insecure curl (diagnostic)
|
||||
echo; echo "=== CURL validate (insecure -k) -> $CURLK_FILE ==="
|
||||
set +e
|
||||
curl -i -sS -k --max-time 15 -H "Authorization: Bearer $TOKEN" "$VALIDATE_URL" > "$CURLK_FILE" 2>&1
|
||||
CURLK_EXIT=$?
|
||||
set -e
|
||||
if [ $CURLK_EXIT -ne 0 ]; then
|
||||
echo "[warn] curl -k failed (exit $CURLK_EXIT). See $CURLK_FILE"
|
||||
else
|
||||
echo "[ok] curl -k completed. See $CURLK_FILE"
|
||||
fi
|
||||
|
||||
# 2) WebSocket handshake attempt
|
||||
WS_HOST=$(echo "$SERVER_URL" | sed -E 's#https?://##' | sed -E 's#/$##')
|
||||
WS_URL="wss://$WS_HOST/rtc?access_token=$TOKEN"
|
||||
|
||||
echo; echo "=== WS handshake attempt to: $WS_URL (output -> $WS_FILE) ==="
|
||||
if command -v wscat >/dev/null 2>&1; then
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 10s wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true
|
||||
else
|
||||
wscat -c "$WS_URL" > "$WS_FILE" 2>&1 || true
|
||||
fi
|
||||
echo "[info] wscat output saved to $WS_FILE"
|
||||
elif command -v websocat >/dev/null 2>&1; then
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout 10s websocat "$WS_URL" > "$WS_FILE" 2>&1 || true
|
||||
else
|
||||
websocat "$WS_URL" > "$WS_FILE" 2>&1 || true
|
||||
fi
|
||||
echo "[info] websocat output saved to $WS_FILE"
|
||||
else
|
||||
echo "[info] wscat/websocat not installed; skipping WS handshake test"
|
||||
fi
|
||||
|
||||
# 3) decode payload (no signature verification)
|
||||
echo "$TOKEN" | awk '{print $0}' > /dev/null
|
||||
node -e 'try{const t=process.argv[1]; const p=t.split(".")[1]; const b=Buffer.from(p.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString(); console.log(b);}catch(e){console.error("failed decode",e);} ' "$TOKEN" > "$PAYLOAD_FILE" 2>&1 || true
|
||||
|
||||
echo; echo "=== DECODED PAYLOAD SAVED TO: $PAYLOAD_FILE ==="
|
||||
|
||||
# Print short summary and file locations for user to copy
|
||||
cat <<EOF
|
||||
|
||||
SUMMARY (files saved):
|
||||
Token: $TOKEN_FILE
|
||||
Curl (strict TLS): $CURL_FILE
|
||||
Curl (insecure -k): $CURLK_FILE
|
||||
WS attempt: $WS_FILE
|
||||
Decoded payload: $PAYLOAD_FILE
|
||||
|
||||
Commands to view outputs:
|
||||
cat $TOKEN_FILE
|
||||
sed -n '1,200p' $CURL_FILE
|
||||
sed -n '1,200p' $CURLK_FILE
|
||||
sed -n '1,200p' $WS_FILE
|
||||
sed -n '1,200p' $PAYLOAD_FILE
|
||||
|
||||
Paste the contents of those files here and I will interpret the results and guide next steps.
|
||||
EOF
|
||||
|
||||
echo; echo "=== DONE ==="
|
||||
34
scripts/validate-livekit/README.md
Normal file
34
scripts/validate-livekit/README.md
Normal file
@ -0,0 +1,34 @@
|
||||
Archivos generados por la validación de LiveKit
|
||||
|
||||
Contenido:
|
||||
- token.txt: token JWT completo (línea única)
|
||||
- payload.json: payload (claims) decodificado del token
|
||||
- curl_strict.txt: resultado del curl estricto a /rtc/validate (HTTP 200 + body: success)
|
||||
|
||||
Comandos listos para usar (zsh)
|
||||
|
||||
# exportar token
|
||||
export TOKEN='eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdF91c2VyIiwidmlkZW8iOnsicm9vbSI6InRlc3Rfcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWV9LCJpc3MiOiJBUElCVHFUR3hmOWh0TUsiLCJleHAiOjE3NjM3OTg3MjgsIm5iZiI6MCwic3ViIjoidGVzdF91c2VyIn0.gyu2VR1jAFlbrbJIpsAeocrUSHTWWszED7KfM3lAwZU'
|
||||
|
||||
# validar con curl (estricto TLS)
|
||||
curl -i -v -H "Authorization: Bearer $TOKEN" "https://livekit-server.bfzqqk.easypanel.host/rtc/validate"
|
||||
|
||||
# validar (ignorar verificación TLS) - solo para diagnóstico
|
||||
echo "(no recomendado en producción)"
|
||||
curl -i -v -k -H "Authorization: Bearer $TOKEN" "https://livekit-server.bfzqqk.easypanel.host/rtc/validate"
|
||||
|
||||
# probar WebSocket con wscat (instalar: npm i -g wscat)
|
||||
# timeout 6s wscat -c "wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=$TOKEN" || true
|
||||
|
||||
# ejemplo cliente JS (browser) - usar livekit-client
|
||||
# import { connect } from 'livekit-client';
|
||||
# const serverUrl = 'wss://livekit-server.bfzqqk.easypanel.host';
|
||||
# const token = '...el token completo...';
|
||||
# (async () => { const room = await connect(serverUrl, token); console.log('Conectado a room:', room.name); })();
|
||||
|
||||
Recomendaciones de seguridad:
|
||||
- No subir tokens ni secretos a repositorios públicos.
|
||||
- Generar tokens en backend y entregar tokens cortos al cliente.
|
||||
- Usar TTL corto en producción (10-30 min) y permisos mínimos por rol.
|
||||
- Guardar LIVEKIT_API_SECRET en un gestor de secretos.
|
||||
|
||||
4
scripts/validate-livekit/curl_strict.txt
Normal file
4
scripts/validate-livekit/curl_strict.txt
Normal file
@ -0,0 +1,4 @@
|
||||
HTTP/2 200
|
||||
|
||||
body: success
|
||||
|
||||
2
scripts/validate-livekit/payload.json
Normal file
2
scripts/validate-livekit/payload.json
Normal file
@ -0,0 +1,2 @@
|
||||
{ "name": "test_user", "video": { "room": "test_room", "roomJoin": true, "canPublish": true, "canSubscribe": true }, "iss": "APIBTqTGxf9htMK", "exp": 1763798728, "nbf": 0, "sub": "test_user" }
|
||||
|
||||
2
scripts/validate-livekit/token.txt
Normal file
2
scripts/validate-livekit/token.txt
Normal file
@ -0,0 +1,2 @@
|
||||
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidGVzdF91c2VyIiwidmlkZW8iOnsicm9vbSI6InRlc3Rfcm9vbSIsInJvb21Kb2luIjp0cnVlLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWV9LCJpc3MiOiJBUElCVHFUR3hmOWh0TUsiLCJleHAiOjE3NjM3OTg3MjgsIm5iZiI6MCwic3ViIjoidGVzdF91c2VyIn0.gyu2VR1jAFlbrbJIpsAeocrUSHTWWszED7KfM3lAwZU
|
||||
|
||||
52
tools/request_session.sh
Normal file
52
tools/request_session.sh
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# tools/request_session.sh
|
||||
# Uso: ./tools/request_session.sh <room> [username]
|
||||
# Hace POST a BACKEND_URL/api/session (usando env BACKEND_URL o VITE_BACKEND_TOKENS_URL)
|
||||
# e imprime JSON prettified y un snippet para inyectar token en la consola del navegador.
|
||||
|
||||
set -euo pipefail
|
||||
ROOM=${1:-d13oie2}
|
||||
USERNAME=${2:-visual-runner}
|
||||
BACKEND=${BACKEND_URL:-${VITE_BACKEND_TOKENS_URL:-https://avanzacast-servertokens.bfzqqk.easypanel.host}}
|
||||
|
||||
if [ -z "$BACKEND" ]; then
|
||||
echo "ERROR: BACKEND_URL or VITE_BACKEND_TOKENS_URL must be set in env" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "POSTing session to $BACKEND/api/session (room=$ROOM username=$USERNAME)"
|
||||
RESP=$(curl -sS -X POST "$BACKEND/api/session" -H 'Content-Type: application/json' -d "{\"room\":\"$ROOM\",\"username\":\"$USERNAME\",\"ttl\":300}") || true
|
||||
|
||||
if [ -z "$RESP" ]; then
|
||||
echo "No response from $BACKEND/api/session" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Pretty print JSON if jq available
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
echo "$RESP" | jq .
|
||||
else
|
||||
echo "$RESP"
|
||||
fi
|
||||
|
||||
# Try to extract token and studioUrl
|
||||
TOKEN=$(echo "$RESP" | grep -o '"token"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"token"\s*:\s*"([^"]*)"/\1/' || true)
|
||||
STUDIOURL=$(echo "$RESP" | grep -o '"studioUrl"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"studioUrl"\s*:\s*"([^"]*)"/\1/' || true)
|
||||
ID=$(echo "$RESP" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed -E 's/"id"\s*:\s*"([^"]*)"/\1/' || true)
|
||||
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo
|
||||
echo "--- Copy-paste this into your browser console (on the broadcast panel page) to inject the token ---"
|
||||
echo "window.postMessage({ type: 'LIVEKIT_TOKEN', token: '$TOKEN', room: '$ROOM' }, window.location.origin);"
|
||||
echo "---"
|
||||
fi
|
||||
|
||||
if [ -n "$STUDIOURL" ]; then
|
||||
echo "studioUrl: $STUDIOURL"
|
||||
fi
|
||||
if [ -n "$ID" ]; then
|
||||
echo "session id: $ID"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user