restreamer-ui-v2/src/views/Main/WHIPPublications.js
Cesar Mendivil 5c586a2aa3 feat: add WHIP-RTSP support for multi-platform streaming
- 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.
2026-03-15 17:04:54 -07:00

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,
};