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
Some checks failed
E2E Playwright - Studio Panel / playwright-e2e (push) Has been cancelled
This commit is contained in:
parent
2a242b35f2
commit
a2a2da6586
153
package-lock.json
generated
153
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
5
packages/avanza-ui/scripts/copy-css.js
Normal file
5
packages/avanza-ui/scripts/copy-css.js
Normal 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);
|
||||||
@ -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; }
|
||||||
@ -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' }}>
|
||||||
|
<button
|
||||||
|
className={`${styles['control-btn']} ${micEnabled ? '' : styles.disabled}`}
|
||||||
|
onClick={async () => { if (micEnabled) { setMicEnabled(false) } else { await requestAndEnableMic() } }}
|
||||||
|
aria-pressed={micEnabled}
|
||||||
|
>
|
||||||
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>D</span></span>
|
<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']}>
|
<div className={styles['control-icon']}>
|
||||||
<FiMic size={24} />
|
<FiMic size={24} />
|
||||||
</div>
|
</div>
|
||||||
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span>
|
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className={`${styles['control-btn']} ${camEnabled ? '' : styles.disabled}`} onClick={toggleCam} aria-pressed={camEnabled}>
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
className={`${styles['control-btn']} ${camEnabled ? '' : styles.disabled}`}
|
||||||
|
onClick={async () => { if (camEnabled) { setCamEnabled(false) } else { await requestAndEnableCam() } }}
|
||||||
|
aria-pressed={camEnabled}
|
||||||
|
>
|
||||||
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>E</span></span>
|
<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']}>
|
<div className={styles['control-icon']}>
|
||||||
<FiVideo size={24} />
|
<FiVideo size={24} />
|
||||||
</div>
|
</div>
|
||||||
<span>{camEnabled ? 'Desactivar cámara' : 'Activar cámara'}</span>
|
<span>{camEnabled ? 'Desactivar cámara' : 'Activar cámara'}</span>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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<{
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user