- Introduced WHIP-RTSP as a new source option in Source.js. - Enhanced Restreamer to handle RTSP relay configuration and address parsing. - Updated WebRTCRoom to allow selection between direct and RTSP relay modes. - Implemented WHIPPublications component for managing multiple WHIP destinations. - Added internal RTSP relay configuration in Settings.js. - Improved handling of WHEP and WHIP streams, including error logging and state management.
523 lines
19 KiB
JavaScript
523 lines
19 KiB
JavaScript
/**
|
|
* WHIPPublications — panel §8 del WHIP_UI_INTEGRATION_PROMPT
|
|
*
|
|
* Permite re-emitir un stream WHIP a múltiples plataformas (YouTube, Twitch,
|
|
* Facebook, etc.) simultáneamente usando el relay RTSP interno del Core.
|
|
*
|
|
* Cada destino es un proceso FFmpeg independiente con:
|
|
* input: {whip-rtsp:name=<channelid>}
|
|
* output: rtmp[s]://<plataforma>/<key> (configurado vía Publication/Add.js)
|
|
* audio: -c:a aac -b:a 128k ← Opus→AAC obligatorio para RTMP/FLV
|
|
* video: -c:v copy
|
|
*
|
|
* El botón "+" navega a la página estándar Publication/Add, donde el usuario
|
|
* selecciona la plataforma y configura OAuth2 / stream key / título / descripción
|
|
* usando los mismos Service components que el panel de Publications normal.
|
|
* Para que la fuente "WHIP RTSP relay" aparezca en el selector, se añadió
|
|
* la opción a Source.js y a Add.js localSources (cuando rtspEnabled=true).
|
|
*/
|
|
import React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { Trans } from '@lingui/macro';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import Button from '@mui/material/Button';
|
|
import Chip from '@mui/material/Chip';
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
|
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
|
import Grid from '@mui/material/Grid';
|
|
import IconButton from '@mui/material/IconButton';
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
import StopIcon from '@mui/icons-material/Stop';
|
|
import StreamIcon from '@mui/icons-material/Stream';
|
|
import Tooltip from '@mui/material/Tooltip';
|
|
import Typography from '@mui/material/Typography';
|
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
|
|
|
import useInterval from '../../hooks/useInterval';
|
|
import Paper from '../../misc/Paper';
|
|
import PaperHeader from '../../misc/PaperHeader';
|
|
import { refreshEgressStreamKey } from '../../utils/autoStreamKey';
|
|
|
|
// ─── State badge ──────────────────────────────────────────────────────────────
|
|
|
|
function StateBadge({ order, state }) {
|
|
if (order === 'stop') {
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
icon={<FiberManualRecordIcon style={{ fontSize: 10 }} />}
|
|
label={<Trans>Stopped</Trans>}
|
|
style={{ backgroundColor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.5)' }}
|
|
/>
|
|
);
|
|
}
|
|
if (state === 'running') {
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
icon={<FiberManualRecordIcon style={{ color: '#27ae60', fontSize: 12 }} />}
|
|
label={<Trans>Live</Trans>}
|
|
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
|
/>
|
|
);
|
|
}
|
|
if (state === 'failed') {
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
icon={<ErrorOutlineIcon style={{ fontSize: 14 }} />}
|
|
label={<Trans>Error</Trans>}
|
|
style={{ backgroundColor: 'rgba(231,76,60,0.15)', color: '#e74c3c', border: '1px solid #e74c3c' }}
|
|
/>
|
|
);
|
|
}
|
|
// starting / finishing / idle while order=start
|
|
return (
|
|
<Chip
|
|
size="small"
|
|
icon={<CircularProgress size={10} />}
|
|
label={<Trans>Starting…</Trans>}
|
|
style={{ backgroundColor: 'rgba(243,156,18,0.15)', color: '#f39c12' }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
|
|
export default function WHIPPublications({ channelid, restreamer }) {
|
|
const navigate = useNavigate();
|
|
|
|
// Session config references
|
|
const configWhip = restreamer?.ConfigActive?.()?.source?.network?.whip || {};
|
|
|
|
const [rtspEnabled, setRtspEnabled] = React.useState(null); // null=loading
|
|
const [whipActive, setWhipActive] = React.useState(false);
|
|
const [destinations, setDestinations] = React.useState([]);
|
|
|
|
// Per-destination log text (shown on error)
|
|
const [showLog, setShowLog] = React.useState({});
|
|
const [logs, setLogs] = React.useState({});
|
|
|
|
// Metadata cache: id → true (whip-rtsp source) | false (other source)
|
|
// Avoids re-fetching metadata for already-seen egresses on every poll.
|
|
const sourceCache = React.useRef(new Map());
|
|
|
|
// ── WHEP preview state (same pattern as InternalWHIP.js) ──────────────
|
|
const [whepUrl, setWhepUrl] = React.useState('');
|
|
const [bearerToken, setBearerToken] = React.useState('');
|
|
const [previewActive, setPreviewActive] = React.useState(false);
|
|
const [previewError, setPreviewError] = React.useState('');
|
|
const pcRef = React.useRef(null);
|
|
const locationRef = React.useRef(null);
|
|
const videoRef = React.useRef(null);
|
|
const streamRef = React.useRef(null);
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────
|
|
React.useEffect(() => {
|
|
load();
|
|
return () => {
|
|
if (pcRef.current) { pcRef.current.close(); pcRef.current = null; }
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const load = async () => {
|
|
await checkRtspRelay();
|
|
await loadWhepUrl();
|
|
await updateDestinations();
|
|
await checkWhipActive();
|
|
};
|
|
|
|
// ── Polling ───────────────────────────────────────────────────────────────
|
|
useInterval(async () => {
|
|
await checkWhipActive();
|
|
await updateDestinations();
|
|
}, 5000);
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
const checkRtspRelay = async () => {
|
|
setRtspEnabled(!!configWhip.rtspEnabled);
|
|
};
|
|
|
|
const loadWhepUrl = async () => {
|
|
if (!restreamer?.WhipStreamUrl) {
|
|
// Fallback: build from config
|
|
if (configWhip.enabled && configWhip.host) {
|
|
const base = configWhip.base_whep_url || `http://${configWhip.host}/whip/`;
|
|
setWhepUrl(`${base}${channelid}/whep`);
|
|
}
|
|
setBearerToken(configWhip.token || '');
|
|
return;
|
|
}
|
|
try {
|
|
const data = await restreamer.WhipStreamUrl(channelid);
|
|
if (data?.whep_url) {
|
|
setWhepUrl(data.whep_url);
|
|
} else if (configWhip.host) {
|
|
const base = configWhip.base_whep_url || configWhip.base_publish_url || `http://${configWhip.host}/whip/`;
|
|
setWhepUrl(`${base}${channelid}/whep`);
|
|
}
|
|
setBearerToken(configWhip.token || '');
|
|
} catch (_) {}
|
|
};
|
|
|
|
const checkWhipActive = async () => {
|
|
if (!restreamer?.WhipStreams) return;
|
|
try {
|
|
const streams = await restreamer.WhipStreams();
|
|
setWhipActive(Array.isArray(streams) && streams.some((s) => s.name === channelid));
|
|
} catch (_) {}
|
|
};
|
|
|
|
const updateDestinations = async () => {
|
|
if (!restreamer?.ListIngestEgresses) return;
|
|
try {
|
|
const all = await restreamer.ListIngestEgresses(channelid, []);
|
|
const nonPlayer = all.filter((p) => p.service !== 'player');
|
|
|
|
// For each egress not yet seen, check its metadata to know if it's
|
|
// a whip-rtsp sourced egress. We only fetch metadata once per ID.
|
|
for (const p of nonPlayer) {
|
|
if (sourceCache.current.has(p.id)) continue;
|
|
try {
|
|
const meta = await restreamer.GetEgressMetadata(channelid, p.id);
|
|
sourceCache.current.set(p.id, meta?.control?.source?.source === 'whip-rtsp');
|
|
} catch (_) {
|
|
sourceCache.current.set(p.id, false);
|
|
}
|
|
}
|
|
|
|
// Clean up cache entries for deleted egresses
|
|
const allIds = new Set(nonPlayer.map((p) => p.id));
|
|
for (const id of sourceCache.current.keys()) {
|
|
if (!allIds.has(id)) sourceCache.current.delete(id);
|
|
}
|
|
|
|
setDestinations(nonPlayer.filter((p) => sourceCache.current.get(p.id) === true));
|
|
} catch (_) {}
|
|
};
|
|
|
|
// ── Navigation ────────────────────────────────────────────────────────────
|
|
const handleAdd = () => {
|
|
navigate(`/${channelid}/publication/`);
|
|
};
|
|
|
|
const handleEdit = (service, index) => () => {
|
|
navigate(`/${channelid}/publication/${service}/${index}`);
|
|
};
|
|
|
|
// ── Destination actions ───────────────────────────────────────────────────
|
|
const handleStart = (id, service) => async () => {
|
|
// Auto-refresh OAuth2 stream key for YouTube / Facebook before starting
|
|
try {
|
|
await refreshEgressStreamKey(restreamer, channelid, id, service);
|
|
} catch (e) {
|
|
console.warn('[WHIPPublications] autoStreamKey error (non-fatal):', e.message);
|
|
}
|
|
await restreamer.StartEgress(channelid, id);
|
|
await updateDestinations();
|
|
};
|
|
|
|
const handleStop = (id) => async () => {
|
|
await restreamer.StopEgress(channelid, id);
|
|
await updateDestinations();
|
|
};
|
|
|
|
const handleDelete = (id) => async () => {
|
|
await restreamer.DeleteEgress(channelid, id);
|
|
sourceCache.current.delete(id);
|
|
setDestinations((prev) => prev.filter((d) => d.id !== id));
|
|
};
|
|
|
|
const handleToggleLog = async (id, state) => {
|
|
if (showLog[id]) {
|
|
setShowLog((prev) => ({ ...prev, [id]: false }));
|
|
return;
|
|
}
|
|
if (state === 'failed') {
|
|
try {
|
|
const log = await restreamer.GetEgressLog(channelid, id);
|
|
const lines = Array.isArray(log) ? log.map((l) => l.data || l).join('\n') : String(log);
|
|
setLogs((prev) => ({ ...prev, [id]: lines }));
|
|
} catch (_) {}
|
|
}
|
|
setShowLog((prev) => ({ ...prev, [id]: true }));
|
|
};
|
|
|
|
const handleStartAll = async () => {
|
|
for (const d of destinations) {
|
|
try { await refreshEgressStreamKey(restreamer, channelid, d.id, d.service); } catch (_) {}
|
|
await restreamer.StartEgress(channelid, d.id);
|
|
}
|
|
await updateDestinations();
|
|
};
|
|
|
|
const handleStopAll = async () => {
|
|
await Promise.all(destinations.map((d) => restreamer.StopEgress(channelid, d.id)));
|
|
await updateDestinations();
|
|
};
|
|
|
|
// ── WHEP preview ──────────────────────────────────────────────────────────
|
|
const startPreview = React.useCallback(async () => {
|
|
if (!whepUrl) return;
|
|
setPreviewError('');
|
|
try {
|
|
const pc = new RTCPeerConnection({ iceServers: [] });
|
|
|
|
pc.ontrack = (e) => {
|
|
if (!e.streams?.[0]) return;
|
|
streamRef.current = e.streams[0];
|
|
if (videoRef.current) {
|
|
videoRef.current.srcObject = e.streams[0];
|
|
videoRef.current.play().catch(() => {});
|
|
}
|
|
};
|
|
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
if (pc.iceGatheringState !== 'complete') {
|
|
await new Promise((resolve) => {
|
|
const tid = setTimeout(resolve, 4000);
|
|
const handler = () => {
|
|
if (pc.iceGatheringState === 'complete') {
|
|
clearTimeout(tid);
|
|
pc.removeEventListener('icegatheringstatechange', handler);
|
|
resolve();
|
|
}
|
|
};
|
|
pc.addEventListener('icegatheringstatechange', handler);
|
|
});
|
|
}
|
|
|
|
const headers = { 'Content-Type': 'application/sdp' };
|
|
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
|
|
const resp = await fetch(whepUrl, { method: 'POST', headers, body: pc.localDescription.sdp });
|
|
if (!resp.ok) throw new Error(`WHEP ${resp.status}`);
|
|
|
|
locationRef.current = resp.headers.get('Location');
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: await resp.text() });
|
|
|
|
pcRef.current = pc;
|
|
setPreviewActive(true);
|
|
} catch (err) {
|
|
setPreviewError(err.message);
|
|
}
|
|
}, [whepUrl, bearerToken]);
|
|
|
|
React.useEffect(() => {
|
|
if (previewActive && videoRef.current && streamRef.current) {
|
|
videoRef.current.srcObject = streamRef.current;
|
|
videoRef.current.play().catch(() => {});
|
|
}
|
|
}, [previewActive]);
|
|
|
|
const stopPreview = React.useCallback(async () => {
|
|
if (pcRef.current) { pcRef.current.close(); pcRef.current = null; }
|
|
if (locationRef.current && whepUrl) {
|
|
const base = whepUrl.replace(/\/whep$/, '');
|
|
try { await fetch(base + locationRef.current, { method: 'DELETE' }); } catch (_) {}
|
|
locationRef.current = null;
|
|
}
|
|
if (videoRef.current) videoRef.current.srcObject = null;
|
|
setPreviewActive(false);
|
|
}, [whepUrl]);
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
return (
|
|
<Paper marginBottom="0" style={{ marginTop: 16 }}>
|
|
<PaperHeader
|
|
title={
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<StreamIcon fontSize="small" />
|
|
<Trans>WHIP Publications</Trans>
|
|
</span>
|
|
}
|
|
onAdd={handleAdd}
|
|
/>
|
|
<Grid container spacing={2} style={{ padding: '0 16px 16px' }}>
|
|
|
|
{/* ── RTSP relay warning ──────────────────────────────────── */}
|
|
{rtspEnabled === false && (
|
|
<Grid item xs={12}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'flex-start', gap: 8,
|
|
background: 'rgba(243,156,18,0.12)', border: '1px solid rgba(243,156,18,0.4)',
|
|
borderRadius: 6, padding: '10px 12px',
|
|
}}>
|
|
<WarningAmberIcon style={{ color: '#f39c12', marginTop: 1, flexShrink: 0 }} />
|
|
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.75)' }}>
|
|
<Trans>
|
|
RTSP relay not active. Enable it in Settings → WHIP Server → Internal RTSP relay port (e.g. 8554)
|
|
to publish to multiple platforms simultaneously.
|
|
</Trans>
|
|
</Typography>
|
|
</div>
|
|
</Grid>
|
|
)}
|
|
|
|
{/* ── WHIP stream status + preview controls ───────────────── */}
|
|
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<Typography variant="caption" style={{ opacity: 0.6 }}>
|
|
<Trans>OBS:</Trans>
|
|
</Typography>
|
|
{whipActive ? (
|
|
<Chip
|
|
size="small"
|
|
icon={<FiberManualRecordIcon style={{ color: '#27ae60', fontSize: 12 }} />}
|
|
label={<Trans>Live — {channelid}</Trans>}
|
|
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
|
/>
|
|
) : (
|
|
<Chip
|
|
size="small"
|
|
icon={<FiberManualRecordIcon style={{ fontSize: 12, opacity: 0.4 }} />}
|
|
label={<Trans>Waiting for OBS…</Trans>}
|
|
style={{ backgroundColor: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.45)' }}
|
|
/>
|
|
)}
|
|
|
|
{/* Preview toggle — only useful when OBS is live */}
|
|
{whepUrl && whipActive && (
|
|
<Tooltip title={previewActive ? <Trans>Stop preview</Trans> : <Trans>Preview WHIP stream</Trans>}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={previewActive ? stopPreview : startPreview}
|
|
style={{ marginLeft: 4 }}
|
|
>
|
|
{previewActive ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
|
|
{destinations.length > 0 && (
|
|
<span style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
|
<Button size="small" variant="outlined" startIcon={<PlayArrowIcon />} onClick={handleStartAll}>
|
|
<Trans>Start all</Trans>
|
|
</Button>
|
|
<Button size="small" variant="outlined" color="secondary" startIcon={<StopIcon />} onClick={handleStopAll}>
|
|
<Trans>Stop all</Trans>
|
|
</Button>
|
|
</span>
|
|
)}
|
|
</Grid>
|
|
|
|
{/* ── WHEP inline preview ──────────────────────────────────── */}
|
|
{previewError && (
|
|
<Grid item xs={12}>
|
|
<Typography variant="caption" color="error">{previewError}</Typography>
|
|
</Grid>
|
|
)}
|
|
{whipActive && (
|
|
<Grid item xs={12} style={{ display: previewActive ? 'block' : 'none' }}>
|
|
<video
|
|
ref={videoRef}
|
|
autoPlay
|
|
playsInline
|
|
muted
|
|
controls
|
|
style={{ width: '100%', maxHeight: 360, borderRadius: 4, background: '#000' }}
|
|
/>
|
|
</Grid>
|
|
)}
|
|
|
|
{/* ── Destination list ──────────────────────────────────────── */}
|
|
{destinations.length === 0 ? (
|
|
<Grid item xs={12}>
|
|
<Typography variant="body2" style={{ opacity: 0.5, textAlign: 'center', padding: '12px 0' }}>
|
|
<Trans>No WHIP destinations. Click + to add one via the Publication wizard.</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
) : (
|
|
destinations.map((d) => (
|
|
<Grid item xs={12} key={d.id}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
background: 'rgba(255,255,255,0.04)', borderRadius: 6, padding: '8px 12px',
|
|
flexWrap: 'wrap',
|
|
}}>
|
|
{/* Platform icon / name */}
|
|
<Typography variant="body2" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{d.name || d.service}
|
|
</Typography>
|
|
|
|
{/* State badge */}
|
|
<StateBadge order={d.order} state={d.state} />
|
|
|
|
{/* Actions */}
|
|
{d.order !== 'start' ? (
|
|
<Tooltip title={<Trans>Start</Trans>}>
|
|
<IconButton size="small" onClick={handleStart(d.id, d.service)}>
|
|
<PlayArrowIcon fontSize="small" style={{ color: '#27ae60' }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip title={<Trans>Stop</Trans>}>
|
|
<IconButton size="small" onClick={handleStop(d.id)}>
|
|
<StopIcon fontSize="small" style={{ color: '#e74c3c' }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
|
|
<Tooltip title={<Trans>Edit</Trans>}>
|
|
<IconButton size="small" onClick={handleEdit(d.service, d.index)}>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
<Tooltip title={d.state === 'failed' ? <Trans>Show error</Trans> : <Trans>Delete</Trans>}>
|
|
<IconButton
|
|
size="small"
|
|
onClick={d.state === 'failed'
|
|
? () => handleToggleLog(d.id, d.state)
|
|
: handleDelete(d.id)}
|
|
>
|
|
{d.state === 'failed'
|
|
? <ErrorOutlineIcon fontSize="small" style={{ color: '#e74c3c' }} />
|
|
: <DeleteIcon fontSize="small" />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
{d.state !== 'failed' && (
|
|
<Tooltip title={<Trans>Delete</Trans>}>
|
|
<IconButton size="small" onClick={handleDelete(d.id)}>
|
|
<DeleteIcon fontSize="small" style={{ opacity: 0.5 }} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error log */}
|
|
{showLog[d.id] && (
|
|
<pre style={{
|
|
margin: '4px 0 0 0', padding: '8px 12px',
|
|
background: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.3)',
|
|
borderRadius: 4, fontSize: 11, color: '#e74c3c',
|
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 200, overflow: 'auto',
|
|
}}>
|
|
{logs[d.id] || '—'}
|
|
</pre>
|
|
)}
|
|
</Grid>
|
|
))
|
|
)}
|
|
</Grid>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
WHIPPublications.defaultProps = {
|
|
channelid: '',
|
|
restreamer: null,
|
|
};
|