feat(deploy): add LiveKit and Caddy configuration files, init script, and Docker Compose setup for server deployment
Some checks failed
E2E Playwright - Studio Panel / playwright-e2e (push) Has been cancelled

This commit is contained in:
Cesar Mendivil 2025-11-26 15:42:40 -07:00
parent 2a242b35f2
commit a2a2da6586
9 changed files with 502 additions and 46 deletions

153
package-lock.json generated
View File

@ -6265,6 +6265,25 @@
"@types/send": "*" "@types/send": "*"
} }
}, },
"node_modules/@types/fs-extra": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz",
"integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==",
"license": "MIT",
"dependencies": {
"@types/minimatch": "*",
"@types/node": "*"
}
},
"node_modules/@types/graceful-fs": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@ -6379,6 +6398,12 @@
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
}, },
"node_modules/@types/minimatch": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz",
"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==",
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -15007,6 +15032,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@ -23510,6 +23544,122 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup-plugin-copy": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/rollup-plugin-copy/-/rollup-plugin-copy-3.4.0.tgz",
"integrity": "sha512-rGUmYYsYsceRJRqLVlE9FivJMxJ7X6jDlP79fmFkL8sJs7VVMSVyA2yfyL+PGyO/vJs4A87hwhgVfz61njI+uQ==",
"license": "MIT",
"dependencies": {
"@types/fs-extra": "^8.0.1",
"colorette": "^1.1.0",
"fs-extra": "^8.1.0",
"globby": "10.0.1",
"is-plain-object": "^3.0.0"
},
"engines": {
"node": ">=8.3"
}
},
"node_modules/rollup-plugin-copy/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/rollup-plugin-copy/node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"license": "MIT"
},
"node_modules/rollup-plugin-copy/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/rollup-plugin-copy/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rollup-plugin-copy/node_modules/globby": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/globby/-/globby-10.0.1.tgz",
"integrity": "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==",
"license": "MIT",
"dependencies": {
"@types/glob": "^7.1.1",
"array-union": "^2.1.0",
"dir-glob": "^3.0.1",
"fast-glob": "^3.0.3",
"glob": "^7.1.3",
"ignore": "^5.1.1",
"merge2": "^1.2.3",
"slash": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/rollup-plugin-copy/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/rollup-plugin-copy/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/rollup-plugin-copy/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/rollup-plugin-peer-deps-external": { "node_modules/rollup-plugin-peer-deps-external": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz", "resolved": "https://registry.npmjs.org/rollup-plugin-peer-deps-external/-/rollup-plugin-peer-deps-external-2.2.4.tgz",
@ -30597,7 +30747,8 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"clsx": "^2.1.1" "clsx": "^2.1.1",
"rollup-plugin-copy": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-commonjs": "^25.0.7",

View File

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

View File

@ -3,6 +3,7 @@ import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs'; import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript'; import typescript from '@rollup/plugin-typescript';
import postcss from 'rollup-plugin-postcss'; import postcss from 'rollup-plugin-postcss';
import copy from 'rollup-plugin-copy';
export default { export default {
input: 'src/index.ts', input: 'src/index.ts',
@ -24,6 +25,14 @@ export default {
commonjs(), commonjs(),
typescript({ tsconfig: './tsconfig.json' }), typescript({ tsconfig: './tsconfig.json' }),
postcss({ extract: true, minimize: true }), postcss({ extract: true, minimize: true }),
// copy CSS module files and global styles to dist so relative @import paths resolve
copy({
targets: [
{ src: 'src/components/**/*.module.css', dest: 'dist/components' },
{ src: 'src/styles/**/*.css', dest: 'dist/styles' }
],
verbose: true,
flatten: false,
}),
], ],
}; };

View File

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

View File

@ -214,6 +214,57 @@
.control-btn.disabled { color: #dc2626; background-color: #fecaca; } .control-btn.disabled { color: #dc2626; background-color: #fecaca; }
.control-btn.disabled:hover { color: #b91c1c; background-color: #fca5a5; } .control-btn.disabled:hover { color: #b91c1c; background-color: #fca5a5; }
/* Device picker dropdown/dialog shown under control buttons when multiple devices exist */
.device-picker,
.devicePicker {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
z-index: 60;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 6px;
}
.device-item,
.deviceItem {
background: transparent;
border: none;
text-align: left;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #111827;
}
.device-item:hover, .deviceItem:hover { background: #f3f4f6; }
.device-cancel,
.deviceCancel {
background: #fff;
border: 1px solid #e5e7eb;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #6b7280;
}
.device-arrow,
.deviceArrow {
margin-left: 8px;
font-size: 12px;
color: #6b7280;
display: inline-block;
}
.control-icon { .control-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -360,6 +411,51 @@
font-size: 14px; font-size: 14px;
} }
/* Modal for device settings */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 120;
padding: 24px;
}
.modal-content {
background: #ffffff;
border-radius: 12px;
width: 720px;
max-width: 100%;
box-shadow: 0 12px 40px rgba(2,6,23,0.12);
overflow: hidden;
}
.modal-header { padding: 20px 24px; border-bottom: 1px solid #eef2f7; }
.modal-header h3 { margin: 0; font-size: 18px; color: #111827; }
.modal-body { padding: 18px 24px; display: flex; gap: 20px; flex-wrap: wrap; }
.modal-section { flex: 1 1 300px; min-width: 260px; }
.modal-section h4 { margin: 0 0 8px 0; font-size: 14px; color: #0f172a; }
.modal-actions { margin-bottom: 8px; }
.btn-primary { background: #2563eb; color: white; border: none; padding: 8px 14px; border-radius: 8px; cursor: pointer; }
.btn-secondary { background: #fff; color: #374151; border: 1px solid #e5e7eb; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.device-list { display: flex; flex-direction: column; gap: 8px; max-height: 180px; overflow: auto; padding-right: 6px; }
.device-row { display: flex; gap: 10px; align-items: center; font-size: 14px; color: #111827; }
.device-row input { accent-color: #2563eb; }
.muted { color: #6b7280; font-size: 13px; }
.modal-footer { display:flex; gap: 12px; justify-content: flex-end; padding: 12px 20px; border-top: 1px solid #eef2f7; }
/* small responsive tweaks */
@media (max-width: 640px) {
.modal-body { flex-direction: column; padding: 12px; }
.modal-content { width: 100%; }
}
/* responsive */ /* responsive */
@media (max-width: 800px) { @media (max-width: 800px) {
.video-container { flex-direction: column; } .video-container { flex-direction: column; }

View File

@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import styles from './PreJoin.module.css' import styles from './PreJoin.module.css'
// We'll dynamically import MockToggle inside the component when appropriate (DEV mode or VITE_MOCK_STUDIO). // We'll dynamically import MockToggle inside the component when appropriate (DEV mode or VITE_MOCK_STUDIO).
import { isMacPlatform } from 'avanza-ui' import { isMacPlatform } from 'avanza-ui/dist'
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi' import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
// Fallback static list in case dynamic discovery fails. Match actual files present in // Fallback static list in case dynamic discovery fails. Match actual files present in
@ -32,6 +32,12 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
const [name, setName] = useState(() => { const [name, setName] = useState(() => {
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' } try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
}) })
// device-management state
const [audioDevices, setAudioDevices] = useState<MediaDeviceInfo[]>([])
const [videoDevices, setVideoDevices] = useState<MediaDeviceInfo[]>([])
const [selectedAudioDeviceId, setSelectedAudioDeviceId] = useState<string | null>(null)
const [selectedVideoDeviceId, setSelectedVideoDeviceId] = useState<string | null>(null)
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [micEnabled, setMicEnabled] = useState(true) const [micEnabled, setMicEnabled] = useState(true)
const [camEnabled, setCamEnabled] = useState(true) const [camEnabled, setCamEnabled] = useState(true)
const [isChecking, setIsChecking] = useState(false) const [isChecking, setIsChecking] = useState(false)
@ -72,11 +78,12 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
;(async () => { ;(async () => {
try { try {
if (!navigator?.mediaDevices?.getUserMedia) return if (!navigator?.mediaDevices?.getUserMedia) return
localStream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: camEnabled }) const audioConstraint: any = micEnabled ? (selectedAudioDeviceId ? { deviceId: { exact: selectedAudioDeviceId } } : true) : false
const videoConstraint: any = camEnabled ? (selectedVideoDeviceId ? { deviceId: { exact: selectedVideoDeviceId } } : true) : false
localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraint, video: videoConstraint })
if (!mounted) { if (!mounted) {
try { try {
const tracks = localStream?.getTracks?.() if (localStream && localStream.getTracks) localStream.getTracks().forEach(t => t.stop())
if (tracks && tracks.length) tracks.forEach(t => t.stop())
} catch (e) {} } catch (e) {}
return return
} }
@ -122,10 +129,7 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
return () => { return () => {
mounted = false mounted = false
if (localStream) { if (localStream) {
try { try { if (localStream.getTracks) localStream.getTracks().forEach(t => t.stop()) } catch (e) {}
const tracks = localStream?.getTracks?.()
if (tracks && tracks.length) tracks.forEach(t => t.stop())
} catch (e) {}
} }
// clear previewStream state // clear previewStream state
setPreviewStream(null) setPreviewStream(null)
@ -149,14 +153,6 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
} }
} }
const toggleMic = async () => {
setMicEnabled(v => !v)
}
const toggleCam = async () => {
setCamEnabled(v => !v)
}
// If analyzing the token, show the loading/analyzing UI instead of prejoin // If analyzing the token, show the loading/analyzing UI instead of prejoin
if (isAnalyzing) { if (isAnalyzing) {
return ( return (
@ -182,6 +178,64 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
) )
} }
// When user selects a device from picker, apply and close picker
const applySelectedAudio = async (deviceId: string) => {
setSelectedAudioDeviceId(deviceId)
// recreate preview stream with selected device
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId } }, video: false })
// merge with existing preview if needed: we let effect recreate full preview on next state change
if (s && s.getTracks) {
try { s.getTracks().forEach(t => t.stop()) } catch (e) {}
}
} catch (e) {}
}
const applySelectedVideo = async (deviceId: string) => {
setSelectedVideoDeviceId(deviceId)
try {
const s = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } }, audio: false })
// merge with existing preview if needed: we let effect recreate full preview on next state change
if (s && s.getTracks) {
try { s.getTracks().forEach(t => t.stop()) } catch (e) {}
}
} catch (e) {}
}
// Request audio permission and optionally show picker
const requestAndEnableMic = async () => {
try {
// ask for audio permission (without specifying device to allow labels)
const s = await navigator.mediaDevices.getUserMedia({ audio: true })
// stop tracks from this quick probe (we'll re-create preview with selected device)
if (s && s.getTracks) { try { s.getTracks().forEach(t => t.stop()) } catch (e) {} }
// enumerate devices and store list (modal will show them)
const list = await navigator.mediaDevices.enumerateDevices()
const audioIn = list.filter(d => d.kind === 'audioinput')
setAudioDevices(audioIn)
if (!selectedAudioDeviceId && audioIn.length) setSelectedAudioDeviceId(audioIn[0].deviceId)
setMicEnabled(true)
} catch (e) {
// permission denied or error -> keep disabled
setMicEnabled(false)
}
}
// Request camera permission and optionally show picker
const requestAndEnableCam = async () => {
try {
const s = await navigator.mediaDevices.getUserMedia({ video: true })
if (s && s.getTracks) { try { s.getTracks().forEach(t => t.stop()) } catch (e) {} }
const list = await navigator.mediaDevices.enumerateDevices()
const videoIn = list.filter(d => d.kind === 'videoinput')
setVideoDevices(videoIn)
if (!selectedVideoDeviceId && videoIn.length) setSelectedVideoDeviceId(videoIn[0].deviceId)
setCamEnabled(true)
} catch (e) {
setCamEnabled(false)
}
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
@ -215,23 +269,35 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
<div className={styles['controls-wrapper']}> <div className={styles['controls-wrapper']}>
<div className={styles.controls}> <div className={styles.controls}>
<button className={`${styles['control-btn']} ${micEnabled ? '' : styles.disabled}`} onClick={toggleMic} aria-pressed={micEnabled}> <div style={{ position: 'relative' }}>
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>D</span></span> <button
<div className={styles['control-icon']}> className={`${styles['control-btn']} ${micEnabled ? '' : styles.disabled}`}
<FiMic size={24} /> onClick={async () => { if (micEnabled) { setMicEnabled(false) } else { await requestAndEnableMic() } }}
</div> aria-pressed={micEnabled}
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span> >
</button> <span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>D</span></span>
<div className={styles['control-icon']}>
<FiMic size={24} />
</div>
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span>
</button>
</div>
<button className={`${styles['control-btn']} ${camEnabled ? '' : styles.disabled}`} onClick={toggleCam} aria-pressed={camEnabled}> <div style={{ position: 'relative' }}>
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>E</span></span> <button
<div className={styles['control-icon']}> className={`${styles['control-btn']} ${camEnabled ? '' : styles.disabled}`}
<FiVideo size={24} /> onClick={async () => { if (camEnabled) { setCamEnabled(false) } else { await requestAndEnableCam() } }}
</div> aria-pressed={camEnabled}
<span>{camEnabled ? 'Desactivar cámara' : 'Activar cámara'}</span> >
</button> <span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>E</span></span>
<div className={styles['control-icon']}>
<FiVideo size={24} />
</div>
<span>{camEnabled ? 'Desactivar cámara' : 'Activar cámara'}</span>
</button>
</div>
<button className={styles['control-btn']} onClick={() => {}}> <button className={styles['control-btn']} onClick={() => setShowSettingsModal(true)}>
<div className={styles['control-icon']}> <div className={styles['control-icon']}>
<FiSettings size={24} /> <FiSettings size={24} />
</div> </div>
@ -253,6 +319,52 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
<button type="submit" className={styles['submit-btn']}>{isChecking ? 'Comprobando...' : 'Entrar al estudio'}</button> <button type="submit" className={styles['submit-btn']}>{isChecking ? 'Comprobando...' : 'Entrar al estudio'}</button>
</form> </form>
{/* Settings modal: device configuration moved here */}
{showSettingsModal ? (
<div className={styles['modal-backdrop']} role="dialog" aria-modal>
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<h3>Configuración de dispositivos</h3>
</div>
<div className={styles['modal-body']}>
<div className={styles['modal-section']}>
<h4>Micrófonos</h4>
<div className={styles['modal-actions']}>
<button className={styles['btn-secondary']} onClick={async () => { await requestAndEnableMic(); }}>Detectar dispositivos</button>
</div>
<div className={styles['device-list']}>
{audioDevices.length ? audioDevices.map(d => (
<label key={d.deviceId} className={styles['device-row']}>
<input type="radio" name="audioDevice" value={d.deviceId} checked={selectedAudioDeviceId === d.deviceId} onChange={() => setSelectedAudioDeviceId(d.deviceId)} />
<span>{d.label || 'Micrófono ' + d.deviceId}</span>
</label>
)) : <div className={styles['muted']}>Ningún dispositivo detectado</div>}
</div>
</div>
<div className={styles['modal-section']}>
<h4>Cámaras</h4>
<div className={styles['modal-actions']}>
<button className={styles['btn-secondary']} onClick={async () => { await requestAndEnableCam(); }}>Detectar dispositivos</button>
</div>
<div className={styles['device-list']}>
{videoDevices.length ? videoDevices.map(d => (
<label key={d.deviceId} className={styles['device-row']}>
<input type="radio" name="videoDevice" value={d.deviceId} checked={selectedVideoDeviceId === d.deviceId} onChange={() => setSelectedVideoDeviceId(d.deviceId)} />
<span>{d.label || 'Cámara ' + d.deviceId}</span>
</label>
)) : <div className={styles['muted']}>Ningún dispositivo detectado</div>}
</div>
</div>
</div>
<div className={styles['modal-footer']}>
<button className={styles['btn-primary']} onClick={() => setShowSettingsModal(false)}>Guardar</button>
<button className={styles['btn-secondary']} onClick={() => setShowSettingsModal(false)}>Cerrar</button>
</div>
</div>
</div>
) : null}
</div> </div>
) )
} }

View File

@ -3,7 +3,7 @@ import StudioRoom from "./StudioRoom";
import "./StudioPortal.css"; import "./StudioPortal.css";
import { Room } from "livekit-client"; import { Room } from "livekit-client";
import AutoRequestAndInject from './AutoRequestAndInject' import AutoRequestAndInject from './AutoRequestAndInject'
import PreJoin from './PreJoin' import PreJoin from '../prejoin/PreJoin'
export interface StudioPortalProps { export interface StudioPortalProps {
serverUrl: string; serverUrl: string;

View File

@ -4,7 +4,7 @@ import PageContainer from "./components/PageContainer";
import "./styles.css"; import "./styles.css";
import { ToastProvider } from "./hooks/useToast"; import { ToastProvider } from "./hooks/useToast";
import StudioPortal from "./features/studio/StudioPortal"; import StudioPortal from "./features/studio/StudioPortal";
import PreJoin from "./features/studio/PreJoin"; import PreJoin from "./features/prejoin/PreJoin";
function SessionLoader({ sessionId }: { sessionId: string }) { function SessionLoader({ sessionId }: { sessionId: string }) {
const [state, setState] = React.useState<{ const [state, setState] = React.useState<{

View File

@ -1,5 +1,5 @@
// Simple Storybook story for PreJoin (keeps it robust across envs) // Simple Storybook story for PreJoin (keeps it robust across envs)
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin' import PreJoin from '../../../broadcast-panel/src/features/prejoin/PreJoin'
export default { export default {
title: 'Broadcast/PreJoin', title: 'Broadcast/PreJoin',