Cesar Mendivil 00e98a19b3 feat: add InternalWHIP component and associated tests
- Implemented the InternalWHIP component for managing WHIP server configurations.
- Added functionality to load live WHIP state from Core and handle OBS URL generation.
- Included polling for active streams and notifying parent components of state changes.
- Created comprehensive tests for the InternalWHIP component covering various scenarios including fallback mechanisms and state changes.

test: add integration tests for WHIP source component

- Developed end-to-end tests for the InternalWHIP component to verify its behavior under different configurations.
- Ensured that the component correctly handles the loading of WHIP state, displays appropriate messages, and emits the correct onChange events.

test: add Settings WHIP configuration tests

- Implemented tests for the WHIP settings tab to validate loading and saving of WHIP configurations.
- Verified that the correct values are sent back to the Core when the user saves changes.
- Ensured that the UI reflects the current state of the WHIP configuration after Core restarts or changes.
2026-03-14 12:27:53 -07:00

304 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import { Trans } from '@lingui/macro';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/ScreenShare';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RefreshIcon from '@mui/icons-material/Refresh';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import Chip from '@mui/material/Chip';
import * as S from '../../Sources/WebRTCRoom';
/** Extrae el puerto de una dirección del tipo ":8555" o "host:8555" → "8555" */
function extractPort(address, fallback) {
if (!address) return fallback;
const m = address.match(/:(\d+)$/);
return m ? m[1] : fallback;
}
/** Construye la URL base WHIP a partir del config del Core */
function buildWhipBaseUrl(coreConfig) {
const whip = coreConfig?.whip;
if (!whip?.enable) return '';
const port = extractPort(whip.address, '8555');
const hosts = coreConfig?.host?.name;
const host = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : window.location.hostname;
return `http://${host}:${port}/whip`;
}
const initSettings = (initialSettings, config) => {
return S.func.initSettings(initialSettings, config);
};
function Source(props) {
const config = { channelid: 'external', ...(props.config || {}) };
const settings = initSettings(props.settings, config);
const streamKey = settings.streamKey || config.channelid || 'external';
const [mode, setMode] = React.useState(settings.mode || 'whip');
const [copied, setCopied] = React.useState(false);
// Core WHIP config
const [coreConfig, setCoreConfig] = React.useState(null);
const [configLoad, setConfigLoad] = React.useState(false);
const [configErr, setConfigErr] = React.useState('');
// Active streams polling
const [active, setActive] = React.useState(false);
const [localKey, setLocalKey] = React.useState(streamKey);
const origin = window.location.origin;
const host = settings.relayHost || window.location.host;
const roomUrl = `${origin}/webrtc-room/?room=${encodeURIComponent(localKey)}&host=${encodeURIComponent(host)}`;
const whipBase = coreConfig ? buildWhipBaseUrl(coreConfig) : '';
const token = coreConfig?.whip?.token || '';
const whipEnabled = coreConfig?.whip?.enable === true;
const whipUrl = whipBase
? `${whipBase}/${localKey}${token ? '?token=' + token : ''}`
: '';
const makeInputs = () => {
const s = { ...settings, streamKey: localKey, mode };
return S.func.createInputs(s, config);
};
const handleChange = (m, key) => {
const k = key !== undefined ? key : localKey;
props.onChange(S.id, { ...settings, streamKey: k, mode: m }, makeInputs(), false);
};
// Carga la config del Core (GET /api/v3/config)
const fetchCoreConfig = React.useCallback(async () => {
setConfigLoad(true);
setConfigErr('');
try {
const res = await fetch('/api/v3/config');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setCoreConfig(data);
} catch (err) {
setConfigErr(err.message);
} finally {
setConfigLoad(false);
}
}, []);
// Poll GET /api/v3/whip para ver si el stream está activo
const pollWhipStreams = React.useCallback(async () => {
try {
const res = await fetch('/api/v3/whip');
if (!res.ok) return;
const streams = await res.json();
setActive(Array.isArray(streams) && streams.some((s) => s.name === localKey));
} catch (_) {}
}, [localKey]);
React.useEffect(() => {
fetchCoreConfig();
}, [fetchCoreConfig]);
// Poll cada 5s cuando modo = whip
React.useEffect(() => {
if (mode !== 'whip') return;
pollWhipStreams();
const id = setInterval(pollWhipStreams, 5000);
return () => clearInterval(id);
}, [mode, pollWhipStreams]);
React.useEffect(() => {
handleChange(mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMode = (_e, newMode) => {
if (!newMode) return;
setMode(newMode);
handleChange(newMode);
};
const handleKeyChange = (e) => {
const k = e.target.value;
setLocalKey(k);
handleChange(mode, k);
};
const handleCopy = (text) => () => {
if (text && navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
setCopied(text);
setTimeout(() => setCopied(false), 2000);
});
}
};
return (
<React.Fragment>
{/* ── Mode toggle ── */}
<Grid item xs={12}>
<ToggleButtonGroup value={mode} exclusive onChange={handleMode} size="small">
<ToggleButton value="whip">
<CloudUploadIcon style={{ fontSize: '1rem', marginRight: 4 }} />
<Trans>WHIP / OBS</Trans>
</ToggleButton>
<ToggleButton value="room">
<Icon style={{ fontSize: '1rem', marginRight: 4 }} />
<Trans>Sala WebRTC (navegador)</Trans>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* ── WHIP mode ── */}
{mode === 'whip' && (
<>
{configLoad && (
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CircularProgress size={16} />
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.5)' }}>
<Trans>Cargando config WHIP del Core</Trans>
</Typography>
</Grid>
)}
{configErr && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#e74c3c' }}>
{configErr}
</Typography>
<Button size="small" startIcon={<RefreshIcon />} onClick={fetchCoreConfig} style={{ marginTop: 4 }}>
<Trans>Reintentar</Trans>
</Button>
</Grid>
)}
{!configLoad && coreConfig && !whipEnabled && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#e67e22' }}>
<Trans>El servidor WHIP del Core está deshabilitado. Actívalo en Ajustes WHIP Server.</Trans>
</Typography>
</Grid>
)}
{!configLoad && coreConfig && whipEnabled && (
<>
{/* Stream Key */}
<Grid item xs={12}>
<TextField
variant="outlined" fullWidth
label={<Trans>Stream Key</Trans>}
value={localKey}
onChange={handleKeyChange}
helperText={<Trans>OBS enviará a esta clave. Usa el ID del canal o escribe una personalizada.</Trans>}
/>
</Grid>
{/* URL de publicación para OBS */}
<Grid item xs={12}>
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.5)', display: 'block', marginBottom: 4 }}>
<Trans>URL para OBS Configuración Emisión Servicio: Custom (WHIP)</Trans>
</Typography>
<div style={{ display: 'flex', gap: 8 }}>
<TextField
variant="outlined" fullWidth size="small"
value={whipUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
helperText={<Trans>Clave de stream en OBS: dejar vacía (ya va en la URL)</Trans>}
/>
<Button
variant="outlined" size="small"
onClick={handleCopy(whipUrl)}
startIcon={<ContentCopyIcon />}
style={{ whiteSpace: 'nowrap', minWidth: 90, height: 56 }}
>
{copied === whipUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
</div>
</Grid>
{/* Estado en tiempo real */}
<Grid item xs={12}>
{active ? (
<Chip
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
label={<Trans>Transmitiendo</Trans>}
size="small"
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
/>
) : (
<Chip
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
label={<Trans>Esperando publicador</Trans>}
size="small"
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
/>
)}
</Grid>
{/* Token warning */}
{token && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: 'rgba(255,200,0,0.8)', fontSize: '0.8rem' }}>
🔑 <Trans>El servidor requiere token. Ya está incluido en la URL anterior.</Trans>
</Typography>
</Grid>
)}
</>
)}
</>
)}
{/* ── Room mode ── */}
{mode === 'room' && (
<Grid item xs={12}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<TextField
variant="outlined" fullWidth
label={<Trans>URL de la sala (compartir con el presentador)</Trans>}
value={roomUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
helperText={<Trans>Room ID: {localKey}</Trans>}
/>
<Button
variant="outlined" size="small"
onClick={handleCopy(roomUrl)}
startIcon={<ContentCopyIcon />}
style={{ whiteSpace: 'nowrap', minWidth: 90, height: 56 }}
>
{copied === roomUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
</div>
</Grid>
)}
</React.Fragment>
);
}
Source.defaultProps = {
knownDevices: [],
settings: {},
config: null,
skills: null,
onChange: function (type, settings, inputs, ready) {},
onRefresh: function () {},
};
function SourceIcon(props) {
return <Icon style={{ color: '#FFF' }} {...props} />;
}
const id = 'webrtcroom';
const type = 'webrtcroom';
const name = <Trans>WebRTC Room</Trans>;
const capabilities = ['audio', 'video'];
export { id, type, name, capabilities, SourceIcon as icon, Source as component };