Panel de ajustes de dispositivos en MeetingRoom (cám/mic/altavoz)
Contexto y motivación
Edu reportó que tras el primer deploy WebRTC, Windows elegía automáticamente la cámara del smartphone conectado vía Phone Link en lugar de la webcam física. Además, no existía ningún botón de configuración en la interfaz para corregirlo manualmente.
El commit 1b8a61e (s79 · 2026-05-22) introduce un panel completo de selección de dispositivos audiovisuales que resuelve este problema y añade persistencia entre sesiones.
Funcionalidades añadidas
Botón «Ajustes» en la barra de controles
Se añade un botón tools-btn junto a «Compartir pantalla» y «Colgar» que activa/desactiva el panel overlay de ajustes.
<button type="button" onClick={() => setSettingsOpen((s) => !s)} className="tools-btn">
{settingsOpen ? 'Cerrar ajustes' : 'Ajustes'}
</button>
Panel overlay con tres dropdowns
El panel se renderiza como position: absolute anclado a la esquina inferior-derecha del modal (right: 8, bottom: 56). Contiene tres selectores:
| Selector | localStorage key | Constraint aplicado |
|---|---|---|
Cámara (videoinput) | meeting:camId | video: { deviceId: { exact } } |
Micrófono (audioinput) | meeting:micId | audio: { deviceId: { exact } } |
Altavoz (audiooutput) | meeting:speakerId | HTMLVideoElement.setSinkId() |
Flujo técnico
1. Inicialización con persistencia
Al montar el componente, los tres estados se inicializan leyendo localStorage:
const [selectedCamId, setSelectedCamId] = useState<string>(
() => localStorage.getItem('meeting:camId') || '',
);
Si hay un ID guardado, getUserMedia arranca directamente con ese deviceId: { exact }. Si el dispositivo ya no existe (p.ej. USB desconectado), hay un fallback automático a getUserMedia({ video: true, audio: true }) sin constraint:
try {
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraint, audio: audioConstraint });
} catch (constraintErr) {
console.warn('[meeting] device constraint failed, fallback default', constraintErr);
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
}
2. Enumeración de dispositivos
refreshDevices() llama a navigator.mediaDevices.enumerateDevices() para poblar las listas de cámaras, micrófonos y altavoces. Se invoca:
- Justo después de
getUserMediaexitoso (las labels solo están pobladas tras obtener permisos). - Cada vez que el sistema operativo emite el evento
devicechange(USB conectado/desconectado durante la reunión).
El listener se registra con addEventListener('devicechange', refreshDevices) y se limpia en el cleanup del useEffect.
3. Cambio de cámara en vivo (applyCameraChange)
getUserMedia({ video: { deviceId: { exact: id } }, audio: false })
→ nuevo VideoTrack
→ RTCRtpSender.replaceTrack() en cada peer (sin colgar)
→ removeTrack / addTrack en originalStreamRef
→ setLocalStream() para refrescar el preview local
→ localStorage.setItem('meeting:camId', id)
Si screenSharing está activo, el preview local no se actualiza (la pantalla sigue como fuente visual de la transmisión).
4. Cambio de micrófono en vivo (applyMicChange)
Mismo patrón que la cámara pero con audioinput:
getUserMedia({ audio: { deviceId: { exact: id } }, video: false })
→ nuevo AudioTrack
→ RTCRtpSender.replaceTrack() en cada peer
→ removeTrack / addTrack en originalStreamRef
→ localStorage.setItem('meeting:micId', id)
5. Cambio de altavoz (applySpeakerChange)
A diferencia de cámara/mic, el altavoz no requiere un nuevo stream: se aplica setSinkId(deviceId) sobre todos los elementos <video> del DOM. Esto incluye los tiles remotos.
const videos = document.querySelectorAll<HTMLVideoElement>('video');
for (const v of Array.from(videos)) {
if (typeof v.setSinkId === 'function') {
await v.setSinkId(deviceId);
}
}
Compatibilidad:
setSinkIdsolo está soportado en Chromium (Chrome, Edge). Firefox y Safari ignoran la selección y usan el dispositivo de salida del sistema. La UI muestra una nota informativa al respecto.
Estado del componente (nuevos useState)
| Estado | Tipo | Propósito |
|---|---|---|
settingsOpen | boolean | Visibilidad del panel overlay |
cameras | MediaDeviceInfo[] | Lista de cámaras disponibles |
microphones | MediaDeviceInfo[] | Lista de micrófonos disponibles |
speakers | MediaDeviceInfo[] | Lista de altavoces disponibles |
selectedCamId | string | ID persistido / seleccionado de cámara |
selectedMicId | string | ID persistido / seleccionado de micrófono |
selectedSpeakerId | string | ID persistido / seleccionado de altavoz |
Casos de borde cubiertos
- Dispositivo desconectado entre sesiones: fallback silencioso a default, sin error visible al usuario.
- Phone Link / cámaras virtuales: el selector muestra todas las
videoinputenumeradas; el usuario puede elegir explícitamente su webcam física. - Dispositivos reconectados en sesión activa:
devicechangeevent refresca los dropdowns automáticamente. - Screen sharing activo al cambiar cámara:
replaceTrackactualiza el track enviado a peers, pero el preview local mantiene la pantalla compartida. - Firefox / Safari y altavoz: la UI avisa que
setSinkIdno está disponible; el resto de funcionalidad (cámara/mic) sigue operativa.
Notas de implementación
- La función
refreshDevicesestá definida dentro deluseEffectque gestiona la sesión WebRTC, lo que garantiza acceso al closuremountedpara evitar actualizaciones de estado tras desmontar. - Los tres
useCallback(applyCameraChange,applyMicChange,applySpeakerChange) son callbacks estables que solo referencian refs y estado gestionado, minimizando re-renders. - El panel overlay usa variables CSS del design system (
--bg-raised,--border,--radius,--fg-dim) para coherencia visual con el resto del modal.
Véase también
- [[entity—meeting—component—meeting-room]]
- [[feature—workspace—webrtc-peer-to-peer]]