feat: add form components including Checkbox, Radio, Switch, and Textarea
This commit is contained in:
parent
91a09df7ab
commit
f5d0051a19
5
.env.production.local
Normal file
5
.env.production.local
Normal 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
59
.github/workflows/e2e-playwright.yml
vendored
Normal 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
72
DEPLOY_PROD.md
Normal 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.
|
||||
|
||||
|
||||
16
deploy/avanzacast-stack.service
Normal file
16
deploy/avanzacast-stack.service
Normal 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
91
deploy/deploy_prod.sh
Normal 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
80
docker-compose.prod.yml
Normal 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
|
||||
12
docker/local-nginx/docker-compose.yml
Normal file
12
docker/local-nginx/docker-compose.yml
Normal 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
|
||||
|
||||
72
docker/local-nginx/nginx.conf
Normal file
72
docker/local-nginx/nginx.conf
Normal 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
39
docker/nginx/default.conf
Normal 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
BIN
docs/img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/img_1.png
Normal file
BIN
docs/img_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
docs/img_2.png
Normal file
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
2729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -29,7 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2",
|
||||
"playwright": "^1.38.0",
|
||||
"playwright": "^1.51.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@ -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`).
|
||||
|
||||
|
||||
21
packages/avanza-ui/src/components/ControlBar.tsx
Normal file
21
packages/avanza-ui/src/components/ControlBar.tsx
Normal 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;
|
||||
16
packages/avanza-ui/src/components/ControlGroup.module.css
Normal file
16
packages/avanza-ui/src/components/ControlGroup.module.css
Normal 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;
|
||||
}
|
||||
|
||||
21
packages/avanza-ui/src/components/ControlGroup.tsx
Normal file
21
packages/avanza-ui/src/components/ControlGroup.tsx
Normal 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;
|
||||
|
||||
21
packages/avanza-ui/src/components/IconButton.module.css
Normal file
21
packages/avanza-ui/src/components/IconButton.module.css
Normal 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)}
|
||||
|
||||
38
packages/avanza-ui/src/components/IconButton.tsx
Normal file
38
packages/avanza-ui/src/components/IconButton.tsx
Normal 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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
108
packages/avanza-ui/src/styles/controls.css
Normal file
108
packages/avanza-ui/src/styles/controls.css
Normal 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; }
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
15
packages/backend-api/.env.production
Normal file
15
packages/backend-api/.env.production
Normal 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
|
||||
28
packages/backend-api/Dockerfile
Normal file
28
packages/backend-api/Dockerfile
Normal 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"]
|
||||
26
packages/backend-api/build_and_run_backend.sh
Executable file
26
packages/backend-api/build_and_run_backend.sh
Executable 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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) }
|
||||
});
|
||||
|
||||
@ -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;"]
|
||||
|
||||
@ -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.
|
||||
|
||||
15
packages/broadcast-panel/public/dump_session.html
Normal file
15
packages/broadcast-panel/public/dump_session.html
Normal 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>
|
||||
|
||||
133
packages/broadcast-panel/public/post_token_to_studio.html
Normal file
133
packages/broadcast-panel/public/post_token_to_studio.html
Normal 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>
|
||||
0
packages/broadcast-panel/scripts/simulate_ack.js
Normal file
0
packages/broadcast-panel/scripts/simulate_ack.js
Normal file
20
packages/broadcast-panel/src/components/Toast.tsx
Normal file
20
packages/broadcast-panel/src/components/Toast.tsx
Normal 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
|
||||
|
||||
@ -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>}
|
||||
|
||||
178
packages/broadcast-panel/src/hooks/useStudioLauncher.ts
Normal file
178
packages/broadcast-panel/src/hooks/useStudioLauncher.ts
Normal 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 }
|
||||
}
|
||||
44
packages/broadcast-panel/src/hooks/useToast.tsx
Normal file
44
packages/broadcast-panel/src/hooks/useToast.tsx
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
|
||||
100
packages/broadcast-panel/src/utils/studioLauncher.ts
Normal file
100
packages/broadcast-panel/src/utils/studioLauncher.ts
Normal 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
|
||||
@ -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: {},
|
||||
}
|
||||
})(),
|
||||
}
|
||||
|
||||
9
packages/studio-panel/.dockerignore
Normal file
9
packages/studio-panel/.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.vscode
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
28
packages/studio-panel/Dockerfile
Normal file
28
packages/studio-panel/Dockerfile
Normal 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;"]
|
||||
|
||||
70
packages/studio-panel/README.DOCKER.md
Normal file
70
packages/studio-panel/README.DOCKER.md
Normal 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.
|
||||
|
||||
|
||||
34
packages/studio-panel/README.E2E.md
Normal file
34
packages/studio-panel/README.E2E.md
Normal 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`.
|
||||
|
||||
36
packages/studio-panel/build_and_pack_docker.sh
Executable file
36
packages/studio-panel/build_and_pack_docker.sh
Executable 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"
|
||||
|
||||
53
packages/studio-panel/deploy/nginx.avanzacast.conf
Normal file
53
packages/studio-panel/deploy/nginx.avanzacast.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
16
packages/studio-panel/deploy/studio-panel.service
Normal file
16
packages/studio-panel/deploy/studio-panel.service
Normal 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
|
||||
|
||||
6
packages/studio-panel/e2e/playwright.config.js
Normal file
6
packages/studio-panel/e2e/playwright.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
// minimal Playwright config for local E2E
|
||||
module.exports = {
|
||||
use: { headless: true },
|
||||
timeout: 30_000,
|
||||
};
|
||||
|
||||
81
packages/studio-panel/e2e/playwright_test.mjs
Normal file
81
packages/studio-panel/e2e/playwright_test.mjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
147
packages/studio-panel/e2e/run_e2e.mjs
Normal file
147
packages/studio-panel/e2e/run_e2e.mjs
Normal 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); });
|
||||
103
packages/studio-panel/e2e/selectors_streamyard.json
Normal file
103
packages/studio-panel/e2e/selectors_streamyard.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
packages/studio-panel/e2e/server.mjs
Normal file
42
packages/studio-panel/e2e/server.mjs
Normal 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)));
|
||||
|
||||
95
packages/studio-panel/e2e/simulated_flow.mjs
Normal file
95
packages/studio-panel/e2e/simulated_flow.mjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
48
packages/studio-panel/e2e/static/sender.html
Normal file
48
packages/studio-panel/e2e/static/sender.html
Normal 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>
|
||||
38
packages/studio-panel/e2e/static/studio.html
Normal file
38
packages/studio-panel/e2e/static/studio.html
Normal 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>
|
||||
51
packages/studio-panel/e2e/tests/postMessage.e2e.spec.js
Normal file
51
packages/studio-panel/e2e/tests/postMessage.e2e.spec.js
Normal 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');
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
19
packages/studio-panel/playwright.config.ts
Normal file
19
packages/studio-panel/playwright.config.ts
Normal 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'] } },
|
||||
],
|
||||
});
|
||||
|
||||
@ -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 = {};
|
||||
|
||||
79
packages/studio-panel/public/debug-styles.html
Normal file
79
packages/studio-panel/public/debug-styles.html
Normal 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,'<')}</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>
|
||||
154
packages/studio-panel/public/simulate_postmessage.html
Normal file
154
packages/studio-panel/public/simulate_postmessage.html
Normal 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>
|
||||
70
packages/studio-panel/public/studio_receiver.html
Normal file
70
packages/studio-panel/public/studio_receiver.html
Normal 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>
|
||||
42
packages/studio-panel/run_e2e_session.sh
Executable file
42
packages/studio-panel/run_e2e_session.sh
Executable 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
|
||||
58
packages/studio-panel/run_playwright_test.sh
Executable file
58
packages/studio-panel/run_playwright_test.sh
Executable 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."
|
||||
|
||||
28
packages/studio-panel/scripts/check_styles_playwright.mjs
Normal file
28
packages/studio-panel/scripts/check_styles_playwright.mjs
Normal 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();
|
||||
}
|
||||
})();
|
||||
@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
31
packages/studio-panel/scripts/e2e_portal_interaction.mjs
Normal file
31
packages/studio-panel/scripts/e2e_portal_interaction.mjs
Normal 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();
|
||||
}
|
||||
})();
|
||||
|
||||
59
packages/studio-panel/scripts/e2e_session_http_runner.mjs
Normal file
59
packages/studio-panel/scripts/e2e_session_http_runner.mjs
Normal 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); }
|
||||
})();
|
||||
|
||||
103
packages/studio-panel/scripts/e2e_session_runner.mjs
Normal file
103
packages/studio-panel/scripts/e2e_session_runner.mjs
Normal 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;
|
||||
}
|
||||
})();
|
||||
164
packages/studio-panel/scripts/e2e_simulate_guests.mjs
Normal file
164
packages/studio-panel/scripts/e2e_simulate_guests.mjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
285
packages/studio-panel/scripts/playwright_mcp_flow.mjs
Normal file
285
packages/studio-panel/scripts/playwright_mcp_flow.mjs
Normal 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');
|
||||
}
|
||||
})();
|
||||
152
packages/studio-panel/scripts/playwright_postmessage_test.mjs
Normal file
152
packages/studio-panel/scripts/playwright_postmessage_test.mjs
Normal 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');
|
||||
})();
|
||||
34
packages/studio-panel/scripts/playwright_rec_test.mjs
Normal file
34
packages/studio-panel/scripts/playwright_rec_test.mjs
Normal 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();
|
||||
})();
|
||||
|
||||
14
packages/studio-panel/scripts/pp_screenshot.mjs
Normal file
14
packages/studio-panel/scripts/pp_screenshot.mjs
Normal 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();
|
||||
})();
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
61
packages/studio-panel/src/__tests__/StudioPortal.test.tsx
Normal file
61
packages/studio-panel/src/__tests__/StudioPortal.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
|
||||
37
packages/studio-panel/src/__tests__/postMessage.test.ts
Normal file
37
packages/studio-panel/src/__tests__/postMessage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
39
packages/studio-panel/src/__tests__/smoke.test.tsx
Normal file
39
packages/studio-panel/src/__tests__/smoke.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
16
packages/studio-panel/src/components/Portal/StudioPortal.css
Normal file
16
packages/studio-panel/src/components/Portal/StudioPortal.css
Normal 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}
|
||||
|
||||
171
packages/studio-panel/src/components/Portal/StudioPortal.tsx
Normal file
171
packages/studio-panel/src/components/Portal/StudioPortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */ }
|
||||
})();
|
||||
|
||||
@ -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); }
|
||||
|
||||
|
||||
272
packages/studio-panel/src/styles/avanza-utilities.css
Normal file
272
packages/studio-panel/src/styles/avanza-utilities.css
Normal 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 }
|
||||
}
|
||||
39
packages/studio-panel/src/utils/postMessage.ts
Normal file
39
packages/studio-panel/src/utils/postMessage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
70
packages/studio-panel/tests/e2e/session_flow.spec.ts
Normal file
70
packages/studio-panel/tests/e2e/session_flow.spec.ts
Normal 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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
16
packages/studio-panel/vitest.config.ts
Normal file
16
packages/studio-panel/vitest.config.ts
Normal 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
71
scripts/check_cors.sh
Executable 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
83
scripts/restart_backend_prod.sh
Executable 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
34
scripts/smoke_studio_session.sh
Executable 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)."
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user