- 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.
304 lines
9.8 KiB
JavaScript
304 lines
9.8 KiB
JavaScript
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 };
|
||
|