feat: add form components including Checkbox, Radio, Switch, and Textarea

This commit is contained in:
Cesar Mendivil 2025-11-16 21:47:47 -07:00
parent 91a09df7ab
commit f5d0051a19
100 changed files with 8861 additions and 725 deletions

5
.env.production.local Normal file
View File

@ -0,0 +1,5 @@
# Top-level production environment pointers for local testing (do not commit secrets)
VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host
VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=https://avanzacast-servertokens.bfzqqk.easypanel.host

59
.github/workflows/e2e-playwright.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: E2E Playwright - Studio Panel
on:
workflow_dispatch: {}
push:
paths:
- 'packages/studio-panel/**'
jobs:
playwright-e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies for studio-panel
working-directory: packages/studio-panel
run: |
npm ci
- name: Install Playwright browsers
working-directory: packages/studio-panel
run: |
npx playwright install --with-deps
- name: Run Playwright E2E script
working-directory: packages/studio-panel
env:
# override the URLs here if you want to use different targets in CI
BROADCAST_URL: ${{ secrets.BROADCAST_URL }}
STUDIO_ORIGIN: ${{ secrets.STUDIO_ORIGIN }}
run: |
node --experimental-vm-modules scripts/playwright_postmessage_test.mjs
- name: Upload Playwright debug log
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-debug-log
path: |
/tmp/playwright_debug.log
/tmp/playwright_run_output.log
/tmp/sim_postmessage_simulator.png
/tmp/sim_postmessage_studio.png
- name: Print small summary
if: always()
working-directory: packages/studio-panel
run: |
echo "Artifacts uploaded: /tmp/playwright_debug.log, /tmp/playwright_run_output.log, screenshots"
ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log || true
ls -lh /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png || true

72
DEPLOY_PROD.md Normal file
View File

@ -0,0 +1,72 @@
# AvanzaCast - Production Deployment (Docker Compose)
This file shows how to build and run the production stack locally (or on a server) with Docker Compose. It sets up:
- `backend-api` (token server) on port 4000
- `studio-panel` served by nginx on port 80 inside container
- `broadcast-panel` on port 5175
- `reverse-proxy` (nginx) mapping the three domains to containers
Prerequisites
- Docker and docker-compose installed on the host
- DNS or hosts entries mapping the following hostnames to the server IP:
- `avanzacast-broadcastpanel.bfzqqk.easypanel.host`
- `avanzacast-studio.bfzqqk.easypanel.host`
- `avanzacast-servertokens.bfzqqk.easypanel.host`
- TLS/SSL: this example doesn't include certificates. Use a separate step with Certbot / Let's Encrypt or your load balancer to provide SSL. Do NOT expose token server over plain HTTP in production without TLS.
Files created
- `docker-compose.prod.yml` - compose file to build and run the stack
- `docker/nginx/default.conf` - nginx config for reverse proxy
- `packages/backend-api/.env.production` - production environment variables for backend-api (placeholder)
Build & Run
1. Build and start the stack
```bash
# from repo root
docker compose -f docker-compose.prod.yml up --build -d
# check status
docker compose -f docker-compose.prod.yml ps
```
2. Verify backend health and CORS
```bash
# verify backend health
curl -i http://localhost:4000/health
# simulate the broadcast requesting token
curl -i -H "Origin: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" \
"http://localhost:4000/api/token?room=studio-demo&username=simulator"
```
You should see an `Access-Control-Allow-Origin` header in the response. If it returns 500 with `LiveKit credentials not configured`, add real `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` to `packages/backend-api/.env.production` and restart.
3. Verify the UIs
Open in browser (or use Playwright):
- https://avanzacast-broadcastpanel.bfzqqk.easypanel.host
- https://avanzacast-studio.bfzqqk.easypanel.host
4. Run E2E test locally (Playwright)
```bash
cd packages/studio-panel
chmod +x run_playwright_test.sh
./run_playwright_test.sh
# artifacts will be in /tmp:
ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png
```
Troubleshooting
- If CORS is blocked: edit `packages/backend-api/src/index.ts` and ensure allowed origins include your domains, then rebuild/restart.
- If the backend dies with EBADF or IO errors: run `npx tsx src/index.ts` in foreground to get full stack trace, paste here.
- For TLS termination: configure nginx with certificates or put the stack behind a TLS-enabled LB.
Security
- Do not commit real secrets to the repo. Use environment variables or a secret manager. The `.env.production` file created contains placeholders; replace with real values on the server.

View File

@ -0,0 +1,16 @@
[Unit]
Description=AvanzaCast Docker Compose Stack
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/xesar/Documentos/Nextream/AvanzaCast
ExecStart=/usr/bin/docker compose -f /home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml up -d --build
ExecStop=/usr/bin/docker compose -f /home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml down
TimeoutStartSec=600
[Install]
WantedBy=multi-user.target

91
deploy/deploy_prod.sh Normal file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE_FILE="$REPO_ROOT/docker-compose.prod.yml"
SERVICE_UNIT="/etc/systemd/system/avanzacast-stack.service"
LOCAL_UNIT_FILE="$REPO_ROOT/deploy/avanzacast-stack.service"
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--build-only Build images with docker compose and exit
--up docker compose up -d (builds first)
--down docker compose down
--install-unit Install systemd unit (requires sudo)
--enable-start Enable & start systemd unit after installation (requires sudo)
--remove-unit Stop, disable and remove systemd unit (requires sudo)
--help Show this help
Examples:
$0 --build-only
sudo $0 --install-unit --enable-start
$0 --up
EOF
}
if [ "$#" -eq 0 ]; then
usage
exit 0
fi
ensure_docker() {
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found; please install Docker on this host" >&2
exit 1
fi
}
build_images() {
echo "[deploy] Building images with docker compose..."
docker compose -f "$COMPOSE_FILE" build --pull
}
compose_up() {
echo "[deploy] Bringing up compose stack..."
docker compose -f "$COMPOSE_FILE" up -d --build
}
compose_down() {
echo "[deploy] Bringing down compose stack..."
docker compose -f "$COMPOSE_FILE" down
}
install_unit() {
if [ ! -f "$LOCAL_UNIT_FILE" ]; then
echo "unit file $LOCAL_UNIT_FILE not found" >&2
exit 1
fi
echo "[deploy] Installing systemd unit to $SERVICE_UNIT (requires sudo)"
sudo cp "$LOCAL_UNIT_FILE" "$SERVICE_UNIT"
sudo systemctl daemon-reload
}
enable_start_unit() {
echo "[deploy] Enabling and starting avanzacast-stack.service"
sudo systemctl enable --now avancacast-stack.service
}
remove_unit() {
echo "[deploy] Stopping and removing avanzacast-stack.service"
sudo systemctl stop avancacast-stack.service || true
sudo systemctl disable avancacast-stack.service || true
sudo rm -f "$SERVICE_UNIT"
sudo systemctl daemon-reload
}
while [ "$#" -gt 0 ]; do
case "$1" in
--build-only) ensure_docker; build_images; shift ;;
--up) ensure_docker; compose_up; shift ;;
--down) ensure_docker; compose_down; shift ;;
--install-unit) install_unit; shift ;;
--enable-start) enable_start_unit; shift ;;
--remove-unit) remove_unit; shift ;;
--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
esac
done

80
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,80 @@
version: '3.8'
services:
backend-api:
build:
context: ./packages/backend-api
dockerfile: Dockerfile
env_file:
- ./packages/backend-api/.env.production
environment:
- REDIS_URL=redis://redis:6379
restart: unless-stopped
networks:
- webnet
expose:
- "4000"
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:4000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
studio-panel:
build:
context: .
dockerfile: ./packages/studio-panel/Dockerfile.simple
environment:
- VITE_TOKEN_SERVER_URL=http://backend-api:4000
- VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host
- VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host
restart: unless-stopped
networks:
- webnet
expose:
- "80"
volumes:
- ./docker/letsencrypt:/etc/letsencrypt:ro
broadcast-panel:
build:
context: ./packages/broadcast-panel
dockerfile: Dockerfile
environment:
- VITE_TOKEN_SERVER_URL=http://backend-api:4000
- VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host
restart: unless-stopped
networks:
- webnet
expose:
- "5175"
volumes:
- ./docker/letsencrypt:/etc/letsencrypt:ro
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- webnet
expose:
- "6379"
reverse-proxy:
image: nginx:stable-alpine
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./docker/letsencrypt:/etc/letsencrypt:ro
ports:
- "80:80"
- "443:443"
depends_on:
- backend-api
- studio-panel
- broadcast-panel
networks:
- webnet
restart: unless-stopped
networks:
webnet:
driver: bridge

View File

@ -0,0 +1,12 @@
version: '3.8'
services:
nginx:
image: nginx:1.21-alpine
container_name: avz_local_nginx
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
network_mode: host
restart: unless-stopped
environment:
- NGINX_HOST=local

View File

@ -0,0 +1,72 @@
# nginx config to proxy production hostnames to local dev servers
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
upstream broadcast_panel {
server 127.0.0.1:5175;
}
upstream studio_panel {
server 127.0.0.1:3020;
}
upstream token_server {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host;
location / {
proxy_pass http://broadcast_panel;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name avanzacast-studio.bfzqqk.easypanel.host;
location / {
proxy_pass http://studio_panel;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name avanzacast-servertokens.bfzqqk.easypanel.host;
location / {
proxy_pass http://token_server;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# optional: default catch-all to show a basic message
server {
listen 80 default_server;
server_name _;
return 200 'AvanzaCast local proxy running.';
}
}

39
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,39 @@
server {
listen 80;
server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host;
location / {
proxy_pass http://broadcast-panel:5175;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name avanzacast-studio.bfzqqk.easypanel.host;
location / {
proxy_pass http://studio-panel:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name avanzacast-servertokens.bfzqqk.easypanel.host;
location / {
proxy_pass http://backend-api:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

BIN
docs/img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
docs/img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
docs/img_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 102 KiB

2729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,7 +29,7 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"playwright": "^1.38.0",
"playwright": "^1.51.0",
"typescript": "^5.2.2"
},
"engines": {

View File

@ -1,227 +1,19 @@
# Avanza-UI
# avanza-ui
Biblioteca de componentes React personalizados para AvanzaCast, basada en el diseño de StreamYard con estilos propios sin dependencias de frameworks CSS.
Librería de componentes reutilizables para AvanzaCast.
## Características
Componentes añadidos en esta entrega:
- ✅ **Sin dependencias de CSS frameworks** - Estilos propios y personalizados
- ✅ **Basado en StreamYard** - Diseño moderno y profesional
- ✅ **TypeScript** - Tipado completo
- ✅ **Tema oscuro** - Optimizado para reducir fatiga visual
- ✅ **Accesible** - Componentes accesibles por defecto
- ✅ **Reutilizable** - Se puede importar desde cualquier package
- `ControlButton` - botón redondo con icono y etiqueta opcional (tamaños: sm|md|lg)
- `IconButton` - botón icon-only para acciones rápidas
- `ControlGroup` - contenedor para agrupar controles
- `ControlBar` - barra de controles centrada que usa `ControlGroup`
## Instalación
Importar desde otros paquetes:
Como esta es una librería local dentro del monorepo, simplemente impórtala en tu package:
```json
{
"dependencies": {
"avanza-ui": "workspace:*"
}
}
```ts
import { ControlButton, IconButton, ControlGroup, ControlBar } from 'avanza-ui'
```
## Uso Básico
```tsx
import { Button } from 'avanza-ui';
function App() {
return (
<div className="studio-theme">
<Button variant="primary">Click me</Button>
</div>
);
}
```
**Importante:** Asegúrate de envolver tu aplicación con la clase `studio-theme` para aplicar los estilos correctamente.
## Componentes Disponibles
### Button
Botón personalizable con múltiples variantes y tamaños.
```tsx
import { Button } from 'avanza-ui';
// Variantes
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button variant="success">Success</Button>
<Button variant="ghost">Ghost</Button>
// Tamaños
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
// Con íconos
<Button leftIcon={<Icon />}>With Left Icon</Button>
<Button rightIcon={<Icon />}>With Right Icon</Button>
<Button iconOnly><Icon /></Button>
// Estados
<Button loading>Loading...</Button>
<Button disabled>Disabled</Button>
// Full width
<Button fullWidth>Full Width</Button>
```
#### Props del Button
| Prop | Tipo | Default | Descripción |
|------|------|---------|-------------|
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'success' \| 'ghost'` | `'secondary'` | Variante visual del botón |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Tamaño del botón |
| `loading` | `boolean` | `false` | Muestra spinner de carga |
| `disabled` | `boolean` | `false` | Deshabilita el botón |
| `fullWidth` | `boolean` | `false` | Ancho completo |
| `iconOnly` | `boolean` | `false` | Solo muestra ícono (sin texto) |
| `leftIcon` | `ReactNode` | - | Ícono a la izquierda |
| `rightIcon` | `ReactNode` | - | Ícono a la derecha |
## Variables CSS (Studio Theme)
Todas las variables CSS están definidas en `studio-theme.css` y pueden ser personalizadas:
### Colores
```css
--studio-bg-primary: #0f0f0f;
--studio-bg-secondary: #1a1a1a;
--studio-bg-tertiary: #242424;
--studio-accent: #3b82f6;
--studio-success: #10b981;
--studio-warning: #f59e0b;
--studio-danger: #ef4444;
```
### Espaciado
```css
--studio-space-xs: 4px;
--studio-space-sm: 8px;
--studio-space-md: 12px;
--studio-space-lg: 16px;
--studio-space-xl: 24px;
```
### Tipografía
```css
--studio-text-xs: 11px;
--studio-text-sm: 12px;
--studio-text-base: 14px;
--studio-text-md: 16px;
--studio-text-lg: 18px;
```
### Border Radius
```css
--studio-radius-sm: 4px;
--studio-radius-md: 6px;
--studio-radius-lg: 8px;
--studio-radius-xl: 12px;
```
## Personalización
Puedes sobrescribir las variables CSS en tu aplicación:
```css
:root {
--studio-accent: #your-color;
--studio-bg-primary: #your-bg;
}
```
## Próximos Componentes
- [ ] Input
- [ ] Select
- [ ] Textarea
- [ ] Checkbox
- [ ] Radio
- [ ] Switch
- [ ] Modal
- [ ] Dropdown
- [ ] Tooltip
- [ ] Card
- [ ] Badge
- [ ] Avatar
- [ ] IconButton
- [ ] Tabs
- [ ] Panel
- [ ] Layout components (StudioLayout, TopBar, BottomBar, etc.)
## Desarrollo
### Estructura del Proyecto
```
avanza-ui/
├── src/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.css
│ │ │ └── index.ts
│ │ └── ... (más componentes)
│ ├── styles/
│ │ └── studio-theme.css
│ └── index.ts
├── package.json
└── README.md
```
### Agregar un Nuevo Componente
1. Crea una carpeta en `src/components/`
2. Crea `ComponentName.tsx` con el componente React
3. Crea `ComponentName.css` con los estilos
4. Crea `index.ts` para exportar el componente
5. Actualiza `src/index.ts` para exportar desde la raíz
### Convenciones de Nomenclatura
- **Componentes**: PascalCase (ej: `Button`, `IconButton`)
- **Archivos**: PascalCase para componentes, kebab-case para estilos
- **CSS Classes**: kebab-case con prefijo `avanza-` (ej: `avanza-button`)
- **CSS Variables**: kebab-case con prefijo `--studio-` (ej: `--studio-accent`)
## Guía de Estilo
### CSS
- Usa variables CSS de `studio-theme.css` en lugar de valores hardcoded
- Sigue el patrón BEM para nombres de clases
- Agrupa propiedades relacionadas
- Usa transiciones para interacciones suaves
### TypeScript
- Exporta interfaces de props
- Usa `React.forwardRef` para componentes que necesiten refs
- Documenta props con JSDoc
- Usa tipos estrictos (evita `any`)
## Licencia
Uso interno - AvanzaCast
## Contribuidores
- Equipo AvanzaCast
---
**Versión:** 1.0.0
**Última actualización:** 2025-11-11
Los estilos se importan como efecto secundario al importar `avanza-ui` (archivo `controls.css`).

View File

@ -0,0 +1,21 @@
import React from 'react';
import { ControlGroup } from './ControlGroup';
export interface ControlBarProps {
children?: React.ReactNode;
className?: string;
}
export const ControlBar: React.FC<ControlBarProps> = ({ children, className }) => {
return (
<div style={{display:'flex',justifyContent:'center',padding:'8px'}} className={className}>
<ControlGroup className="controls-inner">
{children}
</ControlGroup>
</div>
);
};
ControlBar.displayName = 'ControlBar';
export default ControlBar;

View File

@ -0,0 +1,16 @@
.controlGroup{
display:flex;
align-items:center;
gap:12px;
padding:6px;
background: transparent;
}
.controlGroup.center{
justify-content:center;
}
.controlGroup.right{
justify-content:flex-end;
}

View File

@ -0,0 +1,21 @@
import React from 'react';
import styles from './ControlGroup.module.css';
export interface ControlGroupProps {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}
export const ControlGroup: React.FC<ControlGroupProps> = ({ children, className, style }) => {
return (
<div className={`${styles.controlGroup} ${className || ''}`} style={style} role="group">
{children}
</div>
);
};
ControlGroup.displayName = 'ControlGroup';
export default ControlGroup;

View File

@ -0,0 +1,21 @@
.iconButton{
display:inline-flex;
align-items:center;
justify-content:center;
border-radius:9999px;
background:transparent;
border:1px solid rgba(255,255,255,0.06);
color:var(--au-text-primary, #fff);
cursor:pointer;
transition: transform 120ms ease, background 120ms ease;
}
.iconButton.sm{width:40px;height:40px;font-size:18px}
.iconButton.md{width:56px;height:56px;font-size:22px}
.iconButton.lg{width:72px;height:72px;font-size:28px}
.iconButton:hover:not(:disabled){transform:translateY(-3px);background:rgba(255,255,255,0.06)}
.iconButton:active:not(:disabled){transform:translateY(0)}
.iconButton:disabled{opacity:0.5;cursor:not-allowed}
.iconButton.active{box-shadow:0 6px 20px rgba(79,70,229,0.32);background:var(--au-primary)}

View File

@ -0,0 +1,38 @@
import React from 'react';
import styles from './IconButton.module.css';
import { cn } from '../utils/helpers';
export interface IconButtonProps {
icon?: React.ReactNode;
onClick?: (e?: React.MouseEvent) => void;
active?: boolean;
disabled?: boolean;
title?: string;
size?: 'sm' | 'md' | 'lg';
id?: string;
className?: string;
style?: React.CSSProperties;
}
export const IconButton: React.FC<IconButtonProps> = ({ icon, onClick, active = false, disabled = false, title, size = 'md', id, className, style }) => {
return (
<button
id={id}
type="button"
aria-pressed={active}
aria-label={title}
title={title}
disabled={disabled}
onClick={onClick}
className={cn(styles.iconButton, styles[size], active && styles.active, className)}
style={style}
>
{icon}
</button>
);
};
IconButton.displayName = 'IconButton';
export default IconButton;

View File

@ -1,12 +1,26 @@
@import '../styles/globals.css';
/* Fallback tokens para análisis estático */
:root{
--au-gray-950: #0b1220;
--au-gray-900: #0f172a;
--au-primary: #4f46e5;
--au-primary-hover: #4338ca;
--au-radius-md: 8px;
--au-font-bold: 700;
--au-text-primary: #f1f5f9;
--au-text-secondary: #cbd5e1;
}
.studioHeader {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
background: linear-gradient(180deg, var(--au-gray-800) 0%, var(--au-gray-900) 100%);
border-bottom: 1px solid var(--au-border-dark);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
height: 64px;
padding: 0 24px;
background: linear-gradient(180deg, var(--au-gray-900, #0f172a) 0%, var(--au-gray-950, #0b1220) 100%);
border-bottom: 1px solid rgba(255,255,255,0.03);
box-shadow: 0 6px 22px rgba(2,6,23,0.45);
}
.headerLeft {
@ -16,19 +30,19 @@
}
.headerLogo {
width: 40px;
height: 40px;
border-radius: var(--au-radius-md);
width: 48px;
height: 48px;
border-radius: calc(var(--au-radius-md, 8px) + 4px);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--au-font-bold);
font-weight: var(--au-font-bold, 700);
font-size: 18px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.headerLogoGradient {
background: linear-gradient(135deg, var(--au-primary) 0%, var(--au-primary-hover) 100%);
background: linear-gradient(135deg, var(--au-primary, #4f46e5) 0%, var(--au-primary-hover, #4338ca) 100%);
color: white;
}
@ -39,14 +53,15 @@
}
.headerTitleMain {
font-weight: var(--au-font-bold);
font-weight: var(--au-font-bold, 700);
font-size: 16px;
color: var(--au-text-primary);
color: var(--au-text-primary, #f1f5f9);
}
.headerTitleSub {
font-size: 12px;
color: var(--au-text-secondary);
color: var(--au-text-secondary, #cbd5e1);
opacity: 0.9;
}
.headerRight {
@ -54,4 +69,3 @@
align-items: center;
gap: 12px;
}

View File

@ -1,23 +1,44 @@
@import '../styles/globals.css';
/* Fallback tokens para análisis estático */
:root{
--au-gray-950: #0b1220;
--au-gray-900: #0f172a;
--au-gray-800: #111827;
--au-gray-700: #1f2937;
--au-gray-600: #374151;
--au-primary: #4f46e5;
--au-primary-hover: #4338ca;
--au-warning-500: #f59e0b;
--au-success-500: #10b981;
--au-radius-lg: 12px;
--au-radius-md: 8px;
--au-radius-sm: 4px;
--au-radius-full: 9999px;
--au-transition-fast: 150ms ease;
--au-font-medium: 500;
}
.videoTile {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, var(--au-gray-700) 0%, var(--au-gray-800) 100%);
border-radius: var(--au-radius-lg);
background: linear-gradient(135deg, var(--au-gray-700, #1f2937) 0%, var(--au-gray-800, #111827) 100%);
border-radius: var(--au-radius-lg, 12px);
overflow: hidden;
border: 2px solid transparent;
transition: all var(--au-transition-fast);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all var(--au-transition-fast, 150ms ease);
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.18);
}
.videoTile:hover {
border-color: var(--au-primary);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.3);
border-color: var(--au-primary, #4f46e5);
box-shadow: 0 10px 36px rgba(79, 70, 229, 0.28);
transform: translateY(-2px);
}
.videoTile.speaking {
border-color: var(--au-warning-500);
box-shadow: 0 6px 20px rgba(234, 179, 8, 0.4);
border-color: var(--au-warning-500, #f59e0b);
box-shadow: 0 10px 36px rgba(234, 179, 8, 0.38);
}
.videoElement {
@ -26,13 +47,13 @@
width: 100%;
height: 100%;
object-fit: cover;
background-color: var(--au-gray-800);
background-color: var(--au-gray-800, #111827);
}
.videoOverlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 50%);
background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0.18) 45%, transparent 60%);
pointer-events: none;
}
@ -66,28 +87,28 @@
}
.videoName {
color: white;
color: var(--au-text-on-video, #fff);
font-size: 14px;
font-weight: var(--au-font-medium);
font-weight: var(--au-font-medium, 500);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.6);
}
.videoStatus {
color: rgba(255, 255, 255, 0.9);
color: rgba(255, 255, 255, 0.95);
font-size: 12px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
.videoControl {
width: 36px;
height: 36px;
border-radius: var(--au-radius-full);
width: 40px;
height: 40px;
border-radius: var(--au-radius-full, 9999px);
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
backdrop-filter: blur(6px);
color: white;
border: none;
cursor: pointer;
@ -95,35 +116,34 @@
align-items: center;
justify-content: center;
font-size: 18px;
transition: all var(--au-transition-fast);
transition: all var(--au-transition-fast, 150ms ease);
}
.videoControl:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
background: rgba(0, 0, 0, 0.85);
transform: scale(1.06);
}
.videoControl.muted {
background: rgba(220, 38, 38, 0.9);
background: rgba(225, 29, 72, 0.95);
}
.qualityIndicator {
display: flex;
align-items: flex-end;
gap: 2px;
gap: 4px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--au-radius-md);
backdrop-filter: blur(4px);
border-radius: var(--au-radius-md, 8px);
backdrop-filter: blur(6px);
}
.qualityBar {
width: 2px;
background: var(--au-success-500);
border-radius: var(--au-radius-sm);
width: 3px;
background: var(--au-success-500, #10b981);
border-radius: var(--au-radius-sm, 4px);
}
.qualityBar.inactive {
background: var(--au-gray-600);
background: var(--au-gray-600, #374151);
}

View File

@ -1,5 +1,6 @@
// Styles
import './styles/globals.css';
import './styles/controls.css';
// Components
export { Button } from './components/Button';
@ -69,6 +70,15 @@ export type { StudioHeaderProps } from './components/StudioHeader';
export { ControlButton } from './components/ControlButton';
export type { ControlButtonProps } from './components/ControlButton';
export { ControlGroup } from './components/ControlGroup';
export type { ControlGroupProps } from './components/ControlGroup';
export { ControlBar } from './components/ControlBar';
export type { ControlBarProps } from './components/ControlBar';
export { IconButton } from './components/IconButton';
export type { IconButtonProps } from './components/IconButton';
export { SceneCard } from './components/SceneCard';
export type { SceneCardProps } from './components/SceneCard';
@ -80,4 +90,3 @@ export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './typ
// Utils
export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers';

View File

@ -0,0 +1,108 @@
/* controls.css - estilos reutilizables para controles tipo Streamyard
Ubicación: packages/avanza-ui/src/styles/controls.css
*/
/* Container for the control bar */
.controls-inner {
display: inline-flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(0,0,0,0.65);
border-radius: 10px;
color: #fff;
}
/* Individual control wrapper */
.control-wrapper {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
/* Button base */
.btn-control {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 9999px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.06);
color: white;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease, box-shadow 120ms ease;
padding: 6px;
}
.btn-control:hover {
transform: translateY(-2px);
background: rgba(255,255,255,0.12);
}
.btn-control:active {
transform: translateY(0);
}
/* Danger style (record) */
.btn-control--danger {
background: linear-gradient(180deg, rgba(239,68,68,0.98), rgba(220,38,38,0.98));
border: 1px solid rgba(0,0,0,0.2);
}
.btn-control--danger.recording {
box-shadow: 0 6px 18px rgba(239,68,68,0.28), 0 2px 6px rgba(0,0,0,0.3);
}
/* Small red dot when recording */
.record-dot {
display: inline-block;
width: 10px;
height: 10px;
background: var(--studio-recording, #ef4444);
border-radius: 9999px;
margin-right: 8px;
box-shadow: 0 4px 10px rgba(239,68,68,0.28);
}
/* Tooltip below controls */
.tooltip {
position: absolute;
bottom: -30px;
left: 50%;
transform: translateX(-50%);
background: rgba(31,41,55,0.92);
color: #fff;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
display: none;
opacity: 0;
transition: opacity 120ms ease, transform 120ms ease;
}
.control-wrapper:hover .tooltip {
display: block;
opacity: 1;
transform: translateX(-50%) translateY(-4px);
}
/* Visual hidden utility */
.visually-hidden {
position: absolute !important;
height: 1px; width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0; padding: 0; margin: -1px;
}
/* Responsive placement for small screens */
@media (max-width: 640px) {
.controls-inner { padding: 8px 10px; gap: 8px; }
.btn-control { width: 40px; height: 40px; }
}

View File

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

View File

@ -1,3 +1,5 @@
@import './globals.css';
/**
* Studio Theme - Basado en el análisis de StreamYard
* Versión: 1.0
@ -5,133 +7,117 @@
*/
:root {
/* ===== COLORS ===== */
/* Backgrounds */
--studio-bg-primary: #0f0f0f;
--studio-bg-secondary: #1a1a1a;
--studio-bg-tertiary: #242424;
--studio-bg-elevated: #2a2a2a;
--studio-bg-hover: #333333;
/* ===== BROADCAST-PANEL COMPATIBILITY (LIGHT THEME) ===== */
/* These variables mirror names used in broadcast-panel/src/styles.css to ease reuse */
--primary-blue: #4f46e5;
--primary-blue-hover: #4338ca;
--background-color: #f7f8fa;
--surface-color: #ffffff;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--border-light: #e5e7eb;
--active-bg-light: #eef2ff;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
--shadow-lg: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--skeleton-base: #e5e7eb;
--skeleton-highlight: #f3f4f6;
/* ===== STUDIO TOKENS (light mode) ===== */
--studio-bg-primary: var(--background-color);
--studio-bg-secondary: #f2f4f8; /* subtle surface */
--studio-bg-tertiary: #eef2ff; /* accent panel */
--studio-bg-elevated: var(--surface-color);
--studio-bg-hover: #f3f5f9;
/* Borders */
--studio-border: #333333;
--studio-border-light: #404040;
--studio-border-subtle: #2a2a2a;
--studio-border: var(--border-light);
--studio-border-light: #f1f5f9;
--studio-border-subtle: #eef2f6;
/* Text */
--studio-text-primary: #ffffff;
--studio-text-secondary: #e0e0e0;
--studio-text-muted: #999999;
--studio-text-disabled: #666666;
--studio-text-primary: var(--text-primary);
--studio-text-secondary: var(--text-secondary);
--studio-text-muted: #64748b;
--studio-text-disabled: #9ca3af;
/* Accent Colors */
--studio-accent: #3b82f6;
--studio-accent-hover: #2563eb;
--studio-accent-light: rgba(59, 130, 246, 0.1);
--studio-accent: var(--primary-blue);
--studio-accent-hover: var(--primary-blue-hover);
--studio-accent-light: rgba(79, 70, 229, 0.08);
/* Status Colors */
--studio-success: #10b981;
--studio-success-hover: #059669;
--studio-warning: #f59e0b;
--studio-warning-hover: #d97706;
--studio-danger: #ef4444;
--studio-danger-hover: #dc2626;
/* Recording State */
/* Recording */
--studio-recording: #ef4444;
--studio-recording-pulse: rgba(239, 68, 68, 0.4);
--studio-recording-pulse: rgba(239, 68, 68, 0.12);
/* ===== SPACING ===== */
/* Spacing, radius, typography copied from previous tokens */
--studio-space-xs: 4px;
--studio-space-sm: 8px;
--studio-space-md: 12px;
--studio-space-lg: 16px;
--studio-space-xl: 24px;
--studio-space-2xl: 32px;
--studio-space-3xl: 48px;
/* ===== BORDER RADIUS ===== */
--studio-radius-sm: 4px;
--studio-radius-md: 6px;
--studio-radius-lg: 8px;
--studio-radius-xl: 12px;
--studio-radius-2xl: 16px;
--studio-radius-full: 9999px;
/* ===== SHADOWS ===== */
--studio-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--studio-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--studio-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--studio-shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.5);
--studio-shadow-xl: 0 20px 40px rgba(0, 0, 0, 0.6);
/* ===== TRANSITIONS ===== */
--studio-transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
--studio-transition-fast: all 100ms cubic-bezier(0.4, 0, 0.2, 1);
--studio-transition-slow: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
/* ===== TYPOGRAPHY ===== */
--studio-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
/* Font Sizes */
--studio-text-xs: 11px;
--studio-text-sm: 12px;
--studio-font-family: "Inter", -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', system-ui, sans-serif;
--studio-text-base: 14px;
--studio-text-md: 16px;
--studio-text-lg: 18px;
--studio-text-xl: 20px;
--studio-text-2xl: 24px;
--studio-text-sm: 12px;
}
/* Font Weights */
--studio-font-normal: 400;
--studio-font-medium: 500;
--studio-font-semibold: 600;
--studio-font-bold: 700;
/* Dark theme preserves previous studio variables but also map broadcast-panel dark tokens for compatibility */
[data-theme="dark"] {
/* broadcast-panel dark equivalents */
--primary-blue: #6366f1;
--primary-blue-hover: #4f46e5;
--background-color: #0f172a;
--surface-color: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--border-light: #334155;
--active-bg-light: #312e81;
--shadow-sm: 0 1px 2px 0 rgba(0,0,0,0.3);
--shadow-md: 0 1px 3px 0 rgba(0,0,0,0.4);
--shadow-lg: 0 4px 6px -1px rgba(0,0,0,0.5);
--skeleton-base: #334155;
--skeleton-highlight: #475569;
/* Line Heights */
--studio-leading-tight: 1.2;
--studio-leading-normal: 1.5;
--studio-leading-relaxed: 1.75;
/* studio theme dark overrides */
--studio-bg-primary: var(--background-color);
--studio-bg-secondary: var(--surface-color);
--studio-bg-tertiary: #111827; /* dark panel */
--studio-bg-elevated: #0b1220;
--studio-bg-hover: #0f172a;
/* ===== SIZING ===== */
/* Button Sizes */
--studio-btn-sm-height: 32px;
--studio-btn-md-height: 40px;
--studio-btn-lg-height: 48px;
--studio-border: var(--border-light);
--studio-border-light: #24303b;
--studio-border-subtle: #1f2a36;
/* Icon Sizes */
--studio-icon-xs: 14px;
--studio-icon-sm: 16px;
--studio-icon-md: 20px;
--studio-icon-lg: 24px;
--studio-icon-xl: 32px;
--studio-text-primary: var(--text-primary);
--studio-text-secondary: var(--text-secondary);
--studio-text-muted: #94a3b8;
--studio-text-disabled: #6b7280;
/* Panel Widths */
--studio-panel-left-width: 220px;
--studio-panel-right-width: 320px;
--studio-panel-collapsed-width: 60px;
/* ===== Z-INDEX ===== */
--studio-z-base: 1;
--studio-z-dropdown: 1000;
--studio-z-sticky: 1020;
--studio-z-fixed: 1030;
--studio-z-overlay: 1040;
--studio-z-modal: 1050;
--studio-z-popover: 1060;
--studio-z-tooltip: 1070;
--studio-accent: var(--primary-blue);
--studio-accent-hover: var(--primary-blue-hover);
--studio-accent-light: rgba(99, 102, 241, 0.08);
}
/* ===== GLOBAL RESETS ===== */
.studio-theme {
font-family: var(--studio-font-family);
font-size: var(--studio-text-base);
font-weight: var(--studio-font-normal);
line-height: var(--studio-leading-normal);
color: var(--studio-text-primary);
background-color: var(--studio-bg-primary);
font-family: var(--studio-font-family, var(--au-font-family, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif));
font-size: var(--studio-text-base, 14px);
font-weight: var(--studio-font-normal, 400);
line-height: var(--studio-leading-normal, 1.4);
color: var(--studio-text-primary, #111827);
background-color: var(--studio-bg-primary, #f7f8fa);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@ -151,54 +137,54 @@
}
.studio-theme ::-webkit-scrollbar-track {
background: var(--studio-bg-secondary);
background: var(--studio-bg-secondary, #f2f4f8);
}
.studio-theme ::-webkit-scrollbar-thumb {
background: var(--studio-bg-hover);
border-radius: var(--studio-radius-full);
background: var(--studio-bg-hover, #f3f5f9);
border-radius: var(--studio-radius-full, 9999px);
}
.studio-theme ::-webkit-scrollbar-thumb:hover {
background: var(--studio-border-light);
background: var(--studio-border-light, rgba(255,255,255,0.02));
}
/* ===== UTILITY CLASSES ===== */
/* Backgrounds */
.bg-primary { background-color: var(--studio-bg-primary); }
.bg-secondary { background-color: var(--studio-bg-secondary); }
.bg-tertiary { background-color: var(--studio-bg-tertiary); }
.bg-elevated { background-color: var(--studio-bg-elevated); }
.bg-hover { background-color: var(--studio-bg-hover); }
.bg-primary { background-color: var(--studio-bg-primary, #f7f8fa); }
.bg-secondary { background-color: var(--studio-bg-secondary, #f2f4f8); }
.bg-tertiary { background-color: var(--studio-bg-tertiary, #eef2ff); }
.bg-elevated { background-color: var(--studio-bg-elevated, #ffffff); }
.bg-hover { background-color: var(--studio-bg-hover, #f3f5f9); }
/* Text Colors */
.text-primary { color: var(--studio-text-primary); }
.text-secondary { color: var(--studio-text-secondary); }
.text-muted { color: var(--studio-text-muted); }
.text-disabled { color: var(--studio-text-disabled); }
.text-primary { color: var(--studio-text-primary, #1f2937); }
.text-secondary { color: var(--studio-text-secondary, #6b7280); }
.text-muted { color: var(--studio-text-muted, #64748b); }
.text-disabled { color: var(--studio-text-disabled, #9ca3af); }
/* Borders */
.border { border: 1px solid var(--studio-border); }
.border-light { border: 1px solid var(--studio-border-light); }
.border-subtle { border: 1px solid var(--studio-border-subtle); }
.border { border: 1px solid var(--studio-border, rgba(0,0,0,0.06)); }
.border-light { border: 1px solid var(--studio-border-light, rgba(0,0,0,0.03)); }
.border-subtle { border: 1px solid var(--studio-border-subtle, rgba(0,0,0,0.02)); }
/* Radius */
.rounded-sm { border-radius: var(--studio-radius-sm); }
.rounded-md { border-radius: var(--studio-radius-md); }
.rounded-lg { border-radius: var(--studio-radius-lg); }
.rounded-xl { border-radius: var(--studio-radius-xl); }
.rounded-full { border-radius: var(--studio-radius-full); }
.rounded-sm { border-radius: var(--studio-radius-sm, 4px); }
.rounded-md { border-radius: var(--studio-radius-md, 6px); }
.rounded-lg { border-radius: var(--studio-radius-lg, 8px); }
.rounded-xl { border-radius: var(--studio-radius-xl, 12px); }
.rounded-full { border-radius: var(--studio-radius-full, 9999px); }
/* Shadows */
.shadow-sm { box-shadow: var(--studio-shadow-sm); }
.shadow-md { box-shadow: var(--studio-shadow-md); }
.shadow-lg { box-shadow: var(--studio-shadow-lg); }
.shadow-sm { box-shadow: var(--studio-shadow-sm, 0 1px 2px rgba(0,0,0,0.05)); }
.shadow-md { box-shadow: var(--studio-shadow-md, 0 4px 12px rgba(0,0,0,0.08)); }
.shadow-lg { box-shadow: var(--studio-shadow-lg, 0 12px 40px rgba(2,6,23,0.12)); }
/* Transitions */
.transition { transition: var(--studio-transition); }
.transition-fast { transition: var(--studio-transition-fast); }
.transition-slow { transition: var(--studio-transition-slow); }
.transition { transition: var(--studio-transition, 200ms ease); }
.transition-fast { transition: var(--studio-transition-fast, 120ms ease); }
.transition-slow { transition: var(--studio-transition-slow, 320ms ease); }
/* ===== ANIMATIONS ===== */
@keyframes pulse-recording {
@ -256,4 +242,3 @@
.animate-slide-in-left {
animation: slide-in-left 250ms ease-out;
}

View File

@ -0,0 +1,15 @@
# Backend API production env (DO NOT commit secrets in public repos)
VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host
VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=https://avanzacast-servertokens.bfzqqk.easypanel.host
# LiveKit credentials - set real values in your production environment or CI secrets
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secretsecretsecretsecretsecretsecret
LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host
# Allow frontend origins (production)
FRONTEND_URLS=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host,https://avanzacast-studio.bfzqqk.easypanel.host
PORT=4000
NODE_ENV=production

View File

@ -0,0 +1,28 @@
# Multi-stage Dockerfile for backend-api
# Build stage: install deps and compile
FROM node:20-bullseye-slim AS builder
WORKDIR /app
# Install build dependencies
COPY package.json package-lock.json* ./
# Try npm ci (fast & reproducible). If package-lock.json is missing, fallback to npm install
RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential ca-certificates && rm -rf /var/lib/apt/lists/* \
&& (npm ci --no-audit --no-fund || npm install --no-audit --no-fund)
# Copy source and build
COPY . .
RUN npm run build
# Production stage: copy built files and production deps
FROM node:20-bullseye-slim
WORKDIR /app
# Copy node_modules from builder (includes production deps)
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4000
EXPOSE 4000
CMD ["node", "dist/index.js"]

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd "$(dirname "$0")" && pwd)
cd "$ROOT"
IMAGE_TAG="avanzacast/backend-api:local"
# Build the image
docker build -t "$IMAGE_TAG" .
# Run container mapping port 4000
CONTAINER_NAME="avz_backend_local"
# stop existing
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER_NAME" -p 4000:4000 \
-e HOST=0.0.0.0 -e PORT=4000 \
"$IMAGE_TAG"
echo "Started container $CONTAINER_NAME (image $IMAGE_TAG)"
echo 'Wait 2s then curl /health'
sleep 2
curl -sS http://localhost:4000/health || true

View File

@ -5,7 +5,7 @@
"description": "AvanzaCast - Backend API",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "npx tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",

View File

@ -2,126 +2,296 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import os from 'os';
import Redis from 'ioredis';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 4000;
const PORT = process.env.PORT ? Number(process.env.PORT) : 4000;
// Redis setup (optional)
const REDIS_URL = process.env.REDIS_URL || process.env.REDIS || 'redis://localhost:6379';
let redisClient: Redis | null = null;
let redisAvailable = false;
try {
redisClient = new Redis(REDIS_URL);
redisClient.on('error', (err: any) => console.warn('[Redis] error', err));
redisClient.on('connect', () => { redisAvailable = true; console.log('[Redis] connected') });
} catch (e) {
console.warn('Redis init failed, falling back to memory store', e);
redisClient = null;
}
// Middleware
app.use(helmet());
const allowedOrigins = process.env.FRONTEND_URLS?.split(',') || ['http://localhost:3000'];
// Always allow our local dev studio ports to avoid CORS blockers during development
if (process.env.NODE_ENV !== 'production') {
if (!allowedOrigins.includes('http://localhost:3020')) allowedOrigins.push('http://localhost:3020')
if (!allowedOrigins.includes('http://localhost:3021')) allowedOrigins.push('http://localhost:3021')
if (!allowedOrigins.includes('http://localhost:5175')) allowedOrigins.push('http://localhost:5175')
if (!allowedOrigins.includes('https://avanzacast-studio.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-studio.bfzqqk.easypanel.host')
if (!allowedOrigins.includes('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host')) allowedOrigins.push('https://avanzacast-broadcastpanel.bfzqqk.easypanel.host')
}
app.use(cors({
origin: allowedOrigins,
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
// CORS setup
function normalizeOrigin(u?: string) {
if (!u) return undefined;
try { return u.replace(/\/$/, '') } catch { return u }
}
const allowedSet = new Set<string>();
const fromEnv = process.env.FRONTEND_URLS ? process.env.FRONTEND_URLS.split(',').map(s => s.trim()).filter(Boolean) : [];
fromEnv.forEach(u => { const n = normalizeOrigin(u); if (n) allowedSet.add(n) });
// local dev origins
['http://localhost:3000','http://localhost:3020','http://localhost:3021','http://localhost:5175'].forEach(x => allowedSet.add(x));
const studioUrl = normalizeOrigin(process.env.VITE_STUDIO_URL);
const broadcastUrl = normalizeOrigin(process.env.VITE_BROADCASTPANEL_URL);
const tokenServerUrl = normalizeOrigin(process.env.VITE_TOKEN_SERVER_URL);
if (studioUrl) allowedSet.add(studioUrl);
if (broadcastUrl) allowedSet.add(broadcastUrl);
if (tokenServerUrl) allowedSet.add(tokenServerUrl);
// Automatically enable allow-subdomain check when envs point to the easypanel host
let allowSubdomainEnv = process.env.ALLOW_SUBDOMAIN_EASYPANEL === '1';
try {
const easypanelHosts = ['.bfzqqk.easypanel.host'];
const candidates = [...fromEnv, studioUrl || '', broadcastUrl || '', tokenServerUrl || ''];
const found = candidates.some(c => !!c && easypanelHosts.some(h => c.includes(h)));
if (found) {
allowSubdomainEnv = true;
// Do not overwrite explicit config, but log the auto-detection
console.log('Auto-detected easypanel host in envs — enabling ALLOW_SUBDOMAIN_EASYPANEL behavior');
}
} catch (e) {}
if (allowSubdomainEnv) process.env.ALLOW_SUBDOMAIN_EASYPANEL = '1';
console.log('CORS allowed origins:', Array.from(allowedSet));
function isOriginAllowed(originHeader?: string) {
if (!originHeader) return false;
const origin = originHeader.toString().replace(/\/$/, '');
if (process.env.ALLOW_ALL_CORS === '1') return true;
if (allowedSet.has(origin)) return true;
if (process.env.ALLOW_SUBDOMAIN_EASYPANEL === '1') {
try {
const host = new URL(origin).hostname;
if (host && host.endsWith('.bfzqqk.easypanel.host')) return true;
} catch (e) {
// ignore
}
}
return false;
}
// Handle preflight quickly
app.use((req, res, next) => {
try {
if (req.method === 'OPTIONS') {
const origin = (req.headers.origin || '').toString().replace(/\/$/, '');
if (!origin) return res.status(204).send();
if (isOriginAllowed(origin)) {
res.setHeader('Access-Control-Allow-Origin', process.env.ALLOW_ALL_CORS === '1' ? '*' : origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
return res.status(204).send();
}
return res.status(403).send('CORS origin not allowed');
}
} catch (e) { console.warn('preflight error', e) }
next();
});
// API Routes
app.get('/api/v1', (req, res) => {
res.json({
message: 'AvanzaCast Backend API',
version: '1.0.0',
endpoints: {
auth: '/api/v1/auth',
users: '/api/v1/users',
broadcasts: '/api/v1/broadcasts',
subscriptions: '/api/v1/subscriptions',
integrations: '/api/v1/integrations',
if (process.env.ALLOW_ALL_CORS === '1') {
console.warn('⚠️ ALLOW_ALL_CORS=1 is set — allowing all origins for debugging');
app.use(cors({ origin: true, credentials: true }));
} else {
app.use(cors({
origin: function(origin, callback) {
if (!origin) return callback(null, true);
if (isOriginAllowed(origin)) return callback(null, true);
console.warn('[CORS] blocked origin:', origin);
return callback(new Error('Not allowed by CORS'), false);
},
});
credentials: true,
}));
}
app.use((req, res, next) => {
try {
const origin = (req.headers.origin || '').toString();
if (origin && isOriginAllowed(origin)) {
res.setHeader('Access-Control-Allow-Origin', process.env.ALLOW_ALL_CORS === '1' ? '*' : origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
} catch (e) {}
next();
});
// LiveKit token generation endpoint
app.get('/api/token', async (req, res) => {
const { room, username } = req.query;
if (!room || typeof room !== 'string') {
return res.status(400).json({ error: 'Room name is required' });
}
if (!username || typeof username !== 'string') {
return res.status(400).json({ error: 'Username is required' });
}
// Logging middleware
app.use((req, res, next) => {
try {
const bodyPreview = req.body && Object.keys(req.body).length ? JSON.stringify(req.body).slice(0, 500) : '';
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl} from ${req.ip} ${bodyPreview}`);
} catch (e) { console.warn('logging error', e) }
next();
});
// TODO: Implement actual LiveKit token generation
// For now, return a placeholder response
app.get('/health', (_req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }));
function generateShortId(len = 7) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let s = '';
for (let i = 0; i < len; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
const SESSION_TTL = Number(process.env.SESSION_TTL_SECONDS || 300);
const sessionStoreMemory = new Map<string, { token: string, url: string, room: string, username: string, expiresAt: number }>();
async function saveSession(id: string, data: { token: string, url: string, room: string, username: string }, ttlSeconds: number) {
const payload = { ...data, expiresAt: Date.now() + ttlSeconds * 1000 };
if (redisClient && redisAvailable) {
await redisClient.setex(`session:${id}`, ttlSeconds, JSON.stringify(payload));
} else {
sessionStoreMemory.set(id, { ...payload });
setTimeout(() => { sessionStoreMemory.delete(id) }, ttlSeconds * 1000 + 1000);
}
}
async function getSession(id: string): Promise<null | { token: string, url: string, room: string, username: string, expiresAt: number }> {
if (redisClient && redisAvailable) {
const raw = await redisClient.get(`session:${id}`);
if (!raw) return null;
try { return JSON.parse(raw) } catch (e) { return null }
}
const s = sessionStoreMemory.get(id);
if (!s) return null;
if (s.expiresAt <= Date.now()) { sessionStoreMemory.delete(id); return null }
return s;
}
async function createLivekitTokenFor(room: string, username: string) {
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY;
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
console.error('⚠️ LIVEKIT_API_KEY and LIVEKIT_API_SECRET must be set in environment variables');
return res.status(500).json({ error: 'LiveKit credentials not configured' });
const fakeToken = `devtoken-${Math.random().toString(36).slice(2,10)}`;
return { token: fakeToken, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' };
}
const { AccessToken } = await import('livekit-server-sdk');
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: username, name: username });
at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true });
const token = await at.toJwt();
return { token, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' };
}
app.get('/api/token', async (req, res) => {
const { room, username } = req.query as Record<string, unknown>;
if (!room || typeof room !== 'string') return res.status(400).json({ error: 'Room name is required' });
if (!username || typeof username !== 'string') return res.status(400).json({ error: 'Username is required' });
try {
// Import AccessToken from livekit-server-sdk
const { AccessToken } = await import('livekit-server-sdk');
const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, {
identity: username,
name: username,
});
at.addGrant({
room,
roomJoin: true,
canPublish: true,
canSubscribe: true,
});
const token = await at.toJwt();
return res.json({
token,
url: process.env.LIVEKIT_URL || 'ws://localhost:7880',
});
} catch (error) {
console.error('Error generating LiveKit token:', error);
const { token, url } = await createLivekitTokenFor(room, username);
return res.json({ token, url });
} catch (err) {
console.error('Error generating token', err);
return res.status(500).json({ error: 'Failed to generate token' });
}
});
// Minimal LiveKit-related endpoints (placeholder implementation)
app.get('/api/v1/livekit/rooms', (req, res) => {
const roomName = typeof req.query.roomName === 'string' ? req.query.roomName : undefined;
app.post('/api/session', async (req, res) => {
try {
const body = req.body || {};
const room = typeof body.room === 'string' ? body.room : undefined;
const username = typeof body.username === 'string' ? body.username : undefined;
const ttl = body.ttl ? Number(body.ttl) : undefined;
if (!room) return res.status(400).json({ error: 'room is required' });
if (!username) return res.status(400).json({ error: 'username is required' });
// If no roomName provided, return a list of rooms (empty list for now)
if (!roomName) {
return res.json({ rooms: [] });
const { token, url } = await createLivekitTokenFor(room, username);
let id = generateShortId(7);
let attempt = 0;
while (attempt < 6) {
const exists = await getSession(id);
if (!exists) break;
id = generateShortId(7);
attempt++;
}
const ttlSec = ttl || SESSION_TTL;
await saveSession(id, { token, url, room, username }, ttlSec);
const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
// Optionally include token directly in redirectUrl when env var is set (convenience for direct entry)
const includeToken = process.env.INCLUDE_TOKEN_IN_REDIRECT === '1' || process.env.INCLUDE_TOKEN_IN_REDIRECT === 'true';
const redirectUrl = includeToken
? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`
: `${studioBase}/${id}`;
return res.json({
id,
studioUrl: `${studioBase}/${id}`,
redirectUrl,
ttlSeconds: ttlSec,
});
} catch (err) {
console.error('Failed to create session', err);
return res.status(500).json({ error: String(err) });
}
// Placeholder: return empty participants list for the requested room
return res.json({ room: { name: roomName, participants: [] } });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
app.get('/api/session/:id', async (req, res) => {
const id = req.params.id;
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, url: s.url, room: s.room, username: s.username, ttlSeconds: ttlLeft });
});
// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
app.get('/s/:id', async (req, res) => {
const id = req.params.id;
const s = await getSession(id);
if (!s) return res.status(404).send('Not found');
const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
const redirectTo = `${studioBase}/studio_receiver.html?token=${encodeURIComponent(s.token)}&room=${encodeURIComponent(s.room)}&username=${encodeURIComponent(s.username)}`;
return res.redirect(302, redirectTo);
});
// Optional: mark session as consumed to prevent replay (single-use)
app.post('/api/session/:id/consume', async (req, res) => {
const id = req.params.id;
try {
const s = await getSession(id);
if (!s) return res.status(404).json({ error: 'not found' });
// remove from store
if (redisClient && redisAvailable) {
await redisClient.del(`session:${id}`);
} else {
sessionStoreMemory.delete(id);
}
return res.json({ ok: true });
} catch (err) {
console.error('Error consuming session', err);
return res.status(500).json({ error: 'failed' });
}
});
app.use((_req, res) => res.status(404).json({ error: 'Not found' }));
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`🚀 Backend API running on http://localhost:${PORT}`);
const HOST: string = process.env.HOST ? String(process.env.HOST) : '0.0.0.0';
app.listen(Number(PORT), HOST, () => {
console.log(`🚀 Backend API running on http://${HOST}:${PORT}`);
console.log(`📡 Environment: ${process.env.NODE_ENV}`);
try {
const nets = os.networkInterfaces();
const addresses: string[] = [];
Object.keys(nets).forEach((name) => {
const net = nets[name] || [];
net.forEach((iface: any) => {
if (iface.family === 'IPv4' && !iface.internal) addresses.push(iface.address as string);
});
});
if (addresses.length > 0) addresses.forEach(a => console.log(`🔗 Accessible at: http://${a}:${PORT}`));
} catch (e) { console.warn('Could not enumerate network interfaces', e) }
});

View File

@ -1,30 +1,21 @@
# Dockerfile para Broadcast Panel
# Dockerfile for Broadcast Panel (package-local context)
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar package files
# copy package files
COPY package*.json ./
# Instalar dependencias
RUN npm install
# install deps
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund
# Copiar código fuente
COPY . .
# Build
# copy source and build
COPY . ./
RUN npm run build
# Etapa de producción con nginx
# production image
FROM nginx:alpine
# Copiar build a nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponer puerto
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -144,3 +144,28 @@ Este panel sigue el patrón de diseño de **StreamYard**, caracterizado por:
3. **Consistencia:** Uso uniforme de colores, tipografía y espaciado
4. **Accesibilidad:** Contraste adecuado, tamaños de fuente legibles
5. **Responsividad:** Adaptación fluida a diferentes dispositivos
## Entrar al estudio (token flow)
El botón "Entrar al estudio" ahora solicita un token al servidor de tokens (configurable mediante la variable de entorno `VITE_TOKEN_SERVER_URL`) y abre el `studio-panel` enviando el token vía `postMessage` (o redirige como fallback si el popup está bloqueado).
Variables importantes:
- VITE_TOKEN_SERVER_URL - URL base del servidor que responde en `/api/token?room=...&username=...` y devuelve JSON con `{ token, url }`.
- VITE_STUDIO_URL - URL base del `studio-panel` (ej. `https://avanzacast-studio.bfzqqk.easypanel.host`) que se usa como origin para postMessage.
Cómo probar localmente:
1. Levanta `backend-api` con tus credenciales LiveKit (LIVEKIT_API_KEY y LIVEKIT_API_SECRET en .env):
```bash
cd packages/backend-api
npm run dev
```
2. Arranca `broadcast-panel` (por defecto corre en http://localhost:5175):
```bash
cd packages/broadcast-panel
npm run dev
```
3. Abre la lista de transmisiones y pulsa "Entrar al estudio" en una fila; el panel abrirá el `studio-panel` en una ventana popup y enviará el token por postMessage.
Notas de debug:
- Si no recibes confirmación (ACK) del `studio-panel`, revisa la consola del navegador para ver posibles errores de `postMessage` (origin mismatch) o bloqueos de popup.
- Para pruebas locales puedes usar `packages/studio-panel/public/simulate_postmessage.html` (simulador) para verificar el flujo sin LiveKit.

View File

@ -0,0 +1,15 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Dump Session</title></head>
<body>
<h3>Session Storage Dump</h3>
<pre id="out">loading...</pre>
<script>
try{
const user = sessionStorage.getItem('avanzacast_user') || '(no session user)';
document.getElementById('out').textContent = user;
}catch(e){ document.getElementById('out').textContent = 'error reading sessionStorage: '+String(e) }
</script>
</body>
</html>

View File

@ -0,0 +1,133 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Post Token to Studio (production)</title></head>
<body>
<h3>Post Token to Studio (production)</h3>
<div>
<label>Backend API URL: <input id="backendUrl" value="https://avanzacast-servertokens.bfzqqk.easypanel.host" style="width:400px" /></label>
</div>
<div>
<label>Room: <input id="room" value="studio-demo" /></label>
<label>Studio URL: <input id="studio" value="https://avanzacast-studio.bfzqqk.easypanel.host" style="width:400px" /></label>
</div>
<div>
<button id="run">Open Studio & Send Token</button>
</div>
<pre id="log"></pre>
<script>
function log(msg){ try{ const p = document.getElementById('log'); p.textContent += msg + '\n'; console.log(msg); }catch(e){} }
function setHash(msg){ try{ window.location.hash = encodeURIComponent(msg.slice(0,800)); }catch(e){} }
function shortId(len = 8){ return Math.random().toString(36).slice(2, 2 + len) }
// Listen for ACK from studio (real flow)
let lastPopup = null;
window.addEventListener('message', (e) => {
try{
const data = e.data || {};
if (data?.type === 'LIVEKIT_ACK'){
log('[ACK] from ' + (e.origin || 'unknown') + ' status=' + JSON.stringify(data.status || data));
// Optionally close popup if ack received
try{ if (lastPopup && !lastPopup.closed) { lastPopup.close(); log('[ACK] closed popup'); } }catch(err){}
}
}catch(err){ console.warn('message handler error', err) }
}, false);
// helper: handshake with popup - send PING until READY or timeout
function handshakeAndSendToken(win, targetOrigin, payload, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
if (!win || win.closed) return reject(new Error('popup not available'))
let settled = false
const start = Date.now()
function cleanup() {
window.removeEventListener('message', onMessage)
clearInterval(pinger)
}
function onMessage(e) {
try {
if (!e?.data) return
const d = e.data
// ensure origin matches expected origin
if (typeof targetOrigin === 'string' && targetOrigin !== '*' && e.origin !== targetOrigin) return
if (d?.type === 'LIVEKIT_READY') {
if (settled) return
settled = true
try {
win.postMessage(Object.assign({ type: 'LIVEKIT_TOKEN' }, payload), targetOrigin)
log('[HANDSHAKE] LIVEKIT_READY received - token posted')
cleanup()
return resolve({ status: 'sent' })
} catch (err) {
cleanup()
return reject(err)
}
}
} catch (err) { console.debug('handshake message error', err) }
}
// ping interval
const pinger = setInterval(()=>{
try{
if (!win || win.closed) throw new Error('popup closed')
win.postMessage({ type: 'LIVEKIT_PING' }, targetOrigin)
}catch(err){ /* ignore - listener will catch closed */ }
}, 400)
window.addEventListener('message', onMessage)
// overall timeout -> fallback to redirect
const timer = setTimeout(()=>{
if (settled) return
settled = true
cleanup()
return reject(new Error('handshake timeout'))
}, timeoutMs)
})
}
async function runOnce(){
try{
// Read backendUrl from URL params first, then fallback to input field
const urlParams = new URLSearchParams(window.location.search);
const backendUrlParam = urlParams.get('backendUrl');
const backendUrl = (backendUrlParam || document.getElementById('backendUrl').value).replace(/\/$/, '')
const room = document.getElementById('room').value
const studio = document.getElementById('studio').value.replace(/\/$/, '')
const user = sessionStorage.getItem('avanzacast_user') || 'guest-' + shortId(4)
log('user from sessionStorage: ' + user)
log('creating session via backend API: ' + backendUrl + '/api/session')
const res = await fetch(`${backendUrl}/api/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, username: user, ttl: 300 })
})
if (!res.ok) { log('backend API error '+res.status); setHash('backend API error '+res.status); return }
const data = await res.json()
log('session created: id=' + data.id + ', studioUrl=' + data.studioUrl + ', redirectUrl=' + data.redirectUrl)
// Use the redirectUrl provided by backend (should point to production domain with token)
const redirectUrl = data.redirectUrl || data.studioUrl
if (!redirectUrl) { log('no redirectUrl or studioUrl in response'); return }
log('redirecting to: ' + redirectUrl)
setHash('redirecting to production')
window.location.href = redirectUrl
}catch(e){ log('error: ' + e); setHash('error: '+String(e)) }
}
document.getElementById('run').addEventListener('click', runOnce)
// Auto-run if ?auto=1 in URL (useful for headless testing)
try{
const params = new URLSearchParams(window.location.search)
if (params.get('auto') === '1') {
setTimeout(() => { runOnce() }, 300)
}
}catch(e){}
</script>
</body>
</html>

View File

@ -0,0 +1,20 @@
import React from 'react'
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'
return (
<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

View File

@ -7,6 +7,7 @@ import styles from './TransmissionsTable.module.css'
import InviteGuestsModal from './InviteGuestsModal'
import { NewTransmissionModal } from '@shared/components'
import type { Transmission } from '@shared/types'
import useStudioLauncher from '../hooks/useStudioLauncher'
interface Props {
transmissions: Transmission[]
@ -20,15 +21,17 @@ const platformIcons: Record<string, React.ReactNode> = {
'Facebook': <FaFacebook size={16} color="#1877F2" />,
'Twitch': <FaTwitch size={16} color="#9146FF" />,
'LinkedIn': <FaLinkedin size={16} color="#0A66C2" />,
'Genérico': <MdVideocam size={16} color="#5f6368" />, // Logo genérico para transmisiones sin destino
'Generico': <MdVideocam size={16} color="#5f6368" />, // Logo genérico para transmisiones sin destino
}
const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate, isLoading }) => {
const TransmissionsTable: React.FC<Props> = (props) => {
const { transmissions, onDelete, onUpdate, isLoading } = props
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming')
const [inviteOpen, setInviteOpen] = useState(false)
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined)
const [editOpen, setEditOpen] = useState(false)
const [editTransmission, setEditTransmission] = useState<Transmission | undefined>(undefined)
const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher()
const [loadingId, setLoadingId] = useState<string | null>(null)
const handleEdit = (t: Transmission) => {
@ -37,7 +40,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
}
// Filtrado por fechas
const filtered = transmissions.filter(t => {
const filtered = transmissions.filter((t: Transmission) => {
// Si es "Próximamente" o no tiene fecha programada, siempre va a "upcoming"
if (!t.scheduled || t.scheduled === 'Próximamente') return activeTab === 'upcoming'
@ -52,44 +55,20 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
})
const openStudioForTransmission = async (t: Transmission) => {
if (loadingId) return
if (loadingId || launcherLoadingId) return
setLoadingId(t.id)
try {
const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User'
const user = encodeURIComponent(userRaw)
const room = encodeURIComponent(t.id || 'avanzacast-studio')
console.log('[BroadcastPanel] Solicitando token:', { room: decodeURIComponent(room), user: decodeURIComponent(user) })
const TOKEN_SERVER = import.meta.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'
const tokenUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/token?room=${room}&username=${user}`
const tokenRes = await fetch(tokenUrl)
if (!tokenRes.ok) throw new Error('No se pudo obtener token')
const tokenData = await tokenRes.json()
console.log('[BroadcastPanel] Token recibido:', {
tokenLength: tokenData.token?.length || 0,
serverUrl: tokenData.serverUrl,
hasToken: !!tokenData.token,
hasServerUrl: !!tokenData.serverUrl
})
// Pasar solo token, room y user por URL (serverUrl se lee del .env en studio-panel)
const params = new URLSearchParams({
token: tokenData.token,
room: decodeURIComponent(room),
user: decodeURIComponent(user)
})
console.log('[BroadcastPanel] Redirigiendo con parámetros en URL...')
// Redirigir a studio-panel en la misma pestaña con los datos en la URL
const STUDIO_URL = import.meta.env.VITE_STUDIO_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host'
const shortId = Math.random().toString(36).slice(2, 10)
window.location.href = `${STUDIO_URL.replace(/\/$/, '')}/${shortId}?${params.toString()}`
} catch (err) {
const user = (userRaw)
const room = (t.id || 'avanzacast-studio')
const result = await openStudio({ room, username: user })
if (!result) {
throw new Error('No se pudo abrir el estudio')
}
setLoadingId(null)
} catch (err: any) {
console.error('[BroadcastPanel] Error entrando al estudio:', err)
alert('No fue posible entrar al estudio. Revisa el servidor de tokens.')
alert(err?.message || 'No fue posible entrar al estudio. Revisa el servidor de tokens.')
setLoadingId(null)
}
}
@ -148,7 +127,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
</tr>
</thead>
<tbody>
{filtered.map(t => (
{filtered.map((t: Transmission) => (
<tr key={t.id} className={styles.tableRow}>
<td className={styles.tableCell}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@ -158,7 +137,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
<div>
<div className={styles.transmissionTitle}>{t.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
{t.platform === 'Genérico' ? 'Solo grabación' : (t.platform || 'YouTube')}
{t.platform === 'Generico' ? 'Solo grabación' : (t.platform || 'YouTube')}
</div>
</div>
</div>
@ -178,20 +157,23 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
<button
aria-label={`Entrar al estudio ${t.title}`}
className={styles.enterStudioButton}
disabled={loadingId !== null}
disabled={loadingId !== null || launcherLoadingId !== null}
onClick={() => openStudioForTransmission(t)}
>
{loadingId === t.id ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 50 50" style={{ animation: 'spin 1s linear infinite' }}>
<circle cx="25" cy="25" r="20" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4" />
</svg>
Entrando...
</span>
{ (loadingId === t.id || launcherLoadingId === t.id) ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 50 50" style={{ animation: 'spin 1s linear infinite' }}>
<circle cx="25" cy="25" r="20" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4" />
</svg>
Entrando...
</span>
) : (
'Entrar al estudio'
'Entrar al estudio'
)}
</button>
</button>
{launcherError && (
<div style={{ color: 'var(--studio-error-text)', fontSize: 12 }}>{launcherError}</div>
)}
<Dropdown
trigger={<button className={styles.moreOptionsButton} aria-label={`Más opciones ${t.title}`}><MdMoreVert size={20} /></button>}

View File

@ -0,0 +1,178 @@
import { useState } from 'react'
export type OpenStudioOptions = {
room: string
username: string
ttl?: number
}
type SessionData = {
studioUrl?: string
redirectUrl?: string
token?: string
room?: string
ttl?: number
}
export default function useStudioLauncher() {
const [loadingId, setLoadingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
async function openStudio(opts: OpenStudioOptions) {
const { room, username, ttl } = opts
if (!room || !username) {
setError('room and username are required')
return null
}
setError(null)
setLoadingId(room)
// Timeouts and retry config
const POST_MESSAGE_TIMEOUT = 5000 // ms
const POST_MESSAGE_INTERVAL = 300 // ms
try {
const TOKEN_SERVER = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'
const sessionUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session`
// Try to open a blank popup immediately (in direct response to user action) to reduce popup-blocker issues
let popup: Window | null = null
try {
popup = window.open('about:blank', '_blank', 'noopener,noreferrer')
} catch (e) {
popup = null
}
// If popup failed to open, we will fallback to redirect later
const sessionRes = await fetch(sessionUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room, username, ttl })
})
if (!sessionRes.ok) {
const txt = await sessionRes.text().catch(() => '')
const msg = `No se pudo crear la sesión (${sessionRes.status}) ${txt}`
console.error('[useStudioLauncher]', msg)
setError(msg)
setLoadingId(null)
// Close popup if we opened it but will not navigate it
try { popup?.close() } catch (e) { /* ignore */ }
return null
}
const sessionData: SessionData = await sessionRes.json()
const studioUrl = sessionData.studioUrl || sessionData.redirectUrl || null
if (!studioUrl) {
const msg = 'No studio URL returned from token server'
console.error('[useStudioLauncher]', msg)
setError(msg)
setLoadingId(null)
try { popup?.close() } catch (e) { /* ignore */ }
return null
}
const targetUrl = sessionData.studioUrl || sessionData.redirectUrl || studioUrl
// If popup couldn't be opened, fallback to redirecting current window to redirectUrl (may contain token)
if (!popup) {
try {
const fallback = sessionData.redirectUrl || targetUrl
window.location.href = fallback
setLoadingId(null)
return sessionData
} catch (e) {
// can't redirect, return error
const msg = 'No se pudo abrir popup ni redirigir' + (String(e) || '')
console.error('[useStudioLauncher]', msg)
setError(msg)
setLoadingId(null)
return null
}
}
// We have a popup window. Navigate it to the studio (without token in URL if possible)
try {
popup.location.href = targetUrl
} catch (e) {
// Some browsers may block setting location for cross-origin until navigation happens
try { popup.location.assign(targetUrl) } catch (e2) { /* ignore */ }
}
// Prepare message to send the token
const msgPayload = { type: 'LIVEKIT_TOKEN', token: sessionData.token, room: sessionData.room }
const targetOrigin = (() => {
try { return new URL(targetUrl).origin } catch (e) { return '*' }
})()
let posted = false
let ackReceived = false
// Listen for ACK from the studio window
function onMessage(e: MessageEvent) {
try {
const d = e.data || {}
if (d?.type === 'LIVEKIT_ACK' && d?.room === sessionData.room) {
ackReceived = true
// optional: we can close the popup opener listener
window.removeEventListener('message', onMessage)
}
} catch (err) {
// ignore malformed messages
}
}
window.addEventListener('message', onMessage)
const start = Date.now()
// Try posting repeatedly until timeout or ACK
while (!posted && Date.now() - start < POST_MESSAGE_TIMEOUT && !ackReceived) {
try {
// postMessage itself doesn't throw for cross-origin; we still wrap it
popup.postMessage(msgPayload, targetOrigin)
posted = true // assume success; ack will confirm
} catch (e) {
// ignore and retry
}
if (!posted) await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL))
}
// If we posted but didn't receive ACK, try a short wait for ack
const waitForAck = () => new Promise<void>((resolve) => {
const maxWait = 2000
const t0 = Date.now()
const int = setInterval(() => {
if (ackReceived || Date.now() - t0 > maxWait) {
clearInterval(int)
resolve()
}
}, 100)
})
if (posted) {
await waitForAck()
}
// If we couldn't post at all or no ACK received, fallback to redirect to redirectUrl (may include token)
if (!posted || (!ackReceived && sessionData.redirectUrl)) {
try {
// navigate popup to redirectUrl which typically contains token
const fallback = sessionData.redirectUrl || targetUrl
popup.location.href = fallback
} catch (e) {
// If navigation fails, try to navigate the current window
try { window.location.href = sessionData.redirectUrl || targetUrl } catch (e2) { /* ignore */ }
}
}
setLoadingId(null)
return sessionData
} catch (err: any) {
console.error('[useStudioLauncher] error opening studio', err)
setError(String(err?.message || err))
setLoadingId(null)
return null
}
}
return { openStudio, loadingId, error }
}

View File

@ -0,0 +1,44 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import Toast from '../components/Toast'
type ToastItem = { id: string, message: string, variant?: 'info'|'success'|'error'|'warning' }
const ToastContext = createContext<{ show: (m: string, v?: ToastItem['variant']) => void } | undefined>(undefined)
export function ToastProvider({ children }: { children: React.ReactNode }) {
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)
}
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)
} catch (err) {}
}
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>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within ToastProvider')
return ctx
}

View File

@ -2,6 +2,11 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
import PageContainer from './components/PageContainer'
import './styles.css'
import { ToastProvider } from './hooks/useToast'
const root = createRoot(document.getElementById('root')!)
root.render(<PageContainer />)
root.render(
<ToastProvider>
<PageContainer />
</ToastProvider>
)

View File

@ -0,0 +1,100 @@
export interface StudioTokenPayload {
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}`
const payload = {
type: 'LIVEKIT_TOKEN',
token: tokenData.token,
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 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 originAllowed = (() => {
try { return new URL(STUDIO_URL).origin } catch { return '*' }
})()
try {
const win = window.open(studioPath, '_blank')
if (win) {
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 (ackTimeout) { clearTimeout(ackTimeout); ackTimeout = null }
window.removeEventListener('message', ackListener as unknown as EventListener)
}
} catch (err) { console.error('[studioLauncher] ackListener error', err) }
}
window.addEventListener('message', ackListener as unknown as EventListener)
ackTimeout = window.setTimeout(() => {
try {
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' } }))
}
} catch (err) { console.error('[studioLauncher] ack timeout cleanup error', err) }
}, 20000)
const post = () => {
try {
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) }
}
}
setTimeout(post, 300)
setTimeout(post, 800)
setTimeout(post, 1500)
return
}
// popup blocked -> fallback to redirect
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}`
}
} catch (err) {
console.error('[studioLauncher] Unexpected error', err)
}
}
export default openStudioWithToken

View File

@ -1,6 +1,18 @@
module.exports = {
plugins: {
'tailwindcss': {},
'autoprefixer': {},
},
plugins: (() => {
// Tailwind v4 changed the PostCSS plugin distribution to @tailwindcss/postcss
// Try to require it first, otherwise fall back to the older 'tailwindcss' package name
let tailwindPlugin
try {
tailwindPlugin = require('@tailwindcss/postcss')
} catch (e) {
// fallback for environments still using the legacy package name
tailwindPlugin = require('tailwindcss')
}
return {
[tailwindPlugin.name || 'tailwindcss']: tailwindPlugin(),
autoprefixer: {},
}
})(),
}

View File

@ -0,0 +1,9 @@
node_modules
dist
npm-debug.log
Dockerfile
.dockerignore
.vscode
.git
.gitignore

View File

@ -0,0 +1,28 @@
# Multi-stage Dockerfile: build with Node, serve with Nginx
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install build deps
# Copy package files and workspace packages to allow local file:../avanza-ui resolution
COPY package.json package-lock.json* ./
# Copy entire monorepo so file: references work (keeps things simple)
COPY .. /app
WORKDIR /app/packages/studio-panel
RUN npm ci --no-audit --no-fund && npm run build
# Production stage
FROM nginx:stable-alpine
# Remove default nginx static
RUN rm -rf /usr/share/nginx/html/*
# Copy built static files
COPY --from=builder /app/packages/studio-panel/dist /usr/share/nginx/html
# Copy custom nginx config if provided (optional)
# If deploy/nginx.avanzacast.conf exists, use it as default.conf
COPY packages/studio-panel/deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,70 @@
# Docker build & deploy for studio-panel
This file explains how to build the `studio-panel` Docker image locally and produce a deployable artifact.
Prerequisites
- Docker installed and running on the host.
- Node.js and npm (for local build path if you prefer building before docker). If you use Docker multi-stage, Node is not needed locally.
Quick build steps (preferred: multi-stage Docker build)
From repo root or inside `packages/studio-panel`:
```bash
# from repo root
cd packages/studio-panel
# build docker (multi-stage uses npm inside builder stage)
docker build -t avanzacast/studio-panel:latest .
# run locally to test
docker run --rm -it -p 3020:80 avanzacast/studio-panel:latest
# now test with curl
curl -I http://localhost:3020/
```
Notes
- The Dockerfile is multi-stage: the builder stage runs `npm ci` and `npm run build` inside the container. The built `dist/` is copied to nginx in the final image.
- The Dockerfile copies the entire monorepo into the builder context to allow `file:../avanza-ui` dependency to be resolved during container build.
If the build fails inside Docker due to strict network or registry errors, you can build locally first and then use a simpler Dockerfile that only copies the `dist/` folder into the nginx image:
Local-build alternative:
```bash
# build locally
cd packages/studio-panel
npm ci
npm run build
# create a simple nginx image
cd packages/studio-panel
cat > Dockerfile.simple <<'EOF'
FROM nginx:stable-alpine
COPY dist /usr/share/nginx/html
COPY deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
docker build -f Dockerfile.simple -t avanzacast/studio-panel:local .
```
If you want the tarball artifact to deploy on the server, run:
```bash
# after successful build and image creation
IMAGE=avanzacast/studio-panel:latest
docker save $IMAGE -o /tmp/studio-panel-image-$(date +%s).tar
# scp that tar to the server and on server run:
# docker load -i studio-panel-image-<ts>.tar
# docker run -d --name studio-panel -p 80:80 avanzacast/studio-panel:latest
```
Troubleshooting
- If the multi-stage build cannot resolve `file:../avanza-ui`, ensure the entire repository is in the build context (we copy `..` into the builder in the current Dockerfile). If your Docker setup restricts context size, prefer local build + `Dockerfile.simple`.
- Inspect build logs with:
- `docker build --progress=plain -t avanzacast/studio-panel:local .`
- `docker logs <container>` for runtime errors.

View File

@ -0,0 +1,34 @@
# E2E Playwright tests - Studio Panel
This guide explains how to run the Playwright E2E tests locally and in CI. The tests simulate Broadcast -> Token Server -> Studio flows and produce logs and screenshots for debugging.
Local quick run
```bash
cd packages/studio-panel
# optional: install playwright locally
npm install --no-audit --no-fund --no-save playwright
npx playwright install --with-deps
# run the helper script (installs playwright if missing and runs the test)
chmod +x run_playwright_test.sh
./run_playwright_test.sh
# After run, check artifacts:
ls -lh /tmp/playwright_debug.log /tmp/playwright_run_output.log
ls -lh /tmp/sim_postmessage_simulator.png /tmp/sim_postmessage_studio.png
```
CI (GitHub Actions)
A workflow has been added at `.github/workflows/e2e-playwright.yml`. It can be triggered from the Actions tab or via `workflow_dispatch`.
Set these repository secrets to override target URLs (optional):
- `BROADCAST_URL` - e.g. `https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1`
- `STUDIO_ORIGIN` - e.g. `https://avanzacast-studio.bfzqqk.easypanel.host`
The workflow will upload logs and screenshots as artifacts for download.
Troubleshooting
- If Playwright fails to install browsers on runners, try `npx playwright install --with-deps` locally to debug.
- If tests time out, increase timeouts in `scripts/playwright_postmessage_test.mjs`.

View File

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$ROOT_DIR"
echo "[1/5] Installing dependencies"
npm ci
echo "[2/5] Running Vite build"
npm run build
echo "[3/5] Creating Dockerfile.simple and building image"
cat > Dockerfile.simple <<'EOF'
FROM nginx:stable-alpine
COPY dist /usr/share/nginx/html
COPY deploy/nginx.avanzacast.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
IMAGE_TAG="avanzacast/studio-panel:local"
docker build -f Dockerfile.simple -t "$IMAGE_TAG" .
TS=$(date +%s)
OUT=/tmp/studio-panel-image-${TS}.tar
echo "[4/5] Saving docker image to $OUT"
docker save "$IMAGE_TAG" -o "$OUT"
echo "[5/5] Done. Image saved to: $OUT"
ls -lh "$OUT"
# keep the artifact path for caller
echo "$OUT"

View File

@ -0,0 +1,53 @@
# Dev nginx config for avanzacast-studio (non-SSL)
server {
listen 80;
server_name avanzacast-studio.bfzqqk.easypanel.host;
# Proxy to Vite dev server (HTTP)
location / {
proxy_pass http://127.0.0.1:3020;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_set_header X-Forwarded-Host $host;
}
# Optional: serve static built files if you run `npm run build` and serve from dist
location /static/ {
alias /home/xesar/Documentos/Nextream/AvanzaCast/packages/studio-panel/dist/;
}
# Increase timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Also keep a plain HTTP server for broadcast and token-server (reverse-proxy uses root default.conf too)
server {
listen 80;
server_name avanzacast-broadcastpanel.bfzqqk.easypanel.host;
location / {
proxy_pass http://broadcast-panel:5175;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name avanzacast-servertokens.bfzqqk.easypanel.host;
location / {
proxy_pass http://backend-api:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@ -0,0 +1,16 @@
[Unit]
Description=AvanzaCast Studio Panel (Vite dev)
After=network.target
[Service]
Type=simple
User=youruser
WorkingDirectory=/home/xesar/Documentos/Nextream/AvanzaCast/packages/studio-panel
Environment=NODE_ENV=development
ExecStart=/usr/bin/npm run dev
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,6 @@
// minimal Playwright config for local E2E
module.exports = {
use: { headless: true },
timeout: 30_000,
};

View File

@ -0,0 +1,81 @@
#!/usr/bin/env node
// Playwright E2E test for Studio Panel (automates Broadcast -> Studio flow)
import fetch from 'node-fetch';
import fs from 'fs';
import { chromium } from 'playwright';
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000';
const STUDIO_BASE = process.env.STUDIO_URL || 'http://localhost:3020';
const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000);
function log(...args) { console.log(new Date().toISOString(), ...args); }
async function createSession() {
log('POST /api/session ->', TOKEN_SERVER);
const res = await fetch(`${TOKEN_SERVER}/api/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'e2e-room', username: 'pw-runner' })
});
const body = await res.json();
log('session response', res.status, body);
if (!res.ok) throw new Error('session creation failed');
return body;
}
(async () => {
try {
const session = await createSession();
const studioUrl = session.redirectUrl || session.studioUrl || (session.id ? `${STUDIO_BASE}/${session.id}` : null);
if (!studioUrl) throw new Error('No studio url returned');
log('Launching browser');
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
log('Opening', studioUrl);
const resp = await page.goto(studioUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
log('opened status', resp && resp.status());
// Wait for either a #status element or a known text
try {
const status = page.locator('#status');
await status.waitFor({ timeout: 7000 });
const txt = await status.textContent();
log('#status text:', txt && txt.slice(0,200));
} catch (e) {
log('No #status or timeout, trying heuristics');
// look for explicit token string in page content
const content = await page.content();
if (content.includes('Token recibido') || content.includes('LIVEKIT_TOKEN') || content.includes(session.token || '')) {
log('Token text appears in page content');
} else {
log('Token not obviously present in page content');
}
}
const s1 = '/tmp/pw_e2e_studio_' + Date.now() + '.png';
await page.screenshot({ path: s1, fullPage: true });
log('Saved screenshot', s1);
// Also fetch session via API to assert token present
if (session.id) {
try {
const lookup = await fetch(`${TOKEN_SERVER}/api/session/${session.id}`);
const j = await lookup.json();
log('session lookup', lookup.status, Object.keys(j));
const sessionFile = `/tmp/session_${session.id}.json`;
fs.writeFileSync(sessionFile, JSON.stringify(j, null, 2));
log('Saved session JSON to', sessionFile);
} catch (err) { log('session lookup failed', err.message) }
}
await browser.close();
log('E2E success');
process.exit(0);
} catch (err) {
console.error('E2E error', err.message);
process.exit(1);
}
})();

View File

@ -0,0 +1,147 @@
import { chromium } from 'playwright';
import { spawn } from 'child_process';
import path from 'path';
console.log('run_e2e: starting (pid=' + process.pid + ')');
console.log('ENV VITE_STUDIO_URL=' + (process.env.VITE_STUDIO_URL || ''));
console.log('ENV VITE_BROADCASTPANEL_URL=' + (process.env.VITE_BROADCASTPANEL_URL || ''));
console.log('ENV VITE_TOKEN_SERVER_URL=' + (process.env.VITE_TOKEN_SERVER_URL || ''));
const serverPath = path.resolve('./server.mjs');
const base = 'http://localhost:5174';
// Allow overriding the target origin via ENV (e.g., VITE_STUDIO_URL)
const targetOrigin = process.env.VITE_STUDIO_URL || process.env.TARGET_ORIGIN || '';
// If provided, open this as the sender page so origin matches allowed production origins
const broadcastSender = process.env.VITE_BROADCASTPANEL_URL || '';
// Token server URL for obtaining tokens automatically
const tokenServer = process.env.VITE_TOKEN_SERVER_URL || process.env.TOKEN_SERVER_URL || '';
async function startServer() {
console.log('run_e2e: starting static server at', serverPath);
const proc = spawn(process.execPath, [serverPath], { env: { ...process.env, PORT: '5174' }, stdio: ['ignore', 'pipe', 'pipe'] });
proc.stdout.on('data', d => process.stdout.write('[server] ' + d.toString()));
proc.stderr.on('data', d => process.stderr.write('[server] ' + d.toString()));
await new Promise(r => setTimeout(r, 300));
console.log('run_e2e: static server started');
return proc;
}
// helper: request token from token server
async function requestTokenFromServer() {
if (!tokenServer) return null;
try {
// If SESSION_ID is provided, request that session; otherwise use a default /api/session/e2e endpoint
const sessionId = process.env.SESSION_ID || '';
const endpoint = sessionId ? `/api/session/${encodeURIComponent(sessionId)}` : '/api/session/e2e';
const url = `${tokenServer.replace(/\/$/, '')}${endpoint}`;
console.log('Requesting token from', url, sessionId ? `(sessionId=${sessionId})` : '(default)');
const res = await fetch(url, { method: 'GET' });
if (!res.ok) {
console.warn('Token server returned', res.status);
return null;
}
const data = await res.json();
if (data && data.token) return data;
return null;
} catch (err) {
console.warn('Token request failed:', String(err));
return null;
}
}
async function run() {
console.log('run_e2e: launching browser');
const server = await startServer();
const browser = await chromium.launch({ headless: true });
console.log('run_e2e: browser launched');
const context = await browser.newContext();
const page = await context.newPage();
let url;
if (broadcastSender) {
url = broadcastSender; // open prod broadcast panel as sender
console.log('Opening broadcast sender at', url);
} else {
url = base + '/sender.html';
if (targetOrigin) url += '?target=' + encodeURIComponent(targetOrigin);
console.log('Opening local sender at', url);
}
console.log('run_e2e: navigating to', url);
await page.goto(url);
console.log('run_e2e: page loaded');
// If we opened local sender, click UI; if we opened remote broadcast page we try to trigger postMessage via evaluate
if (!broadcastSender) {
await page.click('#open');
}
// Before sending a token, try to fetch a fresh token from the token server
let token = 'E2E_TEST_TOKEN'; // fallback
let tokenMeta = null;
try {
tokenMeta = await requestTokenFromServer();
if (tokenMeta && tokenMeta.token) {
token = tokenMeta.token;
console.log('Obtained token from server, using it for handshake');
} else {
console.log('No token from server, using fallback token');
}
} catch (e) {
console.warn('Token fetch error, using fallback token', e);
}
if (!broadcastSender) {
// send token from the local sender UI (click send)
await page.click('#send');
// but override the message in the page to use the token we fetched
try {
await page.evaluate((tok) => {
if (window.__studioPopup && !window.__studioPopup.closed) {
try {
window.__studioPopup.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, window.__studioPopup.location?.origin || '*');
} catch (e) {
// fallback: send to global origin used by the page
try { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, location.origin); } catch(e){}
}
}
}, token);
} catch (e) { console.warn('Override send failed', e); }
} else {
// For remote broadcast panel, open the studio popup and post message using the fetched token
await page.evaluate(async (studioUrl, tok) => {
// open popup
window.popupForE2E = window.open(studioUrl, 'studioPopup', 'width=800,height=600');
// wait for a moment and then post the token
await new Promise(r => setTimeout(r, 600));
try {
window.popupForE2E.postMessage({ type: 'LIVEKIT_TOKEN', token: tok, room: 'e2e-room' }, studioUrl);
} catch (e) {
console.error('postMessage failed inside page eval', e);
}
}, targetOrigin || '', token);
}
// wait for token ack
await page.waitForFunction(() => {
const p = document.querySelector('pre');
return p && p.textContent && p.textContent.includes('LIVEKIT_TOKEN_ACK');
}, { timeout: 7000 }).catch(()=>{});
// wait for connected ack
await page.waitForFunction(() => {
const p = document.querySelector('pre');
return p && p.textContent && p.textContent.includes('LIVEKIT_ACK') && p.textContent.includes('connected');
}, { timeout: 7000 }).catch(()=>{});
console.log('run_e2e: finished script, collecting logs');
const log = await page.$eval('pre', el => el.textContent).catch(()=>'');
console.log('E2E log:\n', log);
await browser.close();
console.log('run_e2e: browser closed, killing server');
server.kill();
}
run().catch(err => { console.error('run_e2e: fatal error', err); process.exit(1); });

View File

@ -0,0 +1,103 @@
{
"source": "streamyard.com broadcasts -> studio",
"capturedAt": "2025-11-15T00:00:00Z",
"notes": "Selectores heurísticos y alternativas (texto, xpath, css) para pruebas E2E. Usar la estrategia text (Playwright) o XPath cuando el texto es estable.",
"selectors": [
{
"id": "broadcasts.enter_studio_link",
"description": "Link 'Entrar al estudio' en la tabla de broadcasts (fila de transmisión)",
"playwright": "text=Entrar al estudio",
"css": "a:has-text('Entrar al estudio')",
"xpath": "//a[contains(normalize-space(.), 'Entrar al estudio')]",
"page": "broadcasts"
},
{
"id": "prejoin.start_camera_button",
"description": "Botón para iniciar la cámara en la pantalla previa al ingreso",
"playwright": "text=Iniciar cámara",
"css": "button:has-text('Iniciar cámara')",
"xpath": "//button[contains(normalize-space(.), 'Iniciar cámara')]",
"page": "studio-prejoin"
},
{
"id": "prejoin.join_without_devices",
"description": "Botón fallback 'Entrar sin micrófono/cámara' cuando no hay dispositivos",
"playwright": "text=Entrar sin micrófono/cámara",
"css": "button:has-text('Entrar sin micrófono/cámara')",
"xpath": "//button[contains(normalize-space(.), 'Entrar sin micrófono/cámara')]",
"page": "studio-prejoin"
},
{
"id": "prejoin.enter_studio_button",
"description": "Botón final 'Entrar al estudio' en prejoin (si existe)",
"playwright": "text=Entrar al estudio",
"css": "button:has-text('Entrar al estudio'), a:has-text('Entrar al estudio')",
"xpath": "//button[contains(normalize-space(.), 'Entrar al estudio')] | //a[contains(normalize-space(.), 'Entrar al estudio')]",
"page": "studio-prejoin"
},
{
"id": "studio.add_guest_button",
"description": "Botón 'Agregar invitados' en el panel del estudio (green room)",
"playwright": "text=Agregar invitados",
"css": "button:has-text('Agregar invitados')",
"xpath": "//button[contains(normalize-space(.), 'Agregar invitados')]",
"page": "studio"
},
{
"id": "studio.record_button",
"description": "Botón 'Grabar' o 'Record' en la barra de control para iniciar/detener grabación",
"playwright": "text=Grabar",
"css": "button:has-text('Grabar'), button:has-text('Record'), button:has-text('Iniciar grabación'), button:has-text('Start Recording'), button:has-text('Recording'), button[data-testid*='record']",
"xpath": "//button[contains(normalize-space(.), 'Grabar') or contains(normalize-space(.), 'Record') or contains(normalize-space(.), 'Iniciar grabación') or contains(normalize-space(.), 'Start Recording') or contains(normalize-space(.), 'Recording')]",
"page": "studio"
},
{
"id": "studio.destination_button",
"description": "Botón 'Agregar destino' (configuración de plataformas de multistream)",
"playwright": "text=Agregar destino",
"css": "button:has-text('Agregar destino')",
"xpath": "//button[contains(normalize-space(.), 'Agregar destino')]",
"page": "studio"
},
{
"id": "scenes.show_on_stage",
"description": "Botón 'Mostrar en el escenario' dentro del listado de escenas (thumbnail)",
"playwright": "text=Mostrar en el escenario",
"css": "button:has-text('Mostrar en el escenario')",
"xpath": "//button[contains(normalize-space(.), 'Mostrar en el escenario')]",
"page": "studio-aside-scenes"
},
{
"id": "scenes.new_scene",
"description": "Botón 'Nueva escena' en panel de Scenes",
"playwright": "text=Nueva escena",
"css": "button:has-text('Nueva escena')",
"xpath": "//button[contains(normalize-space(.), 'Nueva escena')]",
"page": "studio-aside-scenes"
},
{
"id": "assets.change_logo_section",
"description": "Control para cambiar la sección del logo en Assets",
"playwright": "text=Cambiar la sección del logo",
"css": "button:has-text('Cambiar la sección del logo')",
"xpath": "//button[contains(normalize-space(.), 'Cambiar la sección del logo')]",
"page": "studio-aside-assets"
},
{
"id": "layouts.preset_individual",
"description": "Botón de preset diseño 'Individual' (uno de los presets listados)",
"playwright": "text=Diseño Individual",
"css": "button:has-text('Diseño Individual'), button:has-text('Individual Central')",
"xpath": "//button[contains(normalize-space(.), 'Diseño Individual') or contains(normalize-space(.),'Individual Central')]",
"page": "studio"
},
{
"id": "studio.leave_link",
"description": "Link 'Salir del estudio' para cerrar la sesión del studio",
"playwright": "text=Salir del estudio",
"css": "a:has-text('Salir del estudio'), button:has-text('Salir del estudio')",
"xpath": "//a[contains(normalize-space(.), 'Salir del estudio')] | //button[contains(normalize-space(.), 'Salir del estudio')]",
"page": "studio"
}
]
}

View File

@ -0,0 +1,42 @@
import http from 'http';
import fs from 'fs';
import path from 'path';
const port = process.env.PORT ? Number(process.env.PORT) : 5174;
const staticDir = path.resolve(process.cwd(), './static');
const mime = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
};
const server = http.createServer((req, res) => {
try {
const reqPath = req.url === '/' ? '/sender.html' : req.url;
const filePath = path.join(staticDir, decodeURIComponent(reqPath));
if (!filePath.startsWith(staticDir)) {
res.writeHead(403); res.end('Forbidden'); return;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
res.writeHead(200, { 'Content-Type': mime[ext] || 'text/plain' });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404); res.end('Not found');
}
} catch (e) {
res.writeHead(500); res.end('Server error');
}
});
server.listen(port, () => {
console.log(`Static server running on http://localhost:${port}`);
});
// Graceful shutdown
process.on('SIGINT', () => server.close(() => process.exit(0)));
process.on('SIGTERM', () => server.close(() => process.exit(0)));

View File

@ -0,0 +1,95 @@
#!/usr/bin/env node
// Simulated E2E flow for Studio Panel
// - POST /api/session to token server
// - open studio receiver URL or studio page and assert token reception
// - save screenshots to /tmp
import fs from 'fs';
import fetch from 'node-fetch';
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000';
const STUDIO_BASE = process.env.STUDIO_URL || 'http://localhost:3020';
const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000);
function log(...args) { console.log(new Date().toISOString(), ...args); }
async function createSession() {
log('Creating session at', TOKEN_SERVER);
const res = await fetch(`${TOKEN_SERVER}/api/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'e2e-room', username: 'sim-scripter' })
});
if (!res.ok) {
const t = await res.text();
throw new Error(`Token server returned ${res.status}: ${t}`);
}
const body = await res.json();
log('Session created:', body.id || '(no id)', 'studioUrl=', body.studioUrl);
return body;
}
async function runPlaywright(url) {
log('Attempting Playwright flow for', url);
let playwright;
try { playwright = await import('playwright'); } catch (e) { log('Playwright not available:', e.message); return { available: false }; }
const { chromium } = playwright;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
log('Page loaded status', resp && resp.status());
// try to find #status text
try {
const status = page.locator('#status');
await status.waitFor({ timeout: 5000 });
const txt = await status.textContent();
log('#status text:', txt && txt.slice(0,200));
} catch (e) {
log('No #status element or timeout:', e.message);
}
const shot = `/tmp/e2e_playwright_${Date.now()}.png`;
await page.screenshot({ path: shot, fullPage: true });
log('Saved screenshot', shot);
await browser.close();
return { available: true, screenshot: shot };
}
async function fallbackHttp(url, sessionId, token) {
log('Fallback HTTP: GET', url);
try {
const r = await fetch(url);
log('GET status', r.status, 'content-type', r.headers.get('content-type'));
const txt = await r.text();
const found = txt.includes('Token recibido') || txt.includes('LIVEKIT_TOKEN') || txt.includes(token);
log('Token present in body?', found);
const shotPath = `/tmp/e2e_http_${Date.now()}.html`;
fs.writeFileSync(shotPath, txt);
log('Saved page HTML to', shotPath);
return { ok: true, found, path: shotPath };
} catch (e) {
log('HTTP fallback failed', e.message);
return { ok: false, error: e.message };
}
}
(async () => {
try {
const session = await createSession();
const studioUrl = session.redirectUrl || session.studioUrl || (session.id ? `${STUDIO_BASE}/${session.id}` : null);
if (!studioUrl) throw new Error('No studio url returned by token server');
// Prefer Playwright flow
const pw = await runPlaywright(studioUrl);
if (pw.available) { log('Playwright flow done'); process.exit(0); }
// fallback: HTTP check
const fb = await fallbackHttp(studioUrl, session.id, session.token || '');
if (fb.ok) process.exit(0);
process.exit(2);
} catch (err) {
log('E2E error', err.message);
process.exit(1);
}
})();

View File

@ -0,0 +1,48 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Sender (E2E)</title>
</head>
<body>
<button id="open">Open studio popup</button>
<button id="send">Send token</button>
<pre id="log"></pre>
<script>
function getParam(name) {
try { return new URLSearchParams(window.location.search).get(name); } catch(e) { return null; }
}
const targetOverride = getParam('target');
let popup = null;
document.getElementById('open').addEventListener('click', () => {
// If a target override is provided (production domain), open that URL directly
const urlToOpen = targetOverride ? targetOverride : '/studio.html';
popup = window.open(urlToOpen, 'studioPopup', 'width=800,height=600');
log('opened popup: ' + urlToOpen);
});
document.getElementById('send').addEventListener('click', () => {
if (!popup) return log('no popup');
const payload = { type: 'LIVEKIT_TOKEN', token: 'E2E_TEST_TOKEN', room: 'e2e-room' };
const target = targetOverride || location.origin;
try {
popup.postMessage(payload, target);
log('posted token to target: ' + target);
} catch (err) {
log('postMessage error: ' + String(err));
}
});
window.addEventListener('message', (e) => {
log('received: ' + JSON.stringify(e.data) + ' origin:' + e.origin);
});
function log(msg) {
const p = document.getElementById('log');
p.textContent += msg + '\n';
}
window.__senderReady = true;
</script>
</body>
</html>

View File

@ -0,0 +1,38 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Studio (E2E)</title>
</head>
<body>
<div id="status">Esperando token...</div>
<script>
// simple receiver that mimics the App behaviour
window.addEventListener('message', (e) => {
try {
const data = e.data || {};
if (data && data.type === 'LIVEKIT_TOKEN' && data.token) {
// reply ack to sender
e.source && e.source.postMessage({ type: 'LIVEKIT_TOKEN_ACK', status: 'ok', room: data.room }, e.origin || '*');
// simulate connecting to LiveKit
setTimeout(() => {
document.getElementById('status').textContent = 'Conectado';
// send connected ack to opener/parent
if (window.opener && !window.opener.closed) {
window.opener.postMessage({ type: 'LIVEKIT_ACK', status: 'connected' }, e.origin || '*');
}
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'LIVEKIT_ACK', status: 'connected' }, e.origin || '*');
}
// also post to the source
e.source && e.source.postMessage({ type: 'LIVEKIT_ACK', status: 'connected' }, e.origin || '*');
}, 300);
}
} catch (err) { }
});
// expose a ready flag for tests
window.__studioReady = true;
</script>
</body>
</html>

View File

@ -0,0 +1,51 @@
const { test, expect } = require('@playwright/test');
const { spawn } = require('child_process');
const path = require('path');
const staticServerPath = path.resolve(__dirname, '..', 'server.mjs');
let serverProc;
test.beforeAll(async () => {
// start static server
serverProc = spawn(process.execPath, [staticServerPath], { env: { ...process.env, PORT: '5174' }, stdio: 'inherit' });
// wait a bit for server to start
await new Promise(resolve => setTimeout(resolve, 400));
});
test.afterAll(async () => {
if (serverProc) {
serverProc.kill();
}
});
test('handshake: sender -> studio via postMessage and ACKs', async ({ page, context }) => {
const base = 'http://localhost:5174';
// open sender page
await page.goto(base + '/sender.html');
// open popup by clicking
await page.click('#open');
// give popup time to open and be ready
await new Promise(r => setTimeout(r, 300));
// send token
await page.click('#send');
// wait for token ack from studio
await page.waitForFunction(() => {
const p = document.querySelector('pre');
return p && p.textContent && p.textContent.includes('LIVEKIT_TOKEN_ACK');
}, { timeout: 5000 });
// wait for connected ack
await page.waitForFunction(() => {
const p = document.querySelector('pre');
return p && p.textContent && p.textContent.includes('LIVEKIT_ACK') && p.textContent.includes('connected');
}, { timeout: 5000 });
const log = await page.$eval('pre', el => el.textContent);
expect(log).toContain('LIVEKIT_TOKEN_ACK');
expect(log).toContain('LIVEKIT_ACK');
});

View File

@ -8,7 +8,43 @@
</head>
<body>
<div id="root"></div>
<!-- Status element for E2E: presence ensures tests can detect ACK/state quickly -->
<div id="status" style="position:fixed;right:12px;top:12px;padding:8px 12px;background:rgba(0,0,0,0.05);color:#111;border-radius:6px;z-index:9999">Esperando token...</div>
<script>
// Ensure token present in querystring is delivered to the app via repeated postMessage
(function(){
try {
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const room = params.get('room') || '';
const username = params.get('username') || '';
if (token) {
const payload = { type: 'LIVEKIT_TOKEN', token: token, room: room, user: username };
const origin = window.location.origin || '*';
// send periodically for up to 8s to handle slow SPA mount
const interval = 500;
const maxMs = 8000;
let sent = 0;
const t0 = Date.now();
const id = setInterval(()=>{
try{
window.postMessage(payload, origin);
sent++;
// stop after maxMs
if (Date.now() - t0 > maxMs) {
clearInterval(id);
console.debug('[index] stopped repeated postMessage after', sent, 'attempts');
}
}catch(e){ console.debug('[index] repeated postMessage error', e); clearInterval(id); }
}, interval);
// also send once after 200ms immediately
setTimeout(()=>{
try{ window.postMessage(payload, origin); }catch(e){}
}, 200);
}
} catch (e) { /* ignore */ }
})();
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -6,34 +6,40 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"preview": "vite preview --host",
"test": "vitest",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"e2e": "node ./e2e/playwright_test.mjs"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"avanza-ui": "workspace:*",
"@livekit/components-react": "^2.7.2",
"@livekit/components-styles": "^1.1.5",
"livekit-client": "^2.8.2"
"avanza-ui": "file:../avanza-ui",
"livekit-client": "^2.8.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.0.0",
"vitest": "^1.1.8",
"@testing-library/react": "^14.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/user-event": "^14.4.3",
"@storybook/react": "^8.0.0",
"@storybook/react-vite": "^8.0.0",
"@playwright/test": "1.51.0",
"@storybook/addon-essentials": "^8.0.0",
"@storybook/addon-interactions": "^8.0.0",
"@storybook/addon-links": "^8.0.0",
"@storybook/blocks": "^8.0.0",
"storybook": "^8.0.0"
"@storybook/react": "^8.0.0",
"@storybook/react-vite": "^8.0.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@vitejs/plugin-react": "^4.0.0",
"node-fetch": "^3.3.1",
"playwright": "^1.51.0",
"storybook": "^8.0.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.5.0",
"vite": "^5.0.0",
"vitest": "^1.1.8"
},
"vitest": {
"test": {
@ -41,5 +47,12 @@
"setupFiles": "./src/setupTests.ts",
"globals": true
}
}
},
"main": "index.js",
"directories": {
"test": "tests"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
timeout: 60_000,
expect: { timeout: 5000 },
fullyParallel: false,
reporter: [['list'], ['html', { outputFolder: 'playwright-report' }]],
use: {
headless: true,
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: true,
actionTimeout: 10000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});

View File

@ -1,7 +1,5 @@
module.exports = {
plugins: {
// use the PostCSS plugin package for Tailwind v4
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
// Minimal PostCSS config for studio-panel - disabled to avoid Tailwind/PostCSS plugin issues during development.
// If you want to enable Tailwind processing later, replace this file with a proper config
// that uses '@tailwindcss/postcss' or 'tailwindcss' and avoid top-level requires that may throw.
module.exports = {};

View File

@ -0,0 +1,79 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Debug Styles - Studio Panel</title>
<style>body{font-family:ui-sans-serif,system-ui,Arial;margin:20px;background:#fff;color:#111}pre{white-space:pre-wrap;word-break:break-word}code{display:block;background:#f3f4f6;padding:8px;border-radius:6px;margin-top:8px}</style>
</head>
<body>
<h1>Debug: document.styleSheets</h1>
<div id="out">Cargando...</div>
<div id="summary" style="margin-top:18px;padding:12px;border:1px solid #e5e7eb;border-radius:6px;background:#fafafa"></div>
<script>
function nodeAttrs(n){
if(!n) return null;
const attrs = {};
for(const a of Array.from(n.attributes||[])) attrs[a.name]=a.value;
return {
tagName: n.tagName,
id: n.id || null,
className: n.className || null,
attributes: attrs,
outerHTML: (n.outerHTML || '').slice(0,1200)
}
}
function detectTokensInSheetText(text, tokens){
if(!text) return {};
const res = {};
const low = text.toLowerCase();
tokens.forEach(t => { res[t] = low.indexOf(t.toLowerCase()) !== -1 });
return res;
}
function listSheets(){
try{
const tokensToCheck = ['--studio-bg-primary','--studio-accent','--primary-blue','--studio-accent-light','--studio-text-primary'];
const arr = Array.from(document.styleSheets).map((s, i) => ({
index: i,
href: s.href,
owner: s.ownerNode && s.ownerNode.tagName,
ownerText: (s.ownerNode && s.ownerNode.textContent) ? s.ownerNode.textContent.slice(0,400) : null,
ownerNode: nodeAttrs(s.ownerNode),
tokens: detectTokensInSheetText(s.ownerNode && s.ownerNode.textContent ? s.ownerNode.textContent : '', tokensToCheck)
}));
const out = document.getElementById('out');
out.innerHTML = '<pre>' + JSON.stringify(arr, null, 2) + '</pre>';
// nicer view
arr.forEach(it => {
const d = document.createElement('div');
d.style.padding='10px'; d.style.marginTop='8px'; d.style.border='1px solid #e5e7eb'; d.style.borderRadius='6px';
d.innerHTML = `<strong>sheet[${it.index}] href:</strong> ${it.href || '<inline>'} <br/>
<strong>owner:</strong> ${it.owner || '—'} <br/>
<strong>ownerNode outerHTML (slice):</strong> <code>${(it.ownerNode?.outerHTML||'').replace(/</g,'&lt;')}</code>
<strong>tokens found:</strong> <code>${JSON.stringify(it.tokens)}</code>
`;
out.appendChild(d);
})
// summary: computed values on :root
const cs = getComputedStyle(document.documentElement || document.body || document.querySelector('html'));
const summaryEl = document.getElementById('summary');
const computed = {};
['--studio-bg-primary','--studio-accent','--primary-blue','--studio-text-primary'].forEach(t => {
try{ computed[t] = cs.getPropertyValue(t) || null }catch(e){ computed[t] = null }
});
summaryEl.innerHTML = '<strong>Computed CSS variables on :root (may be empty if variables not set):</strong> <pre>' + JSON.stringify(computed,null,2) + '</pre>';
}catch(e){ document.getElementById('out').textContent = 'Error: '+String(e) }
}
window.addEventListener('load', ()=> setTimeout(listSheets, 300));
setTimeout(listSheets, 1000);
</script>
</body>
</html>

View File

@ -0,0 +1,154 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Broadcast to Studio - Simulate (with token fetch)</title>
<style>body{font-family:Arial;margin:20px}button{padding:8px 12px;border-radius:6px}#log{margin-top:12px;color:#111}#status{margin-top:8px;padding:8px;background:#f3f4f6;border-radius:6px}</style>
</head>
<body>
<h3>Broadcast Simulator (token fetch)</h3>
<p>Simula broadcast-panel: solicita token al backend y se lo envía por postMessage a la ventana del estudio.</p>
<label>Token server URL: <input id="tokenServer" type="text" size="40" value="https://avanzacast-servertokens.bfzqqk.easypanel.host" /></label>
<br /><br />
<label>Studio URL: <input id="studioUrl" type="text" size="40" value="https://avanzacast-studio.bfzqqk.easypanel.host" /></label>
<br /><br />
<label>Room: <input id="room" type="text" value="studio-demo" /></label>
<label style="margin-left:12px">Username: <input id="username" type="text" value="simulator" /></label>
<div style="margin-top:12px">
<button id="openSend">Open Studio and Send Token</button>
<button id="openOnly" style="margin-left:8px">Open Studio Only</button>
</div>
<div id="log"></div>
<div id="status" aria-live="polite">Idle</div>
<script>
const btn = document.getElementById('openSend');
const openOnly = document.getElementById('openOnly');
const log = document.getElementById('log');
const status = document.getElementById('status');
function appendLog(msg){
const p = document.createElement('div');
p.textContent = `[${new Date().toISOString()}] ${msg}`;
log.prepend(p);
}
async function fetchToken(tokenServer, room, username, timeoutMs = 6000){
if (!tokenServer) throw new Error('tokenServer empty');
const url = new URL('/api/token', tokenServer).toString() + `?room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`;
const controller = new AbortController();
const timer = setTimeout(()=> controller.abort(), timeoutMs);
try{
const resp = await fetch(url, { signal: controller.signal, credentials: 'include' });
clearTimeout(timer);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (!data || !data.token) throw new Error('Invalid token response');
return { token: data.token, url: data.url || tokenServer };
}catch(err){
clearTimeout(timer);
throw err;
}
}
function fallbackToken(){
// lightweight fake token for local testing
return { token: 'fake-token-' + Math.random().toString(36).slice(2,10), url: 'wss://livekit-server.example' };
}
function sendToStudioWindow(win, payload){
try{
win.postMessage(payload, '*');
appendLog('Sent token to studio popup (postMessage)');
status.textContent = 'Sent token to studio popup';
}catch(e){
appendLog('postMessage failed: ' + e);
status.textContent = 'postMessage failed';
}
}
// New flow: fetch token first, then try to open popup; fallback to redirect if popup blocked
async function doOpenSend(){
const tokenServer = document.getElementById('tokenServer').value.trim();
const studioUrl = document.getElementById('studioUrl').value.trim().replace(/\/$/, '');
const room = document.getElementById('room').value.trim() || 'studio-demo';
const username = document.getElementById('username').value.trim() || 'simulator';
status.textContent = 'Requesting token...';
appendLog('Requesting token from ' + tokenServer + ' for room=' + room + ' username=' + username);
let tokenData;
try{
tokenData = await fetchToken(tokenServer, room, username, 6000);
appendLog('Token obtained from server');
}catch(err){
appendLog('Token request failed: ' + (err && err.message));
appendLog('Using fallback token');
tokenData = fallbackToken();
}
// prepare payload and query params for fallback redirect
const payload = { type: 'LIVEKIT_TOKEN', token: tokenData.token, url: tokenData.url, room };
const q = new URLSearchParams({ token: tokenData.token || '', room: room || '', username: username || '' });
if (tokenData.url) q.set('serverUrl', tokenData.url);
// try open popup to studio_receiver
const receiverUrl = studioUrl + '/studio_receiver.html';
const studioWin = window.open(receiverUrl, 'studio_receiver', 'width=900,height=700');
if (studioWin && !studioWin.closed){
// post the token to the popup
try{
// attempt postMessage multiple times in case the popup is still loading
const tryPost = ()=>{
try{ studioWin.postMessage(payload, '*'); appendLog('postMessage attempted to popup'); }catch(e){ appendLog('postMessage error: '+e); }
}
setTimeout(tryPost, 300);
setTimeout(tryPost, 800);
setTimeout(tryPost, 1500);
status.textContent = 'Token sent to popup (attempted)';
}catch(e){
appendLog('Error sending to popup: ' + e);
// fallback redirect
window.location.href = `${studioUrl}/studio_receiver.html?${q.toString()}`;
}
} else {
appendLog('Popup blocked or not available; redirecting current window to studio with token');
// fallback: redirect current window to studio_receiver with token in querystring
window.location.href = `${studioUrl}/studio_receiver.html?${q.toString()}`;
}
// Listen for ACK from the popup
function onMessage(e){
try{
if (e?.data?.type === 'LIVEKIT_ACK'){
appendLog('ACK from studio: ' + JSON.stringify(e.data));
status.textContent = 'ACK received: ' + (e.data.status || 'ok');
window.removeEventListener('message', onMessage);
}
}catch(err){ /* ignore */ }
}
window.addEventListener('message', onMessage);
}
btn.addEventListener('click', doOpenSend);
openOnly.addEventListener('click', ()=>{
const studioUrl = document.getElementById('studioUrl').value.trim().replace(/\/$/, '');
window.open(studioUrl + '/studio_receiver.html', 'studio_receiver', 'width=900,height=700');
});
// Auto-run if ?auto=1 added to URL
try{
const params = new URLSearchParams(window.location.search);
if (params.get('auto') === '1'){
setTimeout(()=> doOpenSend(), 300);
}
}catch(e){}
</script>
</body>
</html>

View File

@ -0,0 +1,70 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Studio Receiver (Test)</title>
<style>body{font-family:Arial;background:#0f172a;color:#fff;padding:20px} .box{padding:12px;background:#111827;border-radius:8px}</style>
</head>
<body>
<h3>Studio Receiver - listens for LIVEKIT_TOKEN</h3>
<div class="box" id="status">Esperando token...</div>
<script>
function setStatusText(text){
try{ document.getElementById('status').textContent = text }catch(e){}
}
// helper to try to auto-fill parent opener (if studio UI is in opener)
function tryFillOpener(token, room, username){
try{
const op = window.opener;
if (!op) return false;
// try common fields used in studio Panel (ws url, room, token)
try{ if (op.document && op.document.querySelector) {
const tokenInput = op.document.querySelector('input[placeholder*="Paste your LiveKit token"], input[name*="token"], input[id*="token"]');
if (tokenInput) { tokenInput.value = token; tokenInput.dispatchEvent(new Event('input',{bubbles:true})); }
const roomInput = op.document.querySelector('input[placeholder*="Room Name"], input[name*="room"], input[id*="room"]');
if (roomInput && room) { roomInput.value = room; roomInput.dispatchEvent(new Event('input',{bubbles:true})); }
const connectBtn = op.document.querySelector('button:enabled');
if (connectBtn) { try { connectBtn.click(); } catch(e){} }
return true;
}}catch(e){}
}catch(e){}
return false;
}
// If token is provided via query param, show it and attempt auto actions
try{
const params = new URLSearchParams(window.location.search);
const qtoken = params.get('token');
const qroom = params.get('room') || '';
const quser = params.get('username') || '';
if (qtoken){
setStatusText('Token recibido (query): ' + qtoken);
// try to fill opener's form and trigger connect
const filled = tryFillOpener(qtoken, qroom, quser);
// also post ACK to opener if available
try{ if (window.opener) window.opener.postMessage({ type: 'LIVEKIT_ACK', status: 'received', via: 'query', filled: filled }, '*'); }catch(e){}
}
}catch(e){ console.warn('query token error', e) }
window.addEventListener('message', (e)=>{
try{
const data = e.data || {};
if (data?.type === 'LIVEKIT_TOKEN'){
const token = data.token || '';
const room = data.room || '';
setStatusText('Token recibido: ' + (token || '(empty)'));
// try to autofill opener
const filled = tryFillOpener(token, room, data.user || '');
// send ack back to opener
try{ e.source.postMessage({ type: 'LIVEKIT_ACK', status: 'received', filled: filled }, e.origin || '*') }catch(err){}
}
}catch(err){ console.error(err) }
});
// heartbeat for debugging
setInterval(()=>{ console.log('studio_receiver alive'); }, 5000);
</script>
</body>
</html>

View File

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# install deps (fast fail if missing)
npm ci --no-audit --no-fund
npx playwright install --with-deps || true
# start a simple static server to serve public/ on port 3020
PORT=3020
PUBLIC_DIR="$ROOT/public"
# prefer serve if available, else use npx http-server
if command -v serve > /dev/null 2>&1; then
serve -s "$PUBLIC_DIR" -l $PORT > /tmp/studio_static_server.log 2>&1 &
SERVER_PID=$!
else
npx http-server "$PUBLIC_DIR" -p $PORT > /tmp/studio_static_server.log 2>&1 &
SERVER_PID=$!
fi
echo "Started static server (pid=$SERVER_PID), log: /tmp/studio_static_server.log"
sleep 1
# export envs for the test
export TOKEN_SERVER_URL=${TOKEN_SERVER_URL:-http://localhost:4000}
export STUDIO_URL=${STUDIO_URL:-http://localhost:3020}
# run playwright test
npx playwright test tests/e2e/session_flow.spec.ts --project=chromium --config=playwright.config.ts || true
# cleanup
if [ -n "${SERVER_PID:-}" ]; then
kill "$SERVER_PID" 2>/dev/null || true
fi
echo "Artifacts (screenshots) in /tmp/e2e_*.png"
ls -la /tmp/e2e_*.png || true
exit 0

View File

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$ROOT_DIR"
LOG=/tmp/playwright_debug.log
OUT=/tmp/playwright_run_output.log
SIMSHOT=/tmp/sim_postmessage_simulator.png
STUDIOSHOT=/tmp/sim_postmessage_studio.png
echo "Running Playwright E2E test for studio-panel"
echo "Logs: $LOG Output: $OUT"
# Ensure node modules exist
if [ ! -d node_modules/playwright ]; then
echo "Playwright not found in node_modules — installing as devDependency (this may modify package-lock)
"
npm install --no-audit --no-fund --no-save playwright
fi
echo "Installing Playwright browsers (may require sudo on some systems)..."
npx playwright install --with-deps || true
echo "Running test script... (this may take ~15s)"
# run and capture output
node --experimental-vm-modules scripts/playwright_postmessage_test.mjs > "$OUT" 2>&1 || true
# Show summary
echo "\n=== Playwright run finished ===\n"
if [ -f "$LOG" ]; then
echo "Last 200 lines of $LOG:\n"
tail -n 200 "$LOG"
else
echo "$LOG not found"
fi
if [ -f "$OUT" ]; then
echo "\nLast 200 lines of run output ($OUT):\n"
tail -n 200 "$OUT"
else
echo "$OUT not found"
fi
if [ -f "$SIMSHOT" ]; then
echo "Simulator screenshot: $SIMSHOT"
else
echo "Simulator screenshot not found"
fi
if [ -f "$STUDIOSHOT" ]; then
echo "Studio screenshot: $STUDIOSHOT"
else
echo "Studio screenshot not found"
fi
echo "\nIf the test failed, please paste the contents of the two log files above and attach the screenshots listed.
You can upload screenshots to an image host and paste URLs, or paste the relevant log sections here."

View File

@ -0,0 +1,28 @@
let playwright
try {
playwright = await import('playwright')
} catch (e) {
console.error('\nPlaywright no está instalado en este entorno.');
console.error('Instálalo con: npm install -D playwright (en la raíz del repo) y luego ejecuta: npx playwright install');
process.exitCode = 2
throw e
}
const { chromium } = playwright;
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
try {
// Target studio-panel dev server by default
await page.goto('http://localhost:3020/', { waitUntil: 'networkidle' , timeout: 10000});
const sheets = await page.evaluate(() => Array.from(document.styleSheets).map(s => s.href || s.ownerNode?.textContent?.slice(0,50) || null));
console.log('document.styleSheets count =', sheets.length);
console.log(JSON.stringify(sheets, null, 2));
} catch (e) {
console.error('Error:', e.toString());
process.exitCode = 2;
} finally {
await browser.close();
}
})();

View File

@ -0,0 +1,29 @@
let playwright
try {
playwright = await import('playwright')
} catch (e) {
console.error('\nPlaywright no está instalado en este entorno.');
console.error('Instálalo con: npm install -D playwright (en la raíz del repo) y luego ejecuta: npx playwright install');
process.exitCode = 2
throw e
}
const { chromium } = playwright;
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
try {
// Target studio-panel dev server on port 3021
await page.goto('http://localhost:3021/', { waitUntil: 'networkidle' , timeout: 15000});
const sheets = await page.evaluate(() => Array.from(document.styleSheets).map(s => s.href || s.ownerNode?.textContent?.slice(0,250) || null));
console.log('document.styleSheets count =', sheets.length);
console.log(JSON.stringify(sheets, null, 2));
} catch (e) {
console.error('Error:', e.toString());
process.exitCode = 2;
} finally {
await browser.close();
}
})();

View File

@ -0,0 +1,31 @@
import { chromium } from 'playwright';
const WS = process.env.PLAYWRIGHT_WS_ENDPOINT || 'ws://192.168.1.20:3003';
const REDIR = process.env.REDIR_URL;
(async ()=>{
if (!REDIR) {
console.error('REDIR_URL not provided');
process.exit(2);
}
const browser = await chromium.connect({ wsEndpoint: WS });
const context = await browser.newContext();
const page = await context.newPage();
page.on('console', m => console.log('PAGE:', m.text()));
try {
await page.goto(REDIR, { waitUntil: 'networkidle', timeout: 30000 });
await page.waitForSelector('.studio-portal .layout-btn', { timeout: 5000 }).catch(()=>{});
// click second layout
await page.evaluate(()=>{ const btns = document.querySelectorAll('.layout-btn'); if (btns && btns[1]) (btns[1] as HTMLElement).click(); });
await page.waitForFunction(()=> !!document.querySelector('.studio-room')?.getAttribute('data-layout'), { timeout: 3000 }).catch(()=>{});
await page.evaluate(()=>{ const rec = document.querySelector('.btn-record') as HTMLElement; if(rec) rec.click(); });
await page.waitForTimeout(1500);
await page.screenshot({ path: '/tmp/e2e_portal_interaction.png', fullPage:true});
console.log('PAGE URL', page.url());
} catch (err) {
console.error('E2E error', err);
} finally {
await browser.close();
}
})();

View File

@ -0,0 +1,59 @@
import fetch from 'node-fetch';
import fs from 'fs';
const LOG = '/tmp/e2e_http_runner.log';
function writeLog(...args){ try{ fs.appendFileSync(LOG, args.map(a=>typeof a==='string'?a:JSON.stringify(a)).join(' ')+'\n') }catch(e){}
console.log(...args);
}
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000';
const STUDIO_URL = process.env.STUDIO_URL || 'http://localhost:3020';
const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 15000);
(async ()=>{
try{
writeLog('START HTTP runner', new Date().toISOString(), 'TOKEN_SERVER='+TOKEN_SERVER);
// 1) create session
const resp = await fetch(`${TOKEN_SERVER}/api/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'http-e2e-room', username: 'http-runner' })
});
writeLog('POST /api/session status', resp.status);
const json = await resp.json();
writeLog('POST response', json);
const redirectUrl = json.redirectUrl || json.studioUrl || null;
if (!redirectUrl){ writeLog('No redirectUrl in response, abort'); process.exit(2); }
writeLog('Redirect URL:', redirectUrl);
// 2) GET redirect URL
const r2 = await fetch(redirectUrl, { method: 'GET' });
writeLog('GET redirect status', r2.status, 'content-type', r2.headers.get('content-type'));
const bodyText = await r2.text();
writeLog('GET redirect body length', bodyText.length);
// 3) checks
const hasTokenInUrl = /token=/.test(redirectUrl);
const hasTokenInBody = /token=/.test(bodyText) || /Token recibido/.test(bodyText) || /LIVEKIT_ACK/.test(bodyText);
writeLog('hasTokenInUrl', hasTokenInUrl, 'hasTokenInBody', hasTokenInBody);
// 4) session lookup if id present
if (json.id){
try{
const s = await fetch(`${TOKEN_SERVER}/api/session/${json.id}`);
writeLog('/api/session/:id status', s.status);
const sjson = await s.json(); writeLog('session lookup', sjson);
}catch(e){ writeLog('session lookup error', String(e)); }
}
// save an excerpt of body to file
const out = '/tmp/e2e_http_runner_body.html';
try{ fs.writeFileSync(out, bodyText.slice(0, 20000)); writeLog('Saved redirect HTML excerpt to', out); }catch(e){ writeLog('Failed writing excerpt', String(e)); }
writeLog('FINISH OK');
process.exit(0);
}catch(err){ writeLog('Runner error', String(err)); process.exit(1); }
})();

View File

@ -0,0 +1,103 @@
import fetch from 'node-fetch';
import fs from 'fs';
const LOG_PATH = '/tmp/e2e_node_runner.log';
function log(...args) { try { fs.appendFileSync(LOG_PATH, args.map(a=>typeof a==='string'?a:JSON.stringify(a)).join(' ') + '\n') }catch(e){}
console.log(...args)
}
let playwrightAvailable = true;
let playwright = null;
try {
// dynamic import to allow fallback if playwright not installed
playwright = await import('playwright');
} catch (err) {
playwrightAvailable = false;
log('Playwright not available, will fallback to HTTP checks:', String(err));
}
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'http://localhost:4000';
const TIMEOUT = Number(process.env.E2E_TIMEOUT_MS || 30000);
(async () => {
try {
log('Creating session at token server:', TOKEN_SERVER);
const res = await fetch(`${TOKEN_SERVER}/api/session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room: 'e2e-room', username: 'node-runner' })
});
if (!res.ok) {
log('Token server returned', res.status);
log(await res.text());
process.exitCode = 2; return;
}
const body = await res.json();
log('Session created:', body);
const url = body.redirectUrl || body.studioUrl || (body.id ? `${(process.env.STUDIO_URL||'http://localhost:3020')}/studio_receiver.html?token=${encodeURIComponent(body.token||'')}` : null);
if (!url) { log('No redirectUrl'); process.exitCode = 3; return }
// If playwright available, open browser and verify #status text
if (playwrightAvailable && playwright) {
log('Launching Playwright chromium');
const { chromium } = playwright;
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
log('Opening studio page:', url);
const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
log('Loaded page status:', resp && resp.status());
const status = page.locator('#status');
try {
await status.waitFor({ timeout: TIMEOUT });
const text = await status.textContent();
log('Status text:', (text||'').slice(0,400));
if ((text || '').includes('Token recibido')) {
log('Studio received token OK');
} else {
log('Studio status did not include expected text');
}
} catch (err) {
log('Timeout waiting for #status', String(err));
}
const shot1 = '/tmp/e2e_studio_runner.png';
await page.screenshot({ path: shot1, fullPage: true });
log('Screenshot saved:', shot1);
await browser.close();
process.exitCode = 0;
return;
}
// Fallback: simple HTTP checks
log('Playwright not available: performing HTTP-only checks for', url);
try {
const r = await fetch(url, { method: 'GET' });
log('GET redirectUrl status', r.status, 'content-type', r.headers.get('content-type'));
const text = await r.text();
// look for token in body or in url
const hasTokenInBody = /token=/.test(text) || /Token recibido/.test(text) || /LIVEKIT_ACK/.test(text);
const hasTokenInUrl = /token=/.test(url);
log('hasTokenInBody', hasTokenInBody, 'hasTokenInUrl', hasTokenInUrl);
} catch (err) {
log('Error fetching redirectUrl:', String(err));
}
// Also check session retrieval via API
if (body.id) {
try {
const s = await fetch(`${TOKEN_SERVER}/api/session/${body.id}`);
log('/api/session/:id status', s.status);
if (s.ok) {
const js = await s.json(); log('session lookup:', js);
} else log('session lookup failed');
} catch (err) { log('session lookup error', String(err)) }
}
process.exitCode = 0;
} catch (err) {
log('E2E runner error', String(err));
process.exitCode = 1;
}
})();

View File

@ -0,0 +1,164 @@
import { chromium } from 'playwright';
import fs from 'fs';
const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:4000';
const GUESTS = Number(process.env.GUESTS || '2');
const HEADLESS = process.env.HEADLESS !== 'false';
const VERBOSE_LOG = '/tmp/e2e_sim_verbose.log';
function vlog(...args){
const line = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ');
try { fs.appendFileSync(VERBOSE_LOG, line + '\n'); } catch(e){}
console.log(line);
}
async function createSession(username) {
vlog('createSession ->', username);
try {
const res = await fetch(`${BACKEND}/api/session`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ room: 'studio-demo', username }),
// keepAlive not available in fetch, but backend should be reachable
});
if (!res.ok) {
const txt = await res.text().catch(()=>'<no-body>');
throw new Error(`session create failed ${res.status} ${txt}`);
}
const j = await res.json();
vlog('session created', j.redirectUrl || j);
return j;
} catch (e) {
vlog('createSession error', String(e));
throw e;
}
}
async function checkBackend(){
try {
vlog('Checking backend health at', BACKEND + '/health');
const r = await fetch(`${BACKEND}/health`, { method: 'GET', cache: 'no-store' , redirect: 'follow' });
if (!r.ok) { vlog('backend health not ok', r.status); return false; }
const j = await r.json().catch(()=>null);
vlog('backend health response', j || '<no-json>');
return true;
} catch (e) { vlog('backend health check failed', String(e)); return false; }
}
(async ()=>{
try{
// reset verbose log
try{ fs.writeFileSync(VERBOSE_LOG, ''); } catch(e){}
vlog('E2E simulate guests - starting', { BACKEND, GUESTS, HEADLESS });
const ok = await checkBackend();
if (!ok) {
vlog('Backend not available at', BACKEND, ' -> aborting');
process.exit(2);
}
// create sessions
const sessions = [];
try {
const mod = await createSession('moderator-e2e');
sessions.push({ role: 'moderator', ...mod });
for (let i=0;i<GUESTS;i++){
const u = `guest-e2e-${i+1}`;
const s = await createSession(u);
sessions.push({ role: 'guest', ...s });
}
} catch (e) {
vlog('failed creating sessions', String(e));
process.exit(2);
}
vlog('Launching browser');
const browser = await chromium.launch({
headless: HEADLESS,
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--allow-file-access-from-files',
'--allow-insecure-localhost'
]
});
const contexts = [];
const pages = [];
try {
// open moderator first
const modSession = sessions.find(s=>s.role==='moderator');
if (!modSession) throw new Error('no moderator session');
const modContext = await browser.newContext({ permissions: ['camera','microphone'] });
const modPage = await modContext.newPage();
modPage.on('console', m => vlog('MOD PAGE:', m.text()));
vlog('Opening moderator at', modSession.redirectUrl);
await modPage.goto(modSession.redirectUrl, { waitUntil: 'networkidle', timeout: 60000 });
contexts.push(modContext); pages.push(modPage);
// open guests
for (let s of sessions.filter(x=>x.role==='guest')){
const ctx = await browser.newContext({ permissions: ['camera','microphone'] });
const pg = await ctx.newPage();
pg.on('console', m => vlog('GUEST PAGE:', m.text()));
vlog('Opening guest at', s.redirectUrl);
await pg.goto(s.redirectUrl, { waitUntil: 'networkidle', timeout: 60000 });
contexts.push(ctx); pages.push(pg);
}
// wait short
await modPage.waitForTimeout(2000);
// click Conectar todos on moderator
vlog('Clicking Conectar todos');
await modPage.evaluate(()=>{
const btn = Array.from(document.querySelectorAll('button')).find(b => {
const txt = (b.textContent||'').trim().toLowerCase();
return txt.includes('conectar todos') || txt.includes('connect all');
});
if (btn && typeof btn.click === 'function') btn.click();
});
// wait for invites to be processed; wait until an svg line has green stroke
vlog('Waiting for accepted lines...');
const accepted = await modPage.waitForFunction(()=>{
const svg = document.querySelector('.connections-overlay');
if (!svg) return false;
const lines = Array.from(svg.querySelectorAll('line'));
return lines.some(l => ((l.getAttribute('stroke')||'').toLowerCase() === '#34d399'));
}, { timeout: 20000 }).catch(()=>null);
if (accepted) vlog('Accepted detected'); else vlog('No accepted detected within timeout');
// screenshots
const outDir = 'packages/studio-panel/.playwright-mcp';
try{ fs.mkdirSync(outDir, { recursive: true }); } catch(e){}
const modShot = '/tmp/e2e_sim_moderator.png';
await modPage.screenshot({ path: modShot, fullPage:true });
fs.copyFileSync(modShot, `${outDir}/${modShot.split('/').pop()}`);
vlog('Saved moderator screenshot to', modShot);
for (let i=0;i<pages.length;i++){
const p = pages[i];
const shot = `/tmp/e2e_sim_page_${i}.png`;
await p.screenshot({ path: shot, fullPage:true });
try{ fs.copyFileSync(shot, `${outDir}/${shot.split('/').pop()}`); } catch(e){}
vlog('Saved guest screenshot to', shot);
}
vlog('E2E simulate finished; screenshots saved');
} catch (e) {
vlog('E2E run failed', String(e));
} finally {
try { await browser.close(); } catch(e){}
}
vlog('Done.');
process.exit(0);
}catch(e){
try{ fs.appendFileSync(VERBOSE_LOG, 'fatal:' + String(e) + '\n'); } catch(_){}
console.error('fatal error', e);
process.exit(3);
}
})();

View File

@ -0,0 +1,285 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const LOG = '/tmp/playwright_mcp_flow.log';
// ensure log file exists and add header
try{ fs.writeFileSync(LOG, `=== playwright_mcp_flow log started ${new Date().toISOString()} ===\n`); } catch(e) { console.error('Could not create log file', e); }
function log(msg){ const line = `[${new Date().toISOString()}] ${msg}\n`; try{ fs.appendFileSync(LOG, line);}catch(e){}; try{ console.log(msg);}catch(e){} }
console.log('playwright_mcp_flow: starting script');
log('script entry');
// Robust dynamic import of playwright — if missing, write to log and exit gracefully
let playwright;
try{ playwright = await import('playwright'); } catch (e) { fs.appendFileSync(LOG, `Playwright import error: ${e}\n`); console.error('Playwright not available. Install with `npm i -D playwright` and run `npx playwright install`'); process.exit(2); }
const { chromium } = playwright;
// If a remote Playwright server WS endpoint is provided, connect to it instead of launching locally.
const PLAYWRIGHT_WS = process.env.PLAYWRIGHT_WS_ENDPOINT || process.env.PW_WS || 'ws://192.168.1.20:3003';
let remoteBrowser = null;
let launchedBrowser = null;
(async ()=>{
log('Starting playwright_mcp_flow');
// Connect to remote server if reachable; else fall back to local launch
let browser;
try {
if (PLAYWRIGHT_WS) {
log('Attempting to connect to remote Playwright WS at ' + PLAYWRIGHT_WS);
try {
browser = await chromium.connect({ wsEndpoint: PLAYWRIGHT_WS, timeout: 10000 });
remoteBrowser = browser;
log('Connected to remote Playwright browser via WS');
} catch (err) {
// If server responded with 428 (version mismatch) surface clear message
const emsg = (err && err.message) ? String(err.message) : String(err);
if (emsg.includes('428') || emsg.toLowerCase().includes('version')) {
log('Failed to connect to remote Playwright WS due to version mismatch: ' + emsg);
log('ACTION REQUIRED: Sync Playwright versions. Server reports a different Playwright version than the client.');
} else {
log('Failed to connect to remote Playwright WS: ' + err + '. Falling back to local launch.');
}
}
}
} catch (e) { log('Remote connect error: '+e); }
if (!browser) {
log('Launching local chromium');
launchedBrowser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-dev-shm-usage'] });
browser = launchedBrowser;
}
const context = await browser.newContext();
const page = await context.newPage();
page.on('console', m => log('PAGE LOG: ' + m.text()));
// Load selectors JSON
// convert module URL to file path correctly
const selPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../e2e/selectors_streamyard.json');
let SELECTORS = {};
try{
const raw = fs.readFileSync(selPath, 'utf8');
const parsed = JSON.parse(raw);
(parsed.selectors || []).forEach(s => { SELECTORS[s.id] = s; });
log(`Loaded ${Object.keys(SELECTORS).length} selectors from ${selPath}`);
}catch(err){ log('Failed to load selectors JSON: '+err); }
function getSel(id){
const s = SELECTORS[id];
if (!s) return null;
// prefer Playwright selector string if present
return s.playwright || s.css || s.xpath || null;
}
// Configurable env vars
const BROADCAST_URL = process.env.BROADCAST_URL || process.env.BROADCAST_LIST_URL || 'https://streamyard.com/broadcasts';
const BROADCAST_TIMEOUT = Number(process.env.BROADCAST_TIMEOUT_MS || 30000);
const STUDIO_TIMEOUT = Number(process.env.STUDIO_TIMEOUT_MS || 30000);
try{
log('Navigating to broadcast list: ' + BROADCAST_URL);
await page.goto(BROADCAST_URL, { waitUntil: 'networkidle', timeout: BROADCAST_TIMEOUT });
log('Broadcast page loaded: ' + page.url());
// If the broadcast page attempted to fetch a token but failed due to CORS, detect it in console logs
// We'll check page content for common CORS error markers and fallback to backend-api session creation.
const pageContent = await page.content().catch(()=>null);
const corsIssue = pageContent && (pageContent.includes('Failed to fetch') || pageContent.includes('blocked by CORS') || pageContent.includes('error%3A%20TypeError'));
if (corsIssue) {
log('Detected token fetch CORS issue on broadcast page — will create session via BACKEND_API_URL fallback');
// First attempt: try to fetch token directly from token server (server-side request)
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
try {
log('Attempting direct token request to token-server: ' + TOKEN_SERVER + '/api/token');
const room = process.env.TEST_ROOM || 'studio-demo';
const username = process.env.TEST_USERNAME || 'simulator';
const tokenResp = await context.request.get(TOKEN_SERVER + `/api/token?room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`);
if (tokenResp && tokenResp.ok()) {
const json = await tokenResp.json();
log('Token server returned JSON: ' + JSON.stringify(Object.keys(json)));
const token = json.token || json?.token;
const serverUrl = json.url || json?.url || process.env.VITE_LIVEKIT_WS_URL || process.env.VITE_LIVEKIT_URL;
if (token) {
// compose studio URL
const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, '');
const redirectUrl = `${studioBase}/studio_receiver.html?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}&serverUrl=${encodeURIComponent(serverUrl||'')}`;
log('Opening studio directly with token from token-server: ' + redirectUrl);
const studioPage = await context.newPage();
await studioPage.goto(redirectUrl, { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>log('studio goto failed: '+e));
page = studioPage; // eslint-disable-line
// skip backend fallback
corsIssue = false;
} else {
log('Token server response did not include token');
}
} else {
log('Token server request failed, status=' + (tokenResp && tokenResp.status()));
}
} catch (e) {
log('Direct token-server request failed: ' + e);
}
const BACKEND_API = process.env.BACKEND_API_URL || 'http://localhost:4000';
try {
log('Creating session via backend API: ' + BACKEND_API + '/api/session');
// use Playwright's request to perform POST (works with remote browser)
const room = process.env.TEST_ROOM || 'studio-demo';
const username = process.env.TEST_USERNAME || 'simulator';
const ttl = Number(process.env.TEST_TTL || 300);
const resp = await context.request.post(BACKEND_API + '/api/session', {
data: { room, username, ttl }
});
if (resp && resp.status() === 200) {
const json = await resp.json();
log('Session created: ' + JSON.stringify(json));
const redirectUrl = json.redirectUrl || json.studioUrl;
if (redirectUrl) {
log('Opening studio redirectUrl from backend session: ' + redirectUrl);
// open new page for studio
const studioPage = await context.newPage();
await studioPage.goto(redirectUrl, { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>log('studio goto failed: '+e));
// replace studioPage variable used below
page = studioPage; // eslint-disable-line
} else {
log('Backend session response did not include redirectUrl/studioUrl');
}
} else {
log('Backend session creation failed, status=' + (resp && resp.status()));
}
} catch (e) {
log('Error creating session via backend API fallback: ' + e);
}
}
// --- Login handling: if redirected to /login or login form present, attempt automated login using env vars
try {
const maybeLogin = page.url().includes('/login');
// also detect common login form inputs
const hasEmailInput = await page.locator('input[type="email"], input[name="email"], input[id*=email]').count();
const hasPasswordInput = await page.locator('input[type="password"], input[name="password"], input[id*=password]').count();
if (maybeLogin || hasEmailInput || hasPasswordInput) {
const TEST_USER_EMAIL = process.env.TEST_USER_EMAIL || process.env.STREAMYARD_TEST_EMAIL || '';
const TEST_USER_PASSWORD = process.env.TEST_USER_PASSWORD || process.env.STREAMYARD_TEST_PASSWORD || '';
log('Detected login page/form; env email present? ' + (TEST_USER_EMAIL ? 'yes' : 'no'));
if (TEST_USER_EMAIL && TEST_USER_PASSWORD) {
// fill email
const emailSelectors = ['input[type="email"]','input[name="email"]','input[id*=email]'];
for (const sel of emailSelectors) {
try { if (await page.locator(sel).count()) { await page.fill(sel, TEST_USER_EMAIL); log('Filled email using selector: '+sel); break; } } catch(e){}
}
// fill password
const passSelectors = ['input[type="password"]','input[name="password"]','input[id*=password]'];
for (const sel of passSelectors) {
try { if (await page.locator(sel).count()) { await page.fill(sel, TEST_USER_PASSWORD); log('Filled password using selector: '+sel); break; } } catch(e){}
}
// try submit with several button selectors
const submitSelectors = [
'button:has-text("Iniciar sesión")',
'button:has-text("Iniciar Sesión")',
'button:has-text("Sign in")',
'button[type="submit"]'
];
let clicked = false;
for (const s of submitSelectors) {
try {
if (await page.locator(s).count()) { await page.click(s); log('Clicked submit using selector: '+s); clicked = true; break; }
} catch(e){}
}
// if no button clicked, try pressing Enter on password field
if (!clicked) {
try { await page.keyboard.press('Enter'); log('Pressed Enter to submit login form'); } catch(e){}
}
// wait for navigation away from /login or for broadcasts path
try {
await page.waitForFunction(() => !location.pathname.includes('/login'), { timeout: 20000 });
log('Login appears to have completed; current URL: ' + page.url());
// small delay to allow app to settle
await page.waitForLoadState('networkidle');
} catch(e) { log('Login did not navigate away within timeout, continuing anyway: '+e); }
} else {
log('No TEST_USER_EMAIL/TEST_USER_PASSWORD provided in env; cannot auto-login.');
}
}
} catch (e) { log('Login detection/attempt failed: '+e); }
// Click the Enter Studio link (first match)
const enterSel = getSel('broadcasts.enter_studio_link');
if (!enterSel) throw new Error('Selector broadcasts.enter_studio_link not found');
try{
log('Waiting for enter studio link: ' + enterSel);
const enterLocator = page.locator(enterSel).first();
await enterLocator.waitFor({ timeout: 10000 });
await enterLocator.click({ force: true });
log('Clicked enter studio link');
} catch(e){ log('Failed clicking enter studio link: '+e); }
// Wait for navigation to prejoin/studio
await page.waitForLoadState('networkidle');
log('After click current URL: ' + page.url());
// If prejoin appears in same page, interact; else try to find new page in context
let studioPage = page;
// Try start camera
const startCameraSel = getSel('prejoin.start_camera_button');
const joinWithoutSel = getSel('prejoin.join_without_devices');
const enterStudioBtnSel = getSel('prejoin.enter_studio_button');
// If start camera exists, try click
if (startCameraSel) {
try{
const loc = studioPage.locator(startCameraSel).first();
await loc.waitFor({ timeout: 6000 });
await loc.click({ force:true });
log('Clicked start camera');
}catch(e){ log('start camera not available or failed: '+e);
// try fallback
if (joinWithoutSel){ try{ const j = studioPage.locator(joinWithoutSel).first(); await j.waitFor({ timeout:2000 }); await j.click({ force:true }); log('Clicked join without devices fallback'); }catch(err){ log('join without devices not found: '+err); } }
}
} else if (joinWithoutSel) {
try{ const j = studioPage.locator(joinWithoutSel).first(); await j.waitFor({ timeout:2000 }); await j.click({ force:true }); log('Clicked join without devices fallback (no startCamera selector)'); }catch(err){ log('join without devices not found (no startCamera selector): '+err); }
}
// Click final Enter if exists
if (enterStudioBtnSel){ try{ const ebtn = studioPage.locator(enterStudioBtnSel).first(); await ebtn.waitFor({ timeout:5000 }); await ebtn.click({ force:true }); log('Clicked final enter studio button'); }catch(e){ log('no final enter button: '+e); } }
// Wait for studio to be ready: look for studio.record_button or studio.add_guest_button
const studioReadySel = getSel('studio.record_button') || getSel('studio.add_guest_button');
if (studioReadySel){
log('Waiting for studio ready selector: ' + studioReadySel);
try{
await studioPage.waitForSelector(studioReadySel, { timeout: STUDIO_TIMEOUT });
log('Studio ready detected on page: ' + studioReadySel + ' url=' + studioPage.url());
}catch(e){
// maybe the app opened in a new tab - check other pages
log('Studio ready selector not found in current page, checking other context pages');
const pages = context.pages();
let found = false;
for (const p of pages){
try{
const u = p.url();
const loc = p.locator(studioReadySel).first();
if (await loc.count() > 0){
log('Found studio ready selector in page: ' + u);
studioPage = p;
found = true;
break;
}
}catch(err){}
}
if (!found) log('Studio ready not found in any page');
}
}
// Capture screenshots
const simShot = '/tmp/playwright_mcp_broadcast.png';
const studioShot = '/tmp/playwright_mcp_studio.png';
try{ await page.screenshot({ path: simShot, fullPage: true }); log('Saved broadcast screenshot: ' + simShot); }catch(e){ log('Failed saving broadcast screenshot: '+e); }
try{ await studioPage.screenshot({ path: studioShot, fullPage: true }); log('Saved studio screenshot: ' + studioShot); }catch(e){ log('Failed saving studio screenshot: '+e); }
log('Flow finished successfully (or attempted)');
}catch(err){ log('Unhandled error in flow: ' + (err && err.stack ? err.stack : String(err))); }
finally{
try{ await browser.close(); log('Browser closed'); }catch(e){ log('Error closing browser: '+e); }
log('Script finished');
}
})();

View File

@ -0,0 +1,152 @@
import fs from 'fs';
let playwright;
try { playwright = await import('playwright'); } catch (e) { fs.appendFileSync('/tmp/playwright_debug.log', `Playwright import error: ${e}\n`); process.exit(2); }
const { chromium } = playwright;
const LOG = '/tmp/playwright_debug.log';
function log(msg){
const line = `[${new Date().toISOString()}] ${msg}\n`;
try{ fs.appendFileSync(LOG, line); } catch(e) {}
}
(async ()=>{
log('Starting playwright_postmessage_test');
let browser;
try{
browser = await chromium.launch({ headless: true, args: ['--no-sandbox','--disable-dev-shm-usage'] });
log('Chromium launched');
const context = await browser.newContext();
const page = await context.newPage();
page.on('console', m => log('PAGE LOG: ' + m.text()));
let studioConsoleLogs = [];
// Use environment variables if provided, otherwise default to production domains
const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1';
const STUDIO_ORIGIN = process.env.STUDIO_ORIGIN || 'https://avanzacast-studio.bfzqqk.easypanel.host';
log('Navigating to broadcast URL: ' + BROADCAST_URL);
await page.goto(BROADCAST_URL, { waitUntil: 'networkidle', timeout: 30000 });
log('Broadcast page loaded');
// Wait briefly for auto-run, otherwise click the open button if present
try{
// if the page has a button with id 'run' or 'openSend', try to click it as fallback
const runBtn = await page.$('#run') || await page.$('#openSend');
if (runBtn) {
log('Found run/open button on page; clicking to trigger flow');
await runBtn.click();
}
} catch(e){ log('No run button click fallback: ' + e); }
// Wait up to 12s for the redirect to the studio (the broadcast page may open a popup or redirect)
log('Waiting for a new page whose origin matches ' + STUDIO_ORIGIN + ' (timeout 12s)');
let studioPage = null;
const start = Date.now();
const timeoutMs = 12000;
while (!studioPage && (Date.now() - start) < timeoutMs){
const pages = context.pages();
for (const p of pages){
try{
const u = p.url();
if (u && u.startsWith(STUDIO_ORIGIN)) { studioPage = p; break; }
}catch(e){}
}
if (studioPage) break;
await new Promise(r=>setTimeout(r, 300));
}
if (!studioPage){
log('Studio page not opened automatically; attempting to open studio receiver directly at ' + STUDIO_ORIGIN + '/studio_receiver.html');
// open the studio receiver in the same context
studioPage = await context.newPage();
await studioPage.goto(STUDIO_ORIGIN + '/studio_receiver.html', { waitUntil: 'networkidle', timeout: 30000 }).catch(e=>{ log('Goto studio_receiver failed: '+e); });
}
if (!studioPage) {
log('ERROR: Could not open studio page');
} else {
// capture console logs from studio page to detect connected/ACK messages
studioPage.on('console', m => {
try { const txt = m.text(); studioConsoleLogs.push(txt); log('STUDIO PAGE LOG: ' + txt); } catch(e) {}
});
log('Studio page ready at ' + studioPage.url());
try{
// wait for #status element that studio_receiver exposes
// try a slightly longer wait and fallback to scanning page text
try {
await studioPage.waitForSelector('#status', { timeout: 16000 });
} catch (e) {
log('waitForSelector #status timed out, will fallback to scanning page content');
}
const statusText = await studioPage.evaluate(() => {
const el = document.getElementById('status');
if (el) return el.textContent;
return document.body ? document.body.innerText || document.body.textContent : null;
}).catch(()=>null);
log('Studio #status text (initial): ' + statusText);
} catch(e){ log('No #status element or timeout: ' + (e && e.message)); }
// Now wait for the simulator page log (broadcast) to show ACK or for studio to update
try{
// Wait up to 10s for ACK to appear in any page logs (simulator) or studio status
const ackStart = Date.now();
let ackSeen = false;
const ackTimeout = 20000; // increase to 20s
while (!ackSeen && (Date.now() - ackStart) < ackTimeout){
// check studio console logs first
try{
for (const cmsg of studioConsoleLogs){
if (cmsg && (cmsg.toLowerCase().includes('conectado') || cmsg.toLowerCase().includes('connected') || cmsg.toLowerCase().includes('ack'))) {
ackSeen = true; log('ACK/connected found in studio console logs: ' + cmsg); break;
}
}
}catch(e){}
if (ackSeen) break;
// check simulator page (first page) for log element
try{
const simulatorPages = context.pages();
for (const sp of simulatorPages){
try{
const content = await sp.evaluate(()=>{
const el = document.getElementById('log');
if (el && el.textContent) return el.textContent;
return document.body ? (document.body.innerText || document.body.textContent) : null;
});
if (content && (content.includes('ACK') || /connected/i.test(content) || /conectado/i.test(content))) { ackSeen = true; log('ACK/connected found in simulator page content'); break; }
}catch(e){}
}
}catch(e){ }
// also check studio status
try{
const s = await studioPage.evaluate(()=>{
const st = document.getElementById('status');
if (st && st.textContent) return st.textContent;
return document.body ? (document.body.innerText || document.body.textContent) : null;
});
if (s && (s.toLowerCase().includes('ack') || s.toLowerCase().includes('connected') || s.toLowerCase().includes('conectado'))) { ackSeen = true; log('ACK/connected found in studio content: '+s); }
}catch(e){}
if (!ackSeen) await new Promise(r=>setTimeout(r, 300));
}
if (!ackSeen) log('Timeout waiting for ACK ('+ (ackTimeout/1000) +'s)');
} catch(e){ log('Error while waiting for ACK: '+e); }
// take screenshots
const simShot = '/tmp/sim_postmessage_simulator.png';
const studioShot = '/tmp/sim_postmessage_studio.png';
try{ await page.screenshot({ path: simShot, fullPage: true }); log('Saved simulator screenshot: ' + simShot); } catch(e){ log('Failed saving simulator screenshot: ' + e); }
try{ await studioPage.screenshot({ path: studioShot, fullPage: true }); log('Saved studio screenshot: ' + studioShot); } catch(e){ log('Failed saving studio screenshot: ' + e); }
}
} catch (err) {
log('Unhandled error: ' + (err && err.stack ? err.stack : String(err)));
process.exitCode = 2;
} finally {
try { if (browser) await browser.close(); log('Browser closed'); } catch(e) { log('Error closing browser: ' + e); }
}
log('Test finished');
})();

View File

@ -0,0 +1,34 @@
let playwright
try { playwright = await import('playwright'); } catch (e) { console.error('Playwright no está instalado:', e); process.exit(2); }
const { chromium } = playwright;
(async ()=>{
const browser = await chromium.launch();
const page = await browser.newPage();
try{
console.log('Navegando a simulate.html...');
await page.goto('http://localhost:3021/simulate.html', { waitUntil: 'networkidle', timeout: 15000 });
console.log('Página cargada. Buscando botón Start...');
const btn = await page.waitForSelector('button#recBtn, button:has-text("Start")', { timeout: 5000 });
if(!btn) throw new Error('Botón de grabación no encontrado');
console.log('Haciendo click en Start...');
await btn.click();
// esperar a que la clase recording esté presente o a que el .record-dot sea visible
await page.waitForSelector('.btn-control.recording', { timeout: 5000 });
await page.waitForSelector('.record-dot', { timeout: 5000 });
console.log('Recording visuals present — tomando captura...');
await page.screenshot({ path: '/tmp/studio_record_test.png', fullPage: true });
console.log('Screenshot guardada en /tmp/studio_record_test.png');
// comprobar atributos
const attrs = await page.evaluate(()=>{
const btn = document.getElementById('recBtn');
return {
ariaPressed: btn?.getAttribute('aria-pressed'),
hasRecordingClass: btn?.classList.contains('recording'),
recordDotDisplay: window.getComputedStyle(btn?.querySelector('.record-dot') || document.createElement('span')).display
}
});
console.log('Atributos tras click:', JSON.stringify(attrs));
}catch(e){ console.error('Error durante la prueba:', e); process.exitCode = 2 }
await browser.close();
})();

View File

@ -0,0 +1,14 @@
let playwright
try { playwright = await import('playwright'); } catch (e) { console.error('playwright missing', e); process.exit(2); }
const { chromium } = playwright;
(async ()=>{
const browser = await chromium.launch();
const page = await browser.newPage();
try{
await page.goto('http://localhost:3021/', { waitUntil: 'networkidle' , timeout: 10000 });
await page.screenshot({ path: '/tmp/studio_panel_home.png', fullPage: true });
console.log('screenshot saved to /tmp/studio_panel_home.png');
}catch(e){ console.error('err', e.toString()); process.exitCode=2 }
await browser.close();
})();

View File

@ -1,7 +1,9 @@
import React, { useState } from 'react';
import { StudioRoom } from './components/StudioRoom/StudioRoom';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'avanza-ui';
import './App.css';
import StudioPortal from './components/Portal/StudioPortal';
import { isAllowedOrigin } from './utils/postMessage';
import { Room } from 'livekit-client';
function App() {
const [isConnected, setIsConnected] = useState(false);
@ -12,6 +14,131 @@ function App() {
});
const [tempToken, setTempToken] = useState('');
const autoAttemptRef = useRef(false);
// external LiveKit Room managed by App when token is received
const roomRef = useRef<Room | null>(null);
const messageSourceRef = useRef<Window | null>(null);
// store the last validated origin that sent a token so we can ACK securely
const lastValidatedOrigin = useRef<string | null>(null);
// Called when the LiveKit room reports connected
const handleRoomConnected = () => {
setIsConnected(true);
// send connected ACK to the validated origin if available
try {
const payload = { type: 'LIVEKIT_ACK', status: 'connected' };
const targetOrigin = lastValidatedOrigin.current || '*';
// Prefer replying directly to the message source window if available
if (messageSourceRef.current && typeof (messageSourceRef.current as any).postMessage === 'function') {
try { (messageSourceRef.current as any).postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.opener && !window.opener.closed) {
try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.parent && window.parent !== window) {
try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
};
const handleRoomDisconnected = () => {
setIsConnected(false);
try {
const payload = { type: 'LIVEKIT_ACK', status: 'disconnected' };
const targetOrigin = lastValidatedOrigin.current || '*';
if (window.opener && !window.opener.closed) {
try { window.opener.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
if (window.parent && window.parent !== window) {
try { window.parent.postMessage(payload, targetOrigin); } catch (e) { /* ignore */ }
}
} catch (e) {}
// disconnect and clear app-managed room
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
};
// Cleanup app-managed room on unmount
useEffect(() => {
return () => {
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
};
}, []);
// Listen for LIVEKIT_TOKEN posted via postMessage (handshake flow)
useEffect(() => {
// central token handler used by both message events and custom events
const handleIncomingToken = async (payload: any, origin?: string | null, source?: any) => {
try {
const originToUse = origin || null;
if (!isAllowedOrigin(originToUse)) {
return;
}
// store validated origin and message source for ACKs
if (originToUse) lastValidatedOrigin.current = originToUse;
if (source) messageSourceRef.current = source;
if (payload?.url) setCredentials(prev => ({ ...prev, serverUrl: String(payload.url) }));
const receivedToken = String(payload.token || payload?.token);
setTempToken(receivedToken);
// cleanup previous room if exists
try { if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; } } catch(e){}
const newRoom = new Room();
roomRef.current = newRoom;
try {
const sUrl = payload?.url || credentials.serverUrl;
await newRoom.connect(sUrl, receivedToken);
setCredentials(prev => ({ ...prev, token: receivedToken }));
handleRoomConnected();
} catch (err) {
console.error('App-managed room connect failed', err);
}
if (!autoAttemptRef.current) {
autoAttemptRef.current = true;
setTimeout(() => { if (receivedToken) handleConnectWithToken(receivedToken); }, 60);
}
} catch (err) { console.debug('handleIncomingToken error', err); }
};
function onMessage(e: MessageEvent) {
try {
const d = e.data || {};
if (d?.type === 'LIVEKIT_TOKEN' && d.token) {
// call central handler and pass origin/source
handleIncomingToken(d, e.origin || null, e.source);
}
} catch (err) { console.debug('postMessage in App error', err) }
}
window.addEventListener('message', onMessage);
// Also listen for the custom event dispatched by main.tsx
function onCustomToken(e: any) {
try {
const detail = e?.detail || (window as any).__AVANZACAST_PENDING_TOKEN || null;
// attempt to recover origin/source from globals set by main.tsx if present
const lastMsg = (window as any).__AVZ_LAST_MSG_SOURCE || null;
const origin = lastMsg?.origin || null;
const source = lastMsg?.source || null;
if (detail && detail.token) handleIncomingToken(detail, origin, source);
} catch(err) { console.debug('custom token handler error', err); }
}
window.addEventListener('avz:livekit:token', onCustomToken as EventListener);
return () => {
window.removeEventListener('message', onMessage);
window.removeEventListener('avz:livekit:token', onCustomToken as EventListener);
};
}, []);
function handleConnectWithToken(tokenVal: string) {
if (tokenVal && tokenVal.trim()) {
setCredentials(prev => ({ ...prev, token: tokenVal }));
setIsConnected(true);
}
}
const handleConnect = () => {
if (tempToken.trim()) {
@ -20,6 +147,37 @@ function App() {
}
};
// Update a global #status element and notify opener/parent when connected — helps E2E tests detect ACK/state
useEffect(() => {
try {
const setStatus = (txt: string) => {
try {
let el = document.getElementById('status');
if (!el) {
el = document.createElement('div');
el.id = 'status';
el.style.position = 'fixed';
el.style.right = '12px';
el.style.top = '12px';
el.style.padding = '8px 12px';
el.style.background = 'rgba(16,185,129,0.12)';
el.style.color = '#10B981';
el.style.borderRadius = '6px';
el.style.zIndex = '9999';
document.body.appendChild(el);
}
el.textContent = txt;
} catch (e) { /* ignore */ }
};
if (isConnected) {
setStatus('Conectado');
} else {
setStatus('Esperando token...');
}
} catch (e) {}
}, [isConnected]);
if (!isConnected) {
return (
<div className="studio-theme" style={{
@ -171,19 +329,17 @@ function App() {
);
}
// When connected (we have token), show the StreamYard-like portal
return (
<StudioRoom
<StudioPortal
serverUrl={credentials.serverUrl}
token={credentials.token}
roomName={credentials.roomName}
onConnected={() => console.log('Conectado a la sala')}
onDisconnected={() => {
console.log('Desconectado de la sala');
setIsConnected(false);
}}
onRoomConnected={handleRoomConnected}
onRoomDisconnected={handleRoomDisconnected}
room={roomRef.current || undefined}
/>
);
}
export default App;

View File

@ -0,0 +1,61 @@
import React from 'react'
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
// Mock livekit-client Room class
const connectMock = vi.fn(() => Promise.resolve())
const disconnectMock = vi.fn(() => {})
const mockRoomConstructor = vi.fn(() => ({ connect: connectMock, disconnect: disconnectMock }))
vi.mock('livekit-client', () => {
return { Room: mockRoomConstructor }
})
import StudioPortal from '../components/Portal/StudioPortal'
describe('StudioPortal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
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(mockRoomConstructor).toHaveBeenCalled()
expect(connectMock).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} />)
// local constructor should not be called
await new Promise((r) => setTimeout(r, 50))
expect(mockRoomConstructor).not.toHaveBeenCalled()
})
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" />)
// 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(mockRoomConstructor).not.toHaveBeenCalled()
// Rerender with token to enable connect via button
rerender(<StudioPortal serverUrl="wss://example" token="MANUAL_TOKEN" roomName="r" />)
// Wait for auto connect (effect) or click button to trigger connect
await waitFor(() => expect(mockRoomConstructor).toHaveBeenCalled())
// Now test disconnect button triggers disconnect
const disconnectBtn = screen.getByText('Desconectar')
fireEvent.click(disconnectBtn)
await waitFor(() => expect(disconnectMock).toHaveBeenCalled())
})
})

View File

@ -0,0 +1,37 @@
import { describe, it, expect, vi } from 'vitest';
// We'll dynamically import the module after stubbing import.meta.env
async function loadWithEnv(env: Record<string, string | undefined>) {
// stub import.meta for the module loader
// vitest allows stubbing globals; set globalThis.import = { meta: { env } } may work
// Save original
const origImportMeta = (globalThis as any).importMeta;
try {
(globalThis as any).importMeta = { env };
const mod = await import('../utils/postMessage');
return mod;
} finally {
(globalThis as any).importMeta = origImportMeta;
}
}
describe('postMessage utils', () => {
it('reads allowed origins from VITE_STUDIO_ALLOWED_ORIGINS and VITE_STUDIO_URL', async () => {
const env = {
VITE_STUDIO_ALLOWED_ORIGINS: 'https://a.example.com, https://b.example.com',
VITE_STUDIO_URL: 'https://studio.example.com'
};
const mod = await loadWithEnv(env as any);
const allowed = (mod.getAllowedOriginsFromEnv && mod.getAllowedOriginsFromEnv()) || [];
expect(allowed).toEqual(expect.arrayContaining(['https://a.example.com', 'https://b.example.com', 'https://studio.example.com']));
});
it('isAllowedOrigin returns true for allowed origins and false otherwise', async () => {
const env = { VITE_STUDIO_ALLOWED_ORIGINS: 'https://x.com', VITE_STUDIO_URL: 'https://studio.local' };
const mod = await loadWithEnv(env as any);
expect(mod.isAllowedOrigin('https://x.com')).toBe(true);
expect(mod.isAllowedOrigin('https://studio.local')).toBe(true);
expect(mod.isAllowedOrigin('https://notallowed.com')).toBe(false);
});
});

View File

@ -0,0 +1,39 @@
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { vi, describe, it, expect, beforeEach } from 'vitest'
// Mock livekit-client Room
const connectMock = vi.fn(() => Promise.resolve())
const disconnectMock = vi.fn()
const MockRoom = vi.fn(() => ({ connect: connectMock, disconnect: disconnectMock }))
vi.mock('livekit-client', () => ({ Room: MockRoom }))
import App from '../App'
describe('smoke test - App postMessage -> Studio', () => {
beforeEach(() => {
vi.clearAllMocks()
// ensure a clean DOM for status element
document.body.innerHTML = ''
})
it('accepts LIVEKIT_TOKEN postMessage and shows Conectado status', async () => {
render(<App />)
// simulate sender posting message from same-origin
const payload = { type: 'LIVEKIT_TOKEN', token: 'SMOKE_TOKEN', url: 'wss://example' }
window.dispatchEvent(new MessageEvent('message', { data: payload, origin: window.location.origin, source: window }))
// wait for status element textContent to become 'Conectado'
await waitFor(() => {
const el = document.getElementById('status')
expect(el).toBeTruthy()
expect(el?.textContent).toMatch(/Conectado/i)
}, { timeout: 3000 })
// ensure we constructed a Room and called connect
expect(MockRoom).toHaveBeenCalled()
expect(connectMock).toHaveBeenCalledWith('wss://example', 'SMOKE_TOKEN')
})
})

View File

@ -1,16 +1,178 @@
import React from 'react'
import React, { useState, useContext } from 'react'
import IconCameraOn from './icons/IconCameraOn'
import IconMicOff from './icons/IconMicOff'
import { RoomContext } from '@livekit/components-react'
import { Room } from 'livekit-client'
import { ControlButton, ControlGroup, IconButton } from 'avanza-ui'
interface BottomControlsProps {
onToggleMute?: (muted: boolean) => void;
onToggleCamera?: (cameraOn: boolean) => void;
onToggleRecording?: (recording: boolean) => void;
}
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)
// Try to obtain the LiveKit Room from context when available
const ctxRoom = useContext(RoomContext) as Room | null
// Listen for go-live events to reflect live status in controls (no recording logic here)
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) }
}
window.addEventListener('avz:request:go-live', onGoLive as EventListener);
return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener);
}, []);
// Pre-generate tooltip ids so aria-describedby can reference them
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
}
// fallback: enable/disable tracks
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){}
}
}
} catch (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
}
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){}
}
}
} catch (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
// Try to publish a data message as a recording signal (best-effort)
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 publishData not available, try room.broadcast... (best-effort)
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)
}
}
const handleToggleMute = async () => {
const next = !muted
setMuted(next)
onToggleMute?.(next)
// Try to control livekit
await safeSetMic(!next ? true : false) // note: muted=true means microphone disabled
}
const handleToggleCamera = async () => {
const next = !cameraOn
setCameraOn(next)
onToggleCamera?.(next)
await safeSetCamera(next)
}
const handleToggleRecording = async () => {
const next = !recording
setRecording(next)
onToggleRecording?.(next)
// no-op for recording when focusing on transmission
}
export default function BottomControls(){
return (
<div className="fixed bottom-4 left-4 right-4 flex items-center justify-center gap-4">
<div className="bg-black/70 rounded-md px-4 py-3 flex items-center gap-3">
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconMicOff /></button>
<button className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center"><IconCameraOn /></button>
<button className="w-12 h-12 rounded-full bg-red-600 text-white flex items-center justify-center">Stop</button>
</div>
<div className="fixed bottom-4 left-4 right-4">
<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'}
onClick={handleToggleMute}
size="sm"
/>
<span id={muteTipId} role="tooltip" className="tooltip">{muted ? 'Activar micrófono' : 'Silenciar'}</span>
</div>
<div className="control-wrapper">
<IconButton
id={`btn-cam`}
icon={<IconCameraOn />}
active={cameraOn}
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>
</div>
<div className="control-wrapper">
<ControlButton
id="recBtn"
icon={recording ? <span className="record-dot" /> : undefined}
label={recording ? 'Stop' : 'Start'}
active={recording}
danger={true}
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>
</div>
<span className="visually-hidden" aria-live="polite">{recording ? 'Grabación iniciada' : 'Grabación detenida'}</span>
</ControlGroup>
</div>
)
}

View File

@ -18,7 +18,12 @@ export function ChatPanel() {
// Auto-scroll to bottom when messages change
const el = listRef.current
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
if (typeof el.scrollTo === 'function') {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
} else {
// Fallback for environments like jsdom that don't implement scrollTo
el.scrollTop = el.scrollHeight
}
}
}, [messages])
@ -131,7 +136,6 @@ export function ChatPanel() {
type="submit"
variant="primary"
size="sm"
onClick={send}
style={{
background: 'linear-gradient(135deg, #4361ee 0%, #3651d4 100%)',
border: 'none',

View File

@ -2,12 +2,155 @@ import React, { useEffect, useRef, useState } from 'react'
import { Button, Input, Badge } from 'avanza-ui'
export function LivekitConnector() {
const [url, setUrl] = useState('')
const [token, setToken] = useState('')
// Compatibilidad: priorizamos VITE_LIVEKIT_WS_URL (broadcast-panel), luego VITE_LIVEKIT_URL, luego window global
const defaultUrl = (import.meta.env.VITE_LIVEKIT_WS_URL as string) || (import.meta.env.VITE_LIVEKIT_URL as string) || (window as any).__LIVEKIT_URL__ || ''
// Compatibilidad para token server: VITE_TOKEN_SERVER_URL (broadcast-panel) o VITE_BACKEND_URL
const backendBase = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_URL as string) || 'http://localhost:4000'
const [url, setUrl] = useState<string>(defaultUrl)
const [token, setToken] = useState<string>('')
const [status, setStatus] = useState<'idle'|'connecting'|'connected'|'error'>('idle')
const roomRef = useRef<any>(null)
const [participants, setParticipants] = useState<any[]>([])
// Refs to support postMessage ACK back to the opener
const messageSourceRef = useRef<any>(null)
const messageOriginRef = useRef<string | null>(null)
const lastPayloadRef = useRef<any>(null)
const autoAttemptedRef = useRef<boolean>(false)
// Local helper fields for requesting token from backend
const [requestRoom, setRequestRoom] = useState<string>('studio-demo')
const [requestUsername, setRequestUsername] = useState<string>(() => {
try { return (sessionStorage.getItem('username') as string) || 'guest' } catch (e) { return 'guest' }
})
const [isRequestingToken, setIsRequestingToken] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [isFetchingSessionById, setIsFetchingSessionById] = useState(false)
useEffect(() => {
// Try to read token from query param ?token=...
try {
const params = new URLSearchParams(window.location.search)
const qtoken = params.get('token')
const qserver = params.get('serverUrl')
const qroom = params.get('room')
const quser = params.get('username')
if (qserver) setUrl(qserver)
if (qroom) setRequestRoom(qroom)
if (quser) setRequestUsername(quser)
if (qtoken) {
setToken(qtoken)
// Auto-connect immediately using defaultUrl (env) or current url state
setTimeout(() => {
try { connectToLivekit(qserver || defaultUrl || undefined, qtoken) } catch (e) { console.debug('[LivekitConnector] auto connect error', e) }
}, 20)
}
} catch (e) { console.debug('[LivekitConnector] query parse error', e) }
// Detect a sessionId embedded in the path (e.g. /abc1234) and fetch its token from backend
try {
const pathname = window.location.pathname || '/'
const parts = pathname.split('/').filter(Boolean)
if (parts.length === 1) {
const candidate = parts[0]
// session ids generated by backend are alphanumeric short strings (7 chars by default)
if (/^[a-z0-9]{5,20}$/i.test(candidate)) {
// fetch session info from backend
const sessionId = candidate
;(async () => {
try {
setIsFetchingSessionById(true)
setErrorMessage(null)
const resp = await fetch(`${backendBase.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}`, { method: 'GET' })
if (!resp.ok) {
const text = await resp.text()
console.warn('[LivekitConnector] session fetch failed', resp.status, text)
setErrorMessage(`No fue posible obtener la sesión: HTTP ${resp.status}`)
setIsFetchingSessionById(false)
return
}
const data = await resp.json()
if (data?.token) {
if (data.url) setUrl(String(data.url))
if (data.room) setRequestRoom(String(data.room))
if (data.username) setRequestUsername(String(data.username))
setToken(String(data.token))
// Best-effort: mark session as consumed to reduce replay risk
;(async () => {
try {
await fetch(`${backendBase.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/consume`, { method: 'POST' })
console.debug('[LivekitConnector] session consume requested')
} catch (consumeErr) {
console.debug('[LivekitConnector] session consume failed', consumeErr)
}
})()
// auto connect
if (!autoAttemptedRef.current) {
autoAttemptedRef.current = true
setTimeout(() => {
try { connectToLivekit(data.url || defaultUrl, data.token) } catch (err) { console.debug('[LivekitConnector] session fetch connect error', err) }
}, 30)
}
} else {
setErrorMessage('Respuesta inválida al solicitar la sesión')
}
} catch (err) {
console.error('[LivekitConnector] Error fetching session by id', err)
setErrorMessage(`Error al obtener la sesión: ${String(err)}`)
} finally {
setIsFetchingSessionById(false)
}
})()
}
}
} catch (e) { console.debug('[LivekitConnector] path session detection error', e) }
// Listen for messages (broadcast-panel can postMessage the token)
function onMessage(e: MessageEvent) {
if (!e?.data) return
const data = e.data
// Respond to ping from opener / broadcast to indicate app is ready
if (data?.type === 'LIVEKIT_PING') {
try {
// reply to the source with READY so opener can safely send token
if (e.source && typeof (e.source as any).postMessage === 'function') {
try { (e.source as any).postMessage({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch (err) { console.debug('[LivekitConnector] PING reply failed', err) }
} else if (window.opener && typeof (window.opener as any).postMessage === 'function') {
try { (window.opener as any).postMessage({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch (err) { console.debug('[LivekitConnector] PING reply to opener failed', err) }
}
} catch (err) { console.debug('[LivekitConnector] ping handling error', err) }
}
if (data?.type === 'LIVEKIT_TOKEN') {
// Store source/origin and payload so we can ACK back after connect
try {
messageSourceRef.current = e.source || (window.opener || null)
} catch (err) {
messageSourceRef.current = (window.opener || null)
}
messageOriginRef.current = (e.origin as string) || null
lastPayloadRef.current = data
if (data?.url) setUrl(data.url)
if (data?.token) setToken(data.token)
// auto connect if both present (debounced)
if (data.token && (data.url || defaultUrl)) {
if (!autoAttemptedRef.current) {
autoAttemptedRef.current = true
setTimeout(() => {
try { connectToLivekit(data.url || defaultUrl, data.token) } catch (err) { console.debug('[LivekitConnector] postMessage connect error', err) }
}, 30)
} else {
console.debug('[LivekitConnector] auto connect already attempted, ignoring duplicate token')
}
}
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [])
useEffect(() => {
return () => {
if (roomRef.current?.disconnect) {
@ -16,66 +159,148 @@ export function LivekitConnector() {
}
}, [])
async function connectToLivekit() {
// Try auto-connect if token/url become available and we haven't tried yet
useEffect(() => {
if (!autoAttemptedRef.current && token && (url || defaultUrl)) {
autoAttemptedRef.current = true
setTimeout(() => {
try { connectToLivekit(url || defaultUrl, token) } catch (e) { console.debug('[LivekitConnector] auto connect effect error', e) }
}, 50)
}
}, [token, url])
async function requestTokenFromBackend(roomName?: string, username?: string) {
const room = roomName || requestRoom
const user = username || requestUsername
if (!room || !user) return
setIsRequestingToken(true)
setErrorMessage(null)
setStatus('connecting')
try {
const urlReq = `${backendBase.replace(/\/$/, '')}/api/token?room=${encodeURIComponent(room)}&username=${encodeURIComponent(user)}`
const res = await fetch(urlReq, { method: 'GET' })
const data = await res.json()
if (!res.ok) {
const msg = (data && data.error) ? String(data.error) : `HTTP ${res.status}`
console.error('Token request error', msg)
setErrorMessage(`Error obteniendo token: ${msg}`)
setStatus('error')
setIsRequestingToken(false)
return
}
if (data.token) {
if (data.url) setUrl(data.url)
setToken(data.token)
// auto connect
if (!autoAttemptedRef.current) {
autoAttemptedRef.current = true
setTimeout(() => connectToLivekit(data.url || defaultUrl, data.token), 20)
}
setIsRequestingToken(false)
setErrorMessage(null)
return
}
console.error('Unexpected token response', data)
setErrorMessage('Respuesta inesperada del servidor al solicitar token')
setStatus('error')
setIsRequestingToken(false)
} catch (err) {
console.error('Failed requesting token', err)
setErrorMessage(`Fallo al solicitar token: ${String(err)}`)
setStatus('error')
setIsRequestingToken(false)
}
}
async function connectToLivekit(overrideUrl?: string, overrideToken?: string) {
const connectUrl = overrideUrl || url
const connectToken = overrideToken || token
if (!connectUrl || !connectToken) {
setStatus('error')
console.warn('Missing LiveKit URL or token')
return
}
setStatus('connecting')
setParticipants([])
try {
const mod = await import('livekit-client')
const lk: any = (mod as any).default ? (mod as any).default : mod
const connectFn = lk && (lk.connect || lk.createRoom || lk.Room || null)
if (!connectFn) {
setStatus('error')
console.warn('LiveKit client not available (no connect/createRoom)')
return
}
let room: any = null
// Prefer connect(url, token) if available
if (typeof lk.connect === 'function') {
room = await lk.connect(url, token)
} else if (typeof lk.createRoom === 'function') {
room = await lk.createRoom()
if (room && typeof room.connect === 'function') {
await room.connect(url, token)
}
} else if (lk.Room) {
room = new lk.Room()
if (room && typeof room.connect === 'function') {
await room.connect(url, token)
}
}
const room = await lk.connect(connectUrl, connectToken)
roomRef.current = room
setStatus('connected')
if (!room) {
setStatus('error')
console.warn('Could not create or connect to LiveKit Room')
// NOTE: Do not send LIVEKIT_ACK here; App will centralize ACK sending to avoid duplicates.
const updateParticipants = () => {
try {
const parts: any[] = []
if (room.participants && typeof room.participants.values === 'function') {
for (const p of room.participants.values()) {
parts.push({ sid: p.sid, identity: p.identity, participant: p })
}
} else if (Array.isArray(room.participants)) {
for (const p of room.participants) parts.push(p)
}
setParticipants(parts)
} catch (e) {
console.warn('updateParticipants error', e)
}
}
room.on?.('participantConnected', updateParticipants)
room.on?.('participantDisconnected', updateParticipants)
room.on?.('trackPublished', updateParticipants)
updateParticipants()
return
}
roomRef.current = room
setStatus('connected')
// Fallback: try Room class
if (lk.Room) {
const RoomClass = lk.Room
const room = new RoomClass()
if (room && typeof room.connect === 'function') {
await room.connect(connectUrl, connectToken)
roomRef.current = room
setStatus('connected')
const updateParticipants = () => {
try {
const parts: any[] = []
if (room.participants && typeof room.participants.values === 'function') {
for (const p of room.participants.values()) {
parts.push({ sid: p.sid, identity: p.identity, participant: p })
}
} else if (Array.isArray(room.participants)) {
for (const p of room.participants) parts.push(p)
// NOTE: Do not send LIVEKIT_ACK here; App will centralize ACK sending to avoid duplicates.
const updateParticipants = () => {
try {
const parts: any[] = []
if (room.participants && typeof room.participants.values === 'function') {
for (const p of room.participants.values()) parts.push({ sid: p.sid, identity: p.identity, participant: p })
} else if (Array.isArray(room.participants)) {
for (const p of room.participants) parts.push(p)
}
setParticipants(parts)
} catch (e) {}
}
setParticipants(parts)
} catch (e) {
room.on?.('participantConnected', updateParticipants)
room.on?.('participantDisconnected', updateParticipants)
room.on?.('trackPublished', updateParticipants)
updateParticipants()
return
}
}
room.on?.('participantConnected', updateParticipants)
room.on?.('participantDisconnected', updateParticipants)
room.on?.('trackPublished', updateParticipants)
updateParticipants()
setStatus('error')
console.warn('LiveKit client did not expose a known connect API')
} catch (err) {
setStatus('error')
console.error('LiveKit connect error', err)
// NOTE: Do not send LIVEKIT_ACK/error here; App will centralize ACK sending.
}
}
@ -103,6 +328,29 @@ export function LivekitConnector() {
style={{ marginBottom: 'var(--au-spacing-2)' }}
/>
{isFetchingSessionById && (
<div style={{ marginBottom: '8px', color: 'var(--studio-text-secondary)', fontSize: '13px' }}>Obteniendo sesión desde el servidor...</div>
)}
<div style={{ display: 'flex', gap: '8px', marginBottom: 'var(--au-spacing-2)' }}>
<Input
label="Room"
value={requestRoom}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRequestRoom(e.target.value)}
placeholder="Room name"
size="sm"
style={{ flex: 1 }}
/>
<Input
label="Username"
value={requestUsername}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRequestUsername(e.target.value)}
placeholder="Username"
size="sm"
style={{ width: '180px' }}
/>
</div>
<Input
label="Token"
value={token}
@ -115,7 +363,7 @@ export function LivekitConnector() {
/>
<div style={{ display: 'flex', gap: 'var(--au-spacing-2)' }}>
<Button onClick={connectToLivekit} variant="success" size="sm">
<Button onClick={() => connectToLivekit()} variant="success" size="sm">
Conectar
</Button>
<Button
@ -131,8 +379,39 @@ export function LivekitConnector() {
>
Desconectar
</Button>
<Button
onClick={() => requestTokenFromBackend(requestRoom, requestUsername)}
variant="primary"
size="sm"
disabled={isRequestingToken}
>
{isRequestingToken ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<svg width="14" height="14" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<circle cx="25" cy="25" r="20" stroke="rgba(255,255,255,0.4)" strokeWidth="6" />
<path d="M45 25a20 20 0 0 0-20-20" stroke="#fff" strokeWidth="6" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s" repeatCount="indefinite" />
</path>
</svg>
Solicitando...
</span>
) : 'Obtener token'}
</Button>
</div>
{errorMessage && (
<div role="alert" aria-live="assertive" style={{
marginTop: '12px',
padding: '10px',
borderRadius: '6px',
background: 'var(--studio-error-bg, rgba(245, 101, 101, 0.12))',
color: 'var(--studio-error-text, #b91c1c)',
fontSize: '13px'
}}>
{errorMessage}
</div>
)}
<div style={{ marginTop: 'var(--au-spacing-3)' }}>
<div style={{ fontSize: 'var(--au-text-xs)', color: 'var(--studio-text-secondary)' }}>
Estado: <Badge

View File

@ -0,0 +1,16 @@
.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}

View File

@ -0,0 +1,171 @@
import React, { useState, useEffect, useRef } from 'react';
import StudioRoom from '../StudioRoom/StudioRoom';
import './StudioPortal.css';
import { Room } from 'livekit-client';
export interface StudioPortalProps {
serverUrl: string;
token: string;
roomName?: string;
onRoomConnected?: () => void;
onRoomDisconnected?: () => void;
/** optional external LiveKit Room instance */
room?: any;
}
const LAYOUTS = [
{ 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, room }: StudioPortalProps) {
const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id);
const [live, setLive] = useState(false);
// Local room management when App does not provide a room prop
const localRoomRef = useRef<Room | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const isExternalRoom = Boolean(room);
// Connect function used by UI or auto when token arrives
const connectWithToken = async (useToken?: string, useServer?: string) => {
const tk = useToken || token;
const sUrl = useServer || serverUrl;
if (!tk || !sUrl) return;
try {
setIsConnecting(true);
// cleanup previous
if (localRoomRef.current) {
try { localRoomRef.current.disconnect(); } catch(e) {}
localRoomRef.current = null;
}
const r = new Room();
localRoomRef.current = r;
await r.connect(sUrl, tk);
setIsConnected(true);
onRoomConnected && onRoomConnected();
} catch (err) {
console.error('StudioPortal: failed to connect local room', err);
setIsConnected(false);
} finally {
setIsConnecting(false);
}
};
const disconnectLocalRoom = () => {
try {
if (localRoomRef.current) {
localRoomRef.current.disconnect();
localRoomRef.current = null;
}
} catch (e) { /* ignore */ }
setIsConnected(false);
onRoomDisconnected && onRoomDisconnected();
};
// Auto-connect when token becomes available and there is no external room
useEffect(() => {
if (!isExternalRoom && token && token.trim() && !isConnected && !isConnecting) {
connectWithToken(token, serverUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, serverUrl]);
// Cleanup on unmount
useEffect(() => {
return () => {
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' } }));
setLive(true);
};
const handleStopLive = () => {
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); }
};
// Determine which room to pass into StudioRoom: external first, fallback to local
const effectiveRoom = room || localRoomRef.current || undefined;
return (
<div className="studio-portal">
<aside className="studio-portal__left">
<div className="scenes-header">Escenas</div>
<div className="scenes-list">
<div className="scene-item active">Demo scene 1</div>
<div className="scene-item">Demo scene 2</div>
<div className="scene-item">Intro</div>
</div>
<button className="btn-new-scene">+ Nueva escena</button>
</aside>
<main className="studio-portal__center">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ fontSize: 14 }}>
<strong>LiveKit:</strong> {serverUrl}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{!isExternalRoom && (
<>
<button className="btn-small" onClick={() => connectWithToken()} disabled={isConnecting || isConnected}>
{isConnecting ? 'Conectando...' : isConnected ? 'Conectado' : 'Conectar'}
</button>
<button className="btn-small" onClick={disconnectLocalRoom} disabled={!isConnected}>
Desconectar
</button>
</>
)}
{isExternalRoom && (
<div style={{ fontSize: 13, color: '#6b7280' }}>Usando Room externo</div>
)}
</div>
</div>
<div className={`preview-wrapper ${activeLayout}`}>
<StudioRoom serverUrl={serverUrl} token={token} roomName={roomName} onConnected={onRoomConnected} onDisconnected={onRoomDisconnected} room={effectiveRoom} />
</div>
<div className="controls-bar">
<div className="layout-presets">
{LAYOUTS.map(l => (
<button
key={l.id}
className={`layout-btn ${activeLayout === l.id ? 'active' : ''}`}
onClick={() => changeLayout(l.id)}
>
{l.label}
</button>
))}
</div>
<div className="actions">
{!live ? (
<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>
)}
</div>
</div>
</main>
<aside className="studio-portal__right">
<div className="sidebar-section">Comentarios</div>
<div className="sidebar-section">Activos multimedia</div>
<div className="sidebar-section">Estilo</div>
</aside>
</div>
);
}

View File

@ -191,6 +191,35 @@
padding: var(--studio-space-lg);
}
/* Floating bottom controls overrides to mimic StreamYard center placement */
.fixed.bottom-4.left-4.right-4 {
left: 0 !important;
right: 0 !important;
display: flex;
justify-content: center;
pointer-events: none; /* inner controls manage pointer events */
z-index: 1200;
}
.fixed.bottom-4.left-4.right-4 .controls-inner {
pointer-events: auto;
}
/* ensure the record button stands out */
.btn-control--danger {
background: linear-gradient(180deg, var(--studio-recording) 0%, #d43a3a 100%);
border: none;
}
/* smaller screens: place controls inset to the right */
@media (max-width: 480px) {
.fixed.bottom-4.left-4.right-4 {
left: 12px !important;
right: 12px !important;
justify-content: flex-end;
}
}
/* Responsive */
@media (max-width: 768px) {
.studio-room__header {
@ -214,3 +243,62 @@
}
}
/* Layout presets applied via .studio-room[data-layout="..."] */
/* Layout 1: large single speaker */
.studio-room[data-layout="layout-1"] .lk-grid-layout {
display: flex !important;
align-items: center;
justify-content: center;
}
.studio-room[data-layout="layout-1"] .lk-participant-tile {
width: 80% !important;
height: 80% !important;
max-width: 1200px;
}
/* Layout 2: gallery (4-up) */
.studio-room[data-layout="layout-2"] .lk-grid-layout {
display: flex !important;
flex-wrap: wrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
gap: 12px !important;
}
.studio-room[data-layout="layout-2"] .lk-participant-tile {
flex: 0 0 calc(25% - 12px) !important;
height: calc(50% - 12px) !important;
max-width: none !important;
}
/* Layout 3: speaker + row of participants */
.studio-room[data-layout="layout-3"] .lk-grid-layout {
display: grid !important;
grid-template-columns: 1fr 320px !important;
gap: 12px !important;
}
.studio-room[data-layout="layout-3"] .lk-participant-tile:first-of-type {
grid-column: 1 / 2 !important;
width: 100% !important;
}
.studio-room[data-layout="layout-3"] .lk-participant-tile:not(:first-of-type) {
grid-column: 2 / 3 !important;
width: 100% !important;
height: auto !important;
}
/* Layout 4: wide presenter with sidebar */
.studio-room[data-layout="layout-4"] .lk-grid-layout {
display: grid !important;
grid-template-columns: 1fr 280px !important;
gap: 12px !important;
}
.studio-room[data-layout="layout-4"] .lk-participant-tile {
width: 100% !important;
height: 100% !important;
}
/* Fallback small adjustments to ensure responsive behavior */
.studio-room[data-layout] .lk-participant-tile {
transition: transform 220ms ease, width 220ms ease, height 220ms ease;
}

View File

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react';
import {
LiveKitRoom,
GridLayout,
ParticipantTile,
ControlBar,
@ -12,6 +11,7 @@ import { Room, Track } from 'livekit-client';
import '@livekit/components-styles';
import { Button } from 'avanza-ui';
import './StudioRoom.css';
import BottomControls from '../BottomControls';
export interface StudioRoomProps {
/** LiveKit server URL */
@ -24,6 +24,8 @@ export interface StudioRoomProps {
onConnected?: () => void;
/** Callback when room is disconnected */
onDisconnected?: () => void;
/** Optional externally-created LiveKit Room instance */
room?: Room;
}
export const StudioRoom: React.FC<StudioRoomProps> = ({
@ -32,36 +34,235 @@ export const StudioRoom: React.FC<StudioRoomProps> = ({
roomName,
onConnected,
onDisconnected,
room: externalRoom,
}) => {
const [room] = useState(
() =>
new Room({
adaptiveStream: true,
dynacast: true,
})
// If an external Room is provided, use it; otherwise create an internal Room instance
const [internalRoom] = useState(() => new Room({ adaptiveStream: true, dynacast: true }));
const room = externalRoom || internalRoom;
const isExternalRoom = !!externalRoom;
const [connectError, setConnectError] = useState<string | null>(null);
const [participantsList, setParticipantsList] = useState<Array<{ sid: string; identity: string; isLocal?: boolean; accepted?: boolean }>>([]);
const connectedRef = React.useRef(false);
const connectingRef = React.useRef(false);
const previewRef = React.useRef<HTMLDivElement | null>(null);
const [lines, setLines] = useState<Array<{ x1:number,y1:number,x2:number,y2:number, accepted?: boolean }>>([]);
// connectRoom: reusable connect logic for initial attempt and retries
const connectRoom = React.useCallback(
async (attemptToken?: string, attemptServer?: string) => {
if (connectingRef.current) return;
connectingRef.current = true;
setConnectError(null);
try {
const sUrl = attemptServer || serverUrl;
const tk = attemptToken || token;
if (!sUrl || !tk) {
// Avoid throwing inside this catch-all to keep analyzer happy; set error and return
setConnectError('Missing serverUrl or token');
return;
}
// Only call connect if this component owns the room (internal)
if (!isExternalRoom && (room as Room).connect) {
await (room as Room).connect(sUrl, tk);
}
connectedRef.current = true;
setConnectError(null);
onConnected?.();
} catch (err: any) {
console.error('StudioRoom connect failed', err);
setConnectError(String(err?.message || err || 'Connection failed'));
} finally {
connectingRef.current = false;
}
},
[/* room purposely omitted to avoid re-creating callback */, serverUrl, token, onConnected, isExternalRoom]
);
useEffect(() => {
let mounted = true;
const connect = async () => {
if (mounted) {
await room.connect(serverUrl, token);
// Attempt initial connect once when mounted
(async () => {
if (!mounted) return;
// If we're using an internal room, attempt initial connect
if (!isExternalRoom) await connectRoom();
else {
// If external room is already connected, notify parent
try { if ((room as any)?.state === 'connected' || (room as any)?.isConnected) { connectedRef.current = true; onConnected?.(); } } catch(e){}
}
})();
// If token or serverUrl changes after mount, attempt to connect (useful when token is injected later)
// No polling here: connection will be attempted on mount, and further
// attempts are triggered by an effect that watches `token`/`serverUrl`.
return () => {
mounted = false;
try { (room as any).off && (room as any).off('dataReceived', onDataReceived); } catch(e){}
try { if (onAcceptReceived) { (room as any).off && (room as any).off('dataReceived', onAcceptReceived as any); } } catch(e){}
try {
// Only disconnect if we actually connected
if (!isExternalRoom && connectedRef.current && room.disconnect) {
(room as Room).disconnect();
}
} catch (e) { /* ignore */ }
onDisconnected?.();
// poll removed
};
}, [room, connectRoom, onDisconnected]);
// Reactively attempt to connect whenever token or serverUrl changes
useEffect(() => {
try {
if (connectedRef.current) return; // already connected
if (!connectingRef.current && (token && token.trim()) && (serverUrl && serverUrl.trim())) {
// attempt connection with the latest props
connectRoom(token, serverUrl);
}
} catch (e) { console.warn('reactive connect attempt failed', e); }
}, [token, serverUrl, connectRoom]);
// Notify parent when the room actually becomes connected
useEffect(() => {
// Poll connectedRef to know when it's set by connectRoom
const t = setInterval(() => {
if (connectedRef.current) {
onConnected?.();
clearInterval(t);
}
}, 250);
return () => clearInterval(t);
}, [onConnected]);
// If using external room, notify parent when it becomes connected
useEffect(() => {
if (!isExternalRoom) return;
const checkInterval = setInterval(() => {
try {
if ((room as any)?.state === 'connected' || (room as any)?.isConnected) {
connectedRef.current = true;
onConnected?.();
clearInterval(checkInterval);
}
} catch(e){}
}, 250);
return () => clearInterval(checkInterval);
}, [isExternalRoom, room, onConnected]);
// Auto-start camera, mic, and "recording" when connected
useEffect(() => {
if (!connectedRef.current) return;
const autoStart = async () => {
try {
const lp = room.localParticipant;
if (!lp) return;
// Auto-enable camera
try {
await lp.setCameraEnabled(true);
console.log('Auto-enabled camera');
} catch (e) {
console.warn('Failed to auto-enable camera:', e);
}
// Auto-enable microphone
try {
await lp.setMicrophoneEnabled(true);
console.log('Auto-enabled microphone');
} catch (e) {
console.warn('Failed to auto-enable microphone:', e);
}
// NOTE: removed automatic recording/start signal per request (focus on transmission only)
} catch (e) {
console.warn('Auto-start failed:', e);
}
};
connect();
// Small delay to ensure room is fully ready
const timer = setTimeout(autoStart, 1000);
return () => clearTimeout(timer);
}, [room]);
useEffect(() => {
// layout change listener: apply data-layout attribute to root element
function onLayoutChange(e: any) {
try {
const layoutId = e?.detail?.layoutId;
const root = document.querySelector('.studio-room');
if (root && layoutId) {
(root as HTMLElement).setAttribute('data-layout', String(layoutId));
console.log('Applied layout', layoutId);
}
} catch (err) { console.warn('layout change handler error', err) }
}
window.addEventListener('avz:layout:change', onLayoutChange as EventListener);
return () => {
mounted = false;
room.disconnect();
onDisconnected?.();
window.removeEventListener('avz:layout:change', onLayoutChange as EventListener);
};
}, [room, serverUrl, token, onConnected, onDisconnected]);
}, []);
// Recalculate overlay lines between moderator (local) and guests
React.useEffect(() => {
function computeLines(){
try{
const container = previewRef.current;
if (!container) return setLines([]);
const rootRect = container.getBoundingClientRect();
// find moderator tile by identity
const localIdentity = room.localParticipant?.identity;
let moderatorEl: Element | null = null;
if (localIdentity) {
moderatorEl = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent || '').trim() === localIdentity) as Element || null;
}
// fallback: try first participant tile inside container
if (!moderatorEl) moderatorEl = container.querySelector('.lk-participant-tile');
if (!moderatorEl) return setLines([]);
const mRect = (moderatorEl as HTMLElement).getBoundingClientRect();
const mx = mRect.left + mRect.width/2 - rootRect.left;
const my = mRect.top + mRect.height/2 - rootRect.top;
const newLines: Array<{x1:number,y1:number,x2:number,y2:number, accepted?: boolean}> = [];
// for each participant (excluding local), find tile by name and create line
participantsList.forEach(p => {
if (p.isLocal) return;
const el = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent||'').trim() === p.identity) as Element | undefined;
if (!el) return;
const tRect = (el as HTMLElement).closest('.lk-participant-tile')?.getBoundingClientRect();
if (!tRect) return;
const tx = tRect.left + tRect.width/2 - rootRect.left;
const ty = tRect.top + tRect.height/2 - rootRect.top;
newLines.push({ x1: mx, y1: my, x2: tx, y2: ty, accepted: !!p.accepted });
});
setLines(newLines);
}catch(e){ console.warn('computeLines error', e); setLines([]) }
}
computeLines();
const ro = new ResizeObserver(()=> computeLines());
if (previewRef.current) ro.observe(previewRef.current);
window.addEventListener('resize', computeLines);
const interval = setInterval(computeLines, 1200);
return ()=>{ ro.disconnect(); window.removeEventListener('resize', computeLines); clearInterval(interval); };
}, [participantsList, room]);
return (
<div className="studio-room studio-theme" data-lk-theme="default">
{connectError && (
<div style={{ padding: 12, background: 'rgba(245, 101, 101, 0.08)', color: '#b91c1c', borderRadius: 6, margin: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Error al conectar a LiveKit</div>
<div style={{ fontSize: 13, marginBottom: 8 }}>{connectError}</div>
<div style={{ fontSize: 13, marginBottom: 8 }}>Server: <code style={{ background:'rgba(0,0,0,0.04)', padding:'2px 6px', borderRadius:4 }}>{serverUrl}</code></div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => connectRoom(token, serverUrl)} style={{ padding: '8px 12px', borderRadius:6, border:'1px solid rgba(0,0,0,0.08)', background:'#fff' }}>Reintentar conexión</button>
<button onClick={() => { try { navigator.clipboard.writeText(token || ''); } catch(e){} }} style={{ padding: '8px 12px', borderRadius:6, border:'1px solid rgba(0,0,0,0.08)', background:'#fff' }}>Copiar token</button>
</div>
</div>
)}
<RoomContext.Provider value={room}>
<div className="studio-room__header">
<div className="studio-room__title">
@ -82,13 +283,24 @@ export const StudioRoom: React.FC<StudioRoomProps> = ({
</div>
<div className="studio-room__content">
<VideoConferenceView />
<div ref={previewRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
<VideoConferenceView />
{/* SVG overlay for connection lines */}
<svg className="connections-overlay" style={{ position: 'absolute', left:0, top:0, width: '100%', height: '100%', pointerEvents: 'none' }}>
{lines.map((ln,i)=>(
<line key={i} x1={ln.x1} y1={ln.y1} x2={ln.x2} y2={ln.y2} stroke={ln.accepted ? '#34D399' : '#60A5FA'} strokeWidth={2} strokeOpacity={0.95} />
))}
</svg>
</div>
</div>
<div className="studio-room__controls">
<ControlBar />
</div>
{/* Our BottomControls will consume RoomContext and control mic/cam/recording */}
<BottomControls />
<RoomAudioRenderer />
</RoomContext.Provider>
</div>

View File

@ -1,14 +1,105 @@
import './styles/globals.css';
import './styles.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import 'avanza-ui/dist/studio-theme.css'; // Importar estilos del tema
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
(async function bootstrap() {
const skipProblematic = Boolean(import.meta.env.VITE_DEBUG_SKIP_PROB_IMPORTS);
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Robust fallback: try to ensure studio-theme.css is present by adding a link tag resolved against this module
try {
const themeUrl = new URL('../../avanza-ui/src/styles/studio-theme.css', import.meta.url).toString();
// only add if not already present
if (!document.querySelector(`link[href="${themeUrl}"]`)) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = themeUrl;
l.crossOrigin = 'anonymous';
document.head.appendChild(l);
}
} catch (e) {
console.warn('Could not add link fallback for studio-theme.css:', (e as any)?.message || String(e));
}
if (!skipProblematic) {
// Try to import shared styles (may fail if missing dependencies)
// NOTE: Importing broadcast-panel styles brings `@tailwind` directives which force PostCSS
// to load tailwind plugin. We only import it when explicitly requested via env var.
const importBroadcast = Boolean(import.meta.env.VITE_IMPORT_BROADCAST_STYLES);
if (importBroadcast) {
try {
await import('../../broadcast-panel/src/styles.css');
} catch (e) {
console.warn('Failed to import broadcast-panel styles:', (e as any)?.message || String(e));
}
}
try {
await import('@livekit/components-styles');
} catch (e) {
console.warn('Failed to import @livekit/components-styles:', (e as any)?.message || String(e));
}
}
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
})();
// Small postMessage handshake helper so the SPA can interoperate with post_token_to_studio.html
// - respond to LIVEKIT_PING with LIVEKIT_READY
// - accept LIVEKIT_TOKEN and store it on window, then respond with LIVEKIT_ACK
(function setupPostMessageHandshake(){
try {
// ensure we only add once
if ((window as any).__AVZ_POSTMESSAGE_SETUP) return;
(window as any).__AVZ_POSTMESSAGE_SETUP = true;
// If token present in query params, expose immediately for app to pick up
try {
const p = new URLSearchParams(window.location.search);
const qtoken = p.get('token');
const qroom = p.get('room') || '';
const quser = p.get('username') || '';
const qurl = p.get('serverUrl') || p.get('serverurl') || '';
if (qtoken) {
(window as any).__AVANZACAST_PENDING_TOKEN = { token: qtoken, room: qroom, username: quser, url: qurl };
try { window.dispatchEvent(new CustomEvent('avz:livekit:token', { detail: (window as any).__AVANZACAST_PENDING_TOKEN })); } catch(e){}
}
} catch(e) {}
window.addEventListener('message', (e: MessageEvent) => {
try {
const d = e.data || {};
if (d && typeof d === 'object') {
// ping -> ready
if (d.type === 'LIVEKIT_PING') {
try { (e.source as any)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*'); } catch (err) {}
return;
}
// token received -> store and ack
if (d.type === 'LIVEKIT_TOKEN') {
try {
// store pending token for the app to pick up
(window as any).__AVANZACAST_PENDING_TOKEN = { token: d.token, room: d.room, username: d.username, url: d.url };
// also store the source/origin so the App can ACK directly to it
try { (window as any).__AVZ_LAST_MSG_SOURCE = { origin: e.origin || null, source: e.source || null }; } catch (err) { (window as any).__AVZ_LAST_MSG_SOURCE = null; }
} catch(e){}
// Do NOT send a postMessage ACK here. ACKs are centralized in the React App
// to avoid duplicated acknowledgements back to the sender. The App will listen
// for the 'avz:livekit:token' event and send any required ACK.
try { window.dispatchEvent(new CustomEvent('avz:livekit:token', { detail: (window as any).__AVANZACAST_PENDING_TOKEN })); } catch(e){}
return;
}
}
} catch (err) { /* ignore */ }
}, false);
} catch (err) { /* ignore */ }
})();

View File

@ -1,6 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './styles/avanza-utilities.css';
/* Import avanza-ui theme (standalone CSS in packages/avanza-ui) */
@import '../../avanza-ui/src/styles/globals.css';
@import '../../avanza-ui/src/styles/studio-theme.css';
/* small design tokens to match AvanzaCast palette */
:root{
@ -10,4 +12,3 @@
.bg-surface-50{ background-color: var(--surface-50); }
.dark .bg-surface-900{ background-color: var(--surface-900); }

View File

@ -0,0 +1,272 @@
/* avanza-utilities.css
Minimal utility set that mimics the Tailwind classes used in studio-panel.
This lets studio-panel avoid depending on Tailwind at runtime while keeping a
similar look-and-feel compatible with avanza-ui styles.
*/
:root{
--gap-3: 0.75rem; /* 12px */
--p-2: 0.5rem; /* 8px */
--px-4: 1rem; /* 16px */
--py-3: 0.75rem; /* 12px */
--rounded-md: 0.5rem; /* 8px - slightly larger */
--rounded-full: 9999px;
--w-10: 2.5rem; /* 40px */
--h-10: 2.5rem;
--w-12: 3rem; /* 48px */
--h-12: 3rem;
--bg-black-70: rgba(0,0,0,0.7);
--bg-white-10: rgba(255,255,255,0.08);
--text-gray-400: #9ca3af;
--bg-red-600: #e11d48; /* slightly brighter */
--text-white: #ffffff;
/* streamyard-like tokens */
--studio-control-size: 44px;
--studio-control-gap: 12px;
--studio-shadow: 0 6px 18px rgba(15,23,42,0.12);
}
/* layout */
.flex{display:flex}
.flex-col{flex-direction:column}
.items-center{align-items:center}
.justify-center{justify-content:center}
.gap-3{gap:var(--gap-3)}
/* spacing */
.p-2{padding:var(--p-2)}
.px-4{padding-left:var(--px-4);padding-right:var(--px-4)}
.py-3{padding-top:var(--py-3);padding-bottom:var(--py-3)}
/* sizes */
.w-10{width:var(--w-10)}
.h-10{height:var(--h-10)}
.w-12{width:var(--w-12)}
.h-12{height:var(--h-12)}
.flex-1{flex:1 1 0}
/* rounded */
.rounded-md{border-radius:var(--rounded-md)}
.rounded-full{border-radius:var(--rounded-full)}
/* text */
.font-semibold{font-weight:600}
.font-medium{font-weight:500}
.text-xs{font-size:0.75rem}
.text-gray-400{color:var(--text-gray-400)}
.mb-3{margin-bottom:0.75rem}
/* spacing helpers used by bottom control */
.fixed{position:fixed}
.bottom-4{bottom:1rem}
.left-4{left:1rem}
.right-4{right:1rem}
/* background helpers - we must handle classes that contain slashes (e.g. bg-black/70)
using attribute selectors so we don't need to change component markup.
*/
[class~="bg-black/70"]{background-color:var(--bg-black-70)}
[class~="bg-white/10"]{background-color:var(--bg-white-10)}
/* surface tokens - these mirror variables used across project */
.bg-surface-50{background-color:var(--surface-50)}
.dark .bg-surface-900{background-color:var(--surface-900)}
/* specific colors */
.bg-red-600{background-color:var(--bg-red-600)}
.text-white{color:var(--text-white)}
/* utilities for lists and spacing */
.space-y-3 > * + *{margin-top:var(--gap-3)}
/* small card/video grid helpers */
.video-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}
/* lower-third and studio specific helpers */
.lower-third{background:linear-gradient(90deg, rgba(0,0,0,0.65), rgba(0,0,0,0.55));color:white;padding:10px;border-radius:8px;box-shadow:var(--studio-shadow)}
.lower-third-title{font-weight:600}
.lower-third-subtitle{font-size:0.85rem;opacity:0.9}
/* debug helpers */
.bg-debug{outline:1px dashed rgba(0,0,0,0.08)}
/* ensure buttons visually match Tailwind defaults used in components */
button{font-family:inherit}
/* control buttons */
.btn-control{
width:var(--studio-control-size);
height:var(--studio-control-size);
border-radius:9999px;
display:inline-flex;
align-items:center;
justify-content:center;
background:var(--bg-white-10, rgba(255,255,255,0.08));
color:var(--text-white);
border:1px solid rgba(255,255,255,0.04);
transition: transform 120ms ease, background 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
cursor:pointer;
}
.btn-control:hover{
transform: translateY(-3px);
box-shadow: var(--studio-shadow, 0 6px 18px rgba(15,23,42,0.12));
}
.btn-control:active{
transform: translateY(0);
}
.btn-control:focus{
outline: 2px solid rgba(79,70,229,0.24);
outline-offset: 3px;
}
.btn-control[aria-pressed="true"]{
opacity:0.95;
transform: scale(0.98);
}
.btn-control--danger{
background: var(--studio-recording, #ef4444);
color: var(--text-white);
border-color: rgba(0,0,0,0.12);
}
.btn-control--danger:hover{ transform: translateY(-3px) scale(1.02); }
/* recording dot */
.btn-control--danger .record-dot{
position:absolute;
top:-6px;
right:-6px;
width:12px;
height:12px;
border-radius:50%;
background: var(--studio-recording, #ef4444);
box-shadow: 0 2px 8px rgba(225,29,72,0.45);
animation: pulse-record 1.1s infinite ease-in-out;
}
@keyframes pulse-record{
0%{ transform: scale(1); opacity:1 }
50%{ transform: scale(1.5); opacity:0.5 }
100%{ transform: scale(1); opacity:1 }
}
/* Tooltip using data-tooltip attr */
[data-tooltip]{ position: relative; }
[data-tooltip]::after{
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
background: rgba(20,20,20,0.9);
color:white;
padding:6px 8px;
border-radius:6px;
font-size:12px;
white-space:nowrap;
opacity:0;
pointer-events:none;
transition: opacity 120ms ease, transform 120ms ease;
z-index:9999;
}
[data-tooltip]:hover::after,
[data-tooltip]:focus::after{
opacity:1;
transform: translateX(-50%) translateY(0);
}
/* Accessible tooltip element (used with aria-describedby). Placed next to .btn-control */
.control-wrapper{ position: relative; display:inline-flex; align-items:center; }
.tooltip{
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(6px);
background: rgba(20,20,20,0.95);
color: #fff;
padding:6px 10px;
border-radius:8px;
font-size:12px;
white-space:nowrap;
opacity:0;
pointer-events:none;
transition: opacity 140ms ease, transform 140ms ease;
z-index:9999;
box-shadow: 0 6px 18px rgba(2,6,23,0.35);
}
/* Show tooltip when the previous button is hovered or focused (keyboard) */
.control-wrapper:focus-within .tooltip,
.control-wrapper:hover .tooltip{
opacity:1;
transform: translateX(-50%) translateY(0);
pointer-events:auto;
}
/* Visually-hidden helper for screen readers */
.visually-hidden{
position: absolute !important;
height: 1px; width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap; /* added line */
}
/* Disabled state for controls */
.btn-control[disabled]{
opacity:0.45;
cursor:not-allowed;
transform:none;
pointer-events:none;
}
/* Focus-visible (more explicit keyboard focus) */
.btn-control:focus-visible{
outline: 3px solid rgba(79,70,229,0.22);
outline-offset: 4px;
}
/* Respect user motion preferences */
@media (prefers-reduced-motion: reduce){
.btn-control,
.btn-control:hover,
.btn-control:active,
.btn-control:focus{
transition: none !important;
transform: none !important;
animation: none !important;
}
.tooltip{ transition: none !important }
}
/* positioning helper for container to allow absolute children */
.controls-inner{ position: relative; display:flex; align-items:center }
/* Improve recording visuals: ring behind the button and stronger dot pulse */
.btn-control{ position: relative; z-index: 1; }
.btn-control.recording::after{
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.9);
width: calc(var(--studio-control-size) + 18px);
height: calc(var(--studio-control-size) + 18px);
border-radius: 9999px;
background: rgba(239,68,68,0.12);
z-index: 0;
pointer-events: none;
animation: ring-record 1600ms ease-out infinite;
}
@keyframes ring-record{
0%{ transform: translate(-50%, -50%) scale(0.9); opacity: 0.9 }
60%{ transform: translate(-50%, -50%) scale(1.4); opacity: 0.14 }
100%{ transform: translate(-50%, -50%) scale(1.6); opacity: 0 }
}

View File

@ -0,0 +1,39 @@
// Utilities for postMessage origin validation and ACK helpers
export function getAllowedOriginsFromEnv(): string[] {
const allowed = new Set<string>();
try {
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));
}
} catch (e) { /* ignore */ }
try {
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 */ }
try { allowed.add(window.location.origin); } catch (e) {}
return Array.from(allowed);
}
export function isAllowedOrigin(origin: string | null | undefined): boolean {
if (!origin) return false;
const list = getAllowedOriginsFromEnv();
return list.includes(origin);
}
export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) {
if (!target) return false;
try {
target.postMessage(message, targetOrigin);
return true;
} catch (e) {
// some window proxies can throw when cross-origin; ignore
return false;
}
}

View File

@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
// Production domains (override with env vars if needed)
const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/post_token_to_studio.html?auto=1';
const STUDIO_ORIGIN = process.env.STUDIO_ORIGIN || 'https://avanzacast-studio.bfzqqk.easypanel.host';
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
test('broadcast -> token -> studio flow (production domains)', async ({ browser }) => {
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
// Navigate to broadcast simulator / page that triggers token creation
await page.goto(BROADCAST_URL, { waitUntil: 'networkidle' });
// Try to trigger flow: either the page auto-runs (auto=1), opens a popup, or requires a click
// Wait for a new page with studio origin (popup) for up to 12s
let studioPage = null as (import('@playwright/test').Page | null);
try {
const popupPromise = context.waitForEvent('page', { timeout: 12000 });
// If there's a visible button, click it (best-effort)
const openButton = page.locator('text=Open Studio and Send Token, text=Entrar al estudio, text=Open Studio');
if (await openButton.count() > 0) {
try { await openButton.first().click({ timeout: 3000 }); } catch (_) { /* ignore */ }
}
// Wait for popup
studioPage = await popupPromise;
} catch (e) {
// popup not opened — maybe the page redirected the same tab
}
// If the broadcast redirected the current page to studio origin
if (!studioPage && page.url().startsWith(STUDIO_ORIGIN)) {
studioPage = page;
}
// Fallback: request a session from token server and open the redirectUrl directly
if (!studioPage) {
try {
const resp = await page.request.post(`${TOKEN_SERVER}/api/session`, {
data: { room: 'studio-demo', username: 'playwright-e2e' },
timeout: 15000,
});
const json = await resp.json();
const redirectUrl = json?.redirectUrl;
if (redirectUrl) {
studioPage = await context.newPage();
await studioPage.goto(redirectUrl, { waitUntil: 'networkidle' });
}
} catch (err) {
// ignore — we'll assert later
}
}
expect(studioPage, 'Studio page should be opened (popup or redirect or fallback)')
.not.toBeNull();
// Wait for receiver status element and assert token received
const status = studioPage!.locator('#status');
await expect(status).toBeVisible({ timeout: 10000 });
const txt = await status.innerText();
expect(txt).toMatch(/Token recibido|Token recibido \(query\)|Token received/i);
// Save screenshot for debugging / CI
await studioPage!.screenshot({ path: '/tmp/e2e_studio_received.png', fullPage: true });
await context.close();
});

View File

@ -2,6 +2,16 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// Derive HMR host from environment if available (VITE_STUDIO_URL set in broadcast-panel .env)
const studioUrl = process.env.VITE_STUDIO_URL || process.env.STUDIO_URL || 'https://avanzacast-studio.bfzqqk.easypanel.host'
let hmrHost = 'avanzacast-studio.bfzqqk.easypanel.host'
try {
const u = new URL(studioUrl)
hmrHost = u.hostname
} catch (e) {
// ignore, fallback kept
}
export default defineConfig({
plugins: [react()],
resolve: {
@ -10,8 +20,17 @@ export default defineConfig({
},
},
server: {
port: 3001,
host: '0.0.0.0', // allow access from network / external proxy
port: 3020,
strictPort: false,
hmr: {
protocol: 'wss',
host: hmrHost,
clientPort: 443,
},
},
preview: {
host: '0.0.0.0',
port: 3020,
},
});

View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
resolve: {
alias: {
'avanza-ui': path.resolve(__dirname, '../avanza-ui/src'),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
include: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**/*.ts', 'src/**/__tests__/**/*.tsx']
}
})

71
scripts/check_cors.sh Executable file
View File

@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
OUT_DIR=".deploy_out"
mkdir -p "$OUT_DIR"
TS=$(date +%s)
OUT="${OUT_DIR}/cors_check_${TS}.log"
echo "Starting CORS check - output -> $OUT"
exec &> >(tee "$OUT")
BASE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$BASE_DIR/packages/backend-api"
# stop previous backend if recorded
if [ -f /tmp/backend_api_pid.txt ]; then
PID=$(cat /tmp/backend_api_pid.txt) || true
if [ -n "$PID" ] && ps -p "$PID" > /dev/null 2>&1; then
echo "Stopping previous backend (pid=$PID)"
kill "$PID" || kill -9 "$PID" || true
sleep 1
fi
rm -f /tmp/backend_api_pid.txt
fi
# move old logs
[ -f /tmp/backend_api_run.log ] && mv /tmp/backend_api_run.log /tmp/backend_api_run.log.bak || true
# source .env.production if exists (do not leak secrets)
if [ -f ./.env.production ]; then
echo "Sourcing ./.env.production"
set -a
# shellcheck disable=SC1090
. ./.env.production
set +a
else
echo "No ./.env.production found in packages/backend-api — ensure env vars are set if needed"
fi
# Start backend with ALLOW_ALL_CORS=1 for debugging (background)
echo "Starting backend with ALLOW_ALL_CORS=1 (debug mode)"
nohup env ALLOW_ALL_CORS=1 npx tsx src/index.ts > /tmp/backend_api_run.log 2>&1 &
echo $! > /tmp/backend_api_pid.txt
sleep 2
echo "--- /tmp/backend_api_run.log (head) ---"
head -n 200 /tmp/backend_api_run.log || true
BROADCAST_ORIGIN="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host"
TOKEN_URL="http://localhost:4000/api/token?room=studio-demo&username=simulator"
echo "--- Curl test against $TOKEN_URL with Origin: $BROADCAST_ORIGIN ---"
curl -i -s -H "Origin: $BROADCAST_ORIGIN" "$TOKEN_URL" | sed -n '1,200p' || true
echo "\n--- backend log (tail) ---"
tail -n 200 /tmp/backend_api_run.log || true
# show PID info
if [ -f /tmp/backend_api_pid.txt ]; then
echo "Backend PID: $(cat /tmp/backend_api_pid.txt)"
ps -p $(cat /tmp/backend_api_pid.txt) -o pid,ppid,cmd || true
fi
# show listening sockets
echo "\n--- Listening sockets for :4000 ---"
ss -ltnp | rg 4000 || true
cat "$OUT"
echo "CORS check finished. Log saved at $OUT"

83
scripts/restart_backend_prod.sh Executable file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -euo pipefail
# restart_backend_prod.sh
# Usage:
# Run this on the production host where the repository is deployed.
# ./restart_backend_prod.sh [mode]
# Modes:
# docker - use docker compose to recreate backend-api (default)
# node - restart node process started with npx tsx
# systemd - restart systemd service named backend-api
#
MODE=${1:-docker}
COMPOSE_FILE=${COMPOSE_FILE:-/home/xesar/Documentos/Nextream/AvanzaCast/docker-compose.prod.yml}
REPO_DIR=${REPO_DIR:-/home/xesar/Documentos/Nextream/AvanzaCast}
echo "--> Restart backend-api (mode=$MODE)"
if [ "$MODE" = "docker" ]; then
echo "Using docker-compose file: $COMPOSE_FILE"
if ! command -v docker >/dev/null 2>&1; then
echo "Docker not found on PATH; cannot continue in docker mode" >&2
exit 2
fi
cd "$(dirname "$COMPOSE_FILE")" || cd "$REPO_DIR" || true
echo "Recreating backend-api service (no deps, force recreate)"
docker compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate backend-api
echo "Waiting 3s for startup..."
sleep 3
echo "--- docker compose ps backend-api ---"
docker compose -f "$COMPOSE_FILE" ps backend-api || true
echo "--- docker ps (filter backend-api) ---"
docker ps --filter name=backend-api || true
echo "--- backend-api logs (last 200 lines) ---"
docker compose -f "$COMPOSE_FILE" logs --tail=200 backend-api || true
elif [ "$MODE" = "node" ]; then
echo "Restarting node process (npx tsx)..."
# This assumes the process was started with a matching pattern
pkill -f 'npx tsx src/index.ts' || true
sleep 1
cd "$REPO_DIR/packages/backend-api" || true
# Start in background (modify as needed for your env)
nohup npx tsx src/index.ts > /tmp/backend_api_run.log 2>&1 &
echo $! > /tmp/backend_api_pid.txt
sleep 2
tail -n 200 /tmp/backend_api_run.log || true
elif [ "$MODE" = "systemd" ]; then
echo "Restarting systemd service: backend-api"
sudo systemctl restart backend-api.service
sudo journalctl -u backend-api.service -n 200 --no-pager || true
else
echo "Unknown mode: $MODE" >&2
exit 3
fi
# Basic health checks (local)
echo "\n--- Local health check: http://localhost:4000/health ---"
if command -v curl >/dev/null 2>&1; then
curl -sS http://localhost:4000/health || echo "Local health check failed or endpoint not reachable"
else
echo "curl not available to run health check"
fi
echo "\n--- CORS quick check (OPTIONS against production token-server) ---"
PROD_HOST=${PROD_HOST:-https://avanzacast-servertokens.bfzqqk.easypanel.host}
if command -v curl >/dev/null 2>&1; then
curl -i -X OPTIONS "$PROD_HOST/api/session" \
-H 'Origin: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' \
-H 'Access-Control-Request-Method: POST' | sed -n '1,200p'
else
echo "curl not available to run CORS check"
fi
cat <<'EOF'
Done. If you ran this on the production host, please verify:
- The /health endpoint responds 200
- The OPTIONS call returns Access-Control-Allow-Origin header for the broadcast domain
- A test POST to /api/session returns JSON with redirectUrl
To revert a temporary ALLOW_ALL_CORS=1 deployment, restart the service without that env var, and ensure FRONTEND_URLS is set in the production environment.
EOF

34
scripts/smoke_studio_session.sh Executable file
View File

@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Smoke test: crea una sesión en backend-api y abre el studio URL en el navegador (Linux: xdg-open)
# Usage: ./scripts/smoke_studio_session.sh <room> [username]
set -euo pipefail
ROOM=${1:-smoke-test}
USERNAME=${2:-smoke-user}
BACKEND=${BACKEND_API:-http://localhost:4000}
echo "Creating session for room=$ROOM username=$USERNAME via ${BACKEND}/api/session"
resp=$(curl -s -X POST -H "Content-Type: application/json" -d "{\"room\": \"${ROOM}\", \"username\": \"${USERNAME}\"}" "${BACKEND}/api/session")
if [ -z "$resp" ]; then
echo "No response from backend"
exit 1
fi
echo "Response: $resp"
studioUrl=$(echo "$resp" | python3 -c "import sys, json; print(json.load(sys.stdin).get('studioUrl',''))")
if [ -z "$studioUrl" ]; then
echo "studioUrl not found in response"
exit 1
fi
echo "Opening studio URL: $studioUrl"
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "$studioUrl"
elif command -v gnome-open >/dev/null 2>&1; then
gnome-open "$studioUrl"
else
echo "Open this URL in your browser: $studioUrl"
fi
echo "Smoke test completed (manual verification required: studio should auto-fetch token and connect)."