923 lines
31 KiB
JavaScript
923 lines
31 KiB
JavaScript
import React from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
|
||
import { Trans } from '@lingui/macro';
|
||
import makeStyles from '@mui/styles/makeStyles';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
import Grid from '@mui/material/Grid';
|
||
import Link from '@mui/material/Link';
|
||
import Stack from '@mui/material/Stack';
|
||
import Typography from '@mui/material/Typography';
|
||
import WarningIcon from '@mui/icons-material/Warning';
|
||
import ScreenShareIcon from '@mui/icons-material/ScreenShare';
|
||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||
import StopIcon from '@mui/icons-material/Stop';
|
||
import Button from '@mui/material/Button';
|
||
|
||
import * as M from '../../utils/metadata';
|
||
import { anonymize } from '../../utils/anonymizer';
|
||
import useInterval from '../../hooks/useInterval';
|
||
import ActionButton from '../../misc/ActionButton';
|
||
import CopyButton from '../../misc/CopyButton';
|
||
import DebugModal from '../../misc/modals/Debug';
|
||
import H from '../../utils/help';
|
||
import Paper from '../../misc/Paper';
|
||
import PaperHeader from '../../misc/PaperHeader';
|
||
import Player from '../../misc/Player';
|
||
import Progress from './Progress';
|
||
import Publication from './Publication';
|
||
import ProcessModal from '../../misc/modals/Process';
|
||
import Welcome from '../Welcome';
|
||
|
||
const useStyles = makeStyles((theme) => ({
|
||
gridContainerL1: {
|
||
marginBottom: '6em',
|
||
},
|
||
gridContainerL2: {
|
||
paddingTop: '.6em',
|
||
},
|
||
link: {
|
||
marginLeft: 10,
|
||
},
|
||
playerL1: {
|
||
paddingTop: 10,
|
||
paddingLeft: 18
|
||
},
|
||
playerL2: {
|
||
position: 'relative',
|
||
width: '100%',
|
||
paddingTop: '56.25%',
|
||
},
|
||
playerL3: {
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
bottom: 0,
|
||
right: 0,
|
||
backgroundColor: theme.palette.common.black,
|
||
},
|
||
playerWarningIcon: {
|
||
color: theme.palette.warning.main,
|
||
fontSize: 'xxx-large',
|
||
},
|
||
webrtcPanel: {
|
||
position: 'absolute',
|
||
top: 0, left: 0, bottom: 0, right: 0,
|
||
backgroundColor: theme.palette.common.black,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 12,
|
||
padding: 16,
|
||
},
|
||
webrtcLiveDot: {
|
||
display: 'inline-block',
|
||
width: 10, height: 10,
|
||
borderRadius: '50%',
|
||
backgroundColor: '#2ecc71',
|
||
marginRight: 6,
|
||
animation: '$pulse 1.2s ease-in-out infinite',
|
||
},
|
||
whepVideo: {
|
||
position: 'absolute',
|
||
top: 0, left: 0,
|
||
width: '100%', height: '100%',
|
||
background: '#000',
|
||
objectFit: 'contain',
|
||
},
|
||
whepOverlay: {
|
||
position: 'absolute',
|
||
top: 0, left: 0, bottom: 0, right: 0,
|
||
backgroundColor: theme.palette.common.black,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 8,
|
||
},
|
||
'@keyframes pulse': {
|
||
'0%, 100%': { opacity: 1 },
|
||
'50%': { opacity: 0.3 },
|
||
},
|
||
}));
|
||
|
||
export default function Main(props) {
|
||
const classes = useStyles();
|
||
const navigate = useNavigate();
|
||
const { channelid: _channelid } = useParams();
|
||
const [$state, setState] = React.useState({
|
||
ready: false,
|
||
valid: false,
|
||
progress: {},
|
||
state: 'disconnected',
|
||
onConnect: null,
|
||
});
|
||
const [$metadata, setMetadata] = React.useState(M.getDefaultIngestMetadata());
|
||
const [$processDetails, setProcessDetails] = React.useState({
|
||
open: false,
|
||
data: {
|
||
prelude: [],
|
||
log: [],
|
||
},
|
||
});
|
||
// WHIP/WHEP source state
|
||
const [$whipSource, setWhipSource] = React.useState({
|
||
active: false,
|
||
whepUrl: '',
|
||
bearerToken: '',
|
||
previewActive: false,
|
||
previewError: '',
|
||
whepConnState: 'idle', // idle | gathering | connecting | live | error
|
||
});
|
||
const whepPcRef = React.useRef(null);
|
||
const whepLocationRef = React.useRef(null);
|
||
const whepVideoRef = React.useRef(null);
|
||
const whepStreamRef = React.useRef(null); // holds MediaStream across re-renders
|
||
|
||
// WebRTC Room detection
|
||
const [$webrtcRoom, setWebrtcRoom] = React.useState({
|
||
active: false, // source type = webrtcroom
|
||
roomUrl: '',
|
||
roomId: '',
|
||
copied: false,
|
||
relayActive: false, // hay sesión FFmpeg activa en el relay
|
||
sessions: [], // sesiones activas del relay
|
||
});
|
||
const processLogTimer = React.useRef();
|
||
const [$processDebug, setProcessDebug] = React.useState({
|
||
open: false,
|
||
data: '',
|
||
});
|
||
const [$config, setConfig] = React.useState(null);
|
||
const [$invalid, setInvalid] = React.useState(false);
|
||
|
||
useInterval(async () => {
|
||
await update();
|
||
}, 1000);
|
||
|
||
// Poll relay sessions when source is webrtcroom
|
||
useInterval(async () => {
|
||
if (!$webrtcRoom.active) return;
|
||
try {
|
||
const resp = await fetch('/webrtc-relay/status', { signal: AbortSignal.timeout(2000) });
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
const sessions = data.sessions || [];
|
||
const roomSessions = sessions.filter(
|
||
(s) => !$webrtcRoom.roomId || s.roomId === $webrtcRoom.roomId
|
||
);
|
||
setWebrtcRoom((prev) => ({
|
||
...prev,
|
||
relayActive: roomSessions.length > 0,
|
||
sessions: roomSessions,
|
||
}));
|
||
}
|
||
} catch (_) {}
|
||
}, 2000);
|
||
|
||
React.useEffect(() => {
|
||
(async () => {
|
||
await load();
|
||
await update();
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if ($invalid === true) {
|
||
navigate('/', { replace: true });
|
||
}
|
||
}, [navigate, $invalid]);
|
||
|
||
const load = async () => {
|
||
const config = props.restreamer.ConfigActive();
|
||
setConfig(config);
|
||
|
||
const metadata = await props.restreamer.GetIngestMetadata(_channelid);
|
||
setMetadata({
|
||
...$metadata,
|
||
...metadata,
|
||
});
|
||
|
||
// Detect if the video source is a WebRTC Room
|
||
const videoSource = metadata.sources && metadata.sources[0];
|
||
const isWhip = videoSource?.type === 'network'
|
||
&& videoSource?.settings?.mode === 'push'
|
||
&& videoSource?.settings?.push?.type === 'whip';
|
||
|
||
if (videoSource && videoSource.type === 'webrtcroom') {
|
||
const settings = videoSource.settings || {};
|
||
const roomId = settings.roomId || _channelid;
|
||
const origin = window.location.origin;
|
||
const roomUrl = `${origin}/webrtc-room/?room=${encodeURIComponent(roomId)}`;
|
||
setWebrtcRoom({
|
||
active: true,
|
||
roomUrl,
|
||
roomId,
|
||
copied: false,
|
||
});
|
||
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
|
||
} else if (isWhip) {
|
||
const name = videoSource.settings.push.name || _channelid;
|
||
const token = config?.source?.network?.whip?.token || '';
|
||
let whepUrl = '';
|
||
if (props.restreamer.WhipStreamUrl) {
|
||
const data = await props.restreamer.WhipStreamUrl(name);
|
||
whepUrl = data?.whep_url || '';
|
||
}
|
||
if (!whepUrl) {
|
||
const whipHost = config?.source?.network?.whip?.host || 'localhost:8555';
|
||
whepUrl = `http://${whipHost}/whip/${name}/whep`;
|
||
}
|
||
setWhipSource({ active: true, whepUrl, bearerToken: token, previewActive: false, previewError: '' });
|
||
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
|
||
} else {
|
||
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
|
||
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
|
||
}
|
||
|
||
await update();
|
||
};
|
||
|
||
const update = async () => {
|
||
const channelid = props.restreamer.SelectChannel(_channelid);
|
||
if (channelid === '' || channelid !== _channelid) {
|
||
setInvalid(true);
|
||
return;
|
||
}
|
||
|
||
const progress = await props.restreamer.GetIngestProgress(_channelid);
|
||
|
||
const state = {
|
||
...$state,
|
||
ready: true,
|
||
valid: progress.valid,
|
||
progress: progress,
|
||
state: progress.state,
|
||
};
|
||
|
||
if (state.state === 'connecting') {
|
||
if (state.onConnect === null) {
|
||
state.onConnect = async () => {
|
||
await props.restreamer.StopIngestSnapshot(_channelid);
|
||
await props.restreamer.StartIngestSnapshot(_channelid);
|
||
};
|
||
}
|
||
} else if (state.state === 'connected') {
|
||
if (state.onConnect !== null && typeof state.onConnect === 'function') {
|
||
const onConnect = state.onConnect;
|
||
setTimeout(async () => {
|
||
await onConnect();
|
||
}, 100);
|
||
state.onConnect = null;
|
||
}
|
||
}
|
||
|
||
if ($metadata.control.rtmp.enable) {
|
||
if (!$config.source.network.rtmp.enabled) {
|
||
state.state = 'error';
|
||
state.progress.error = 'RTMP server is not enabled, but required.';
|
||
}
|
||
} else if ($metadata.control.srt.enable) {
|
||
if (!$config.source.network.srt.enabled) {
|
||
state.state = 'error';
|
||
state.progress.error = 'SRT server is not enabled, but required.';
|
||
}
|
||
}
|
||
|
||
setState({
|
||
...$state,
|
||
...state,
|
||
});
|
||
};
|
||
|
||
const connect = async () => {
|
||
setState({
|
||
...$state,
|
||
state: 'connecting',
|
||
onConnect: async () => {
|
||
await props.restreamer.StopIngestSnapshot(_channelid);
|
||
await props.restreamer.StartIngestSnapshot(_channelid);
|
||
},
|
||
});
|
||
|
||
await props.restreamer.StartIngest(_channelid);
|
||
await props.restreamer.StartIngestSnapshot(_channelid);
|
||
};
|
||
|
||
const disconnect = async () => {
|
||
setState({
|
||
...$state,
|
||
state: 'disconnecting',
|
||
});
|
||
|
||
await props.restreamer.StopIngestSnapshot(_channelid);
|
||
await props.restreamer.StopIngest(_channelid);
|
||
|
||
await disconnectEgresses();
|
||
};
|
||
|
||
const reconnect = async () => {
|
||
await disconnect();
|
||
await connect();
|
||
};
|
||
|
||
const disconnectEgresses = async () => {
|
||
await props.restreamer.StopAllEgresses(_channelid);
|
||
};
|
||
|
||
const handleProcessDetails = async (event) => {
|
||
event.preventDefault();
|
||
|
||
const open = !$processDetails.open;
|
||
let logdata = {
|
||
prelude: [],
|
||
log: [],
|
||
};
|
||
|
||
if (open === true) {
|
||
const data = await props.restreamer.GetIngestLog(_channelid);
|
||
if (data !== null) {
|
||
logdata = data;
|
||
}
|
||
|
||
processLogTimer.current = setInterval(async () => {
|
||
await updateProcessDetailsLog();
|
||
}, 1000);
|
||
} else {
|
||
clearInterval(processLogTimer.current);
|
||
}
|
||
|
||
setProcessDetails({
|
||
...$processDetails,
|
||
open: open,
|
||
data: logdata,
|
||
});
|
||
};
|
||
|
||
const updateProcessDetailsLog = async () => {
|
||
const data = await props.restreamer.GetIngestLog(_channelid);
|
||
if (data !== null) {
|
||
setProcessDetails({
|
||
...$processDetails,
|
||
open: true,
|
||
data: data,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleProcessDebug = async (event) => {
|
||
event.preventDefault();
|
||
|
||
let data = '';
|
||
|
||
if ($processDebug.open === false) {
|
||
const debug = await props.restreamer.GetIngestDebug(_channelid);
|
||
data = JSON.stringify(debug, null, 2);
|
||
}
|
||
|
||
setProcessDebug({
|
||
...$processDebug,
|
||
open: !$processDebug.open,
|
||
data: data,
|
||
});
|
||
};
|
||
|
||
// ── WHEP preview ─────────────────────────────────────────────────────────
|
||
const startWhepPreview = React.useCallback(async () => {
|
||
setWhipSource((prev) => ({ ...prev, previewError: '', whepConnState: 'gathering' }));
|
||
try {
|
||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||
|
||
// ① ontrack set BEFORE createOffer — tracks fire as soon as ICE+DTLS finishes
|
||
pc.ontrack = (e) => {
|
||
if (!e.streams?.[0]) return;
|
||
whepStreamRef.current = e.streams[0];
|
||
if (whepVideoRef.current) {
|
||
whepVideoRef.current.srcObject = e.streams[0];
|
||
whepVideoRef.current.play().catch(() => {});
|
||
}
|
||
};
|
||
|
||
// ICE failure feedback
|
||
pc.oniceconnectionstatechange = () => {
|
||
const s = pc.iceConnectionState;
|
||
if (s === 'connected' || s === 'completed') {
|
||
setWhipSource((prev) => ({ ...prev, whepConnState: 'live' }));
|
||
} else if (s === 'failed') {
|
||
setWhipSource((prev) => ({
|
||
...prev,
|
||
whepConnState: 'error',
|
||
previewActive: false,
|
||
previewError: 'ICE failed — check that port 8555 UDP is reachable from this browser',
|
||
}));
|
||
} else if (s === 'disconnected') {
|
||
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewActive: false }));
|
||
}
|
||
};
|
||
|
||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||
const offer = await pc.createOffer();
|
||
await pc.setLocalDescription(offer);
|
||
|
||
// ② Wait for ICE gathering to complete (max 4 s) before POSTing.
|
||
// This ensures local candidates are included in the offer SDP,
|
||
// which is required for Core’s ICE-lite WHEP server to reach the browser.
|
||
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);
|
||
});
|
||
}
|
||
|
||
setWhipSource((prev) => ({ ...prev, whepConnState: 'connecting' }));
|
||
const headers = { 'Content-Type': 'application/sdp' };
|
||
if ($whipSource.bearerToken) headers['Authorization'] = `Bearer ${$whipSource.bearerToken}`;
|
||
// ③ Use pc.localDescription.sdp — now contains gathered ICE candidates
|
||
const resp = await fetch($whipSource.whepUrl, {
|
||
method: 'POST', headers, body: pc.localDescription.sdp,
|
||
});
|
||
if (!resp.ok) throw new Error(`WHEP ${resp.status}`);
|
||
whepLocationRef.current = resp.headers.get('Location');
|
||
const answerSDP = await resp.text();
|
||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
|
||
whepPcRef.current = pc;
|
||
setWhipSource((prev) => ({ ...prev, previewActive: true }));
|
||
} catch (err) {
|
||
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewError: err.message }));
|
||
}
|
||
}, [$whipSource.whepUrl, $whipSource.bearerToken]);
|
||
|
||
// ② After previewActive → true React renders <video>. Apply the stream
|
||
// that may have arrived (ontrack) before the DOM node existed.
|
||
React.useEffect(() => {
|
||
if ($whipSource.previewActive && whepVideoRef.current && whepStreamRef.current) {
|
||
whepVideoRef.current.srcObject = whepStreamRef.current;
|
||
whepVideoRef.current.play().catch(() => {});
|
||
}
|
||
}, [$whipSource.previewActive]);
|
||
|
||
const stopWhepPreview = React.useCallback(async () => {
|
||
if (whepPcRef.current) {
|
||
whepPcRef.current.close();
|
||
whepPcRef.current = null;
|
||
}
|
||
if (whepLocationRef.current && $whipSource.whepUrl) {
|
||
const base = $whipSource.whepUrl.replace(/\/whep$/, '');
|
||
const deleteUrl = base + whepLocationRef.current;
|
||
try { await fetch(deleteUrl, { method: 'DELETE' }); } catch (_) {}
|
||
whepLocationRef.current = null;
|
||
}
|
||
if (whepVideoRef.current) {
|
||
whepVideoRef.current.srcObject = null;
|
||
}
|
||
whepStreamRef.current = null;
|
||
setWhipSource((prev) => ({ ...prev, previewActive: false, whepConnState: 'idle', previewError: '' }));
|
||
}, [$whipSource.whepUrl]);
|
||
|
||
// Stop preview if stream goes offline
|
||
React.useEffect(() => {
|
||
if ($state.state !== 'connected' && $whipSource.previewActive) {
|
||
stopWhepPreview();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [$state.state]);
|
||
|
||
// Cleanup on unmount
|
||
React.useEffect(() => {
|
||
return () => { if (whepPcRef.current) whepPcRef.current.close(); };
|
||
}, []);
|
||
|
||
// ── Go Live: start ingest + all configured egresses ───────────────────────
|
||
const connectAndStartPublications = React.useCallback(async () => {
|
||
await connect();
|
||
// Give FFmpeg a moment to start reading the WHIP source before launching egresses
|
||
setTimeout(async () => {
|
||
try {
|
||
const processes = await props.restreamer.ListIngestEgresses(_channelid);
|
||
for (const p of processes) {
|
||
if (p.service === 'player') continue;
|
||
try { await props.restreamer.StartEgress(_channelid, p.id); } catch (_) {}
|
||
}
|
||
} catch (_) {}
|
||
}, 3000);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [_channelid, props.restreamer]);
|
||
|
||
const handleHelp = (topic) => () => {
|
||
H(topic);
|
||
};
|
||
|
||
const handleCopyRoomUrl = () => {
|
||
if (navigator.clipboard && $webrtcRoom.roomUrl) {
|
||
navigator.clipboard.writeText($webrtcRoom.roomUrl).then(() => {
|
||
setWebrtcRoom((prev) => ({ ...prev, copied: true }));
|
||
setTimeout(() => setWebrtcRoom((prev) => ({ ...prev, copied: false })), 2000);
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleOpenRoom = () => {
|
||
if ($webrtcRoom.roomUrl) {
|
||
// Start publications (egresses) for this channel before opening the Room
|
||
(async () => {
|
||
try {
|
||
const processes = await props.restreamer.ListIngestEgresses(_channelid);
|
||
for (let p of processes) {
|
||
// skip player entry
|
||
if (p.service === 'player') continue;
|
||
try {
|
||
await props.restreamer.StartEgress(_channelid, p.id);
|
||
} catch (e) {
|
||
console.warn('[Main] StartEgress error:', e.message || e);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[Main] Error starting publications:', e.message || e);
|
||
}
|
||
})();
|
||
|
||
const w = 820, h = 700;
|
||
const left = Math.max(0, Math.round(window.screen.width / 2 - w / 2));
|
||
const top = Math.max(0, Math.round(window.screen.height / 2 - h / 2));
|
||
window.open(
|
||
$webrtcRoom.roomUrl,
|
||
'webrtc-room-' + ($webrtcRoom.roomId || 'default'),
|
||
`width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,status=no`
|
||
);
|
||
}
|
||
};
|
||
|
||
if ($state.ready === false) {
|
||
return (
|
||
<Paper xs={8} sm={6} md={4} className="PaperM">
|
||
<Grid container justifyContent="center" spacing={2} align="center">
|
||
<Grid item xs={12}>
|
||
<CircularProgress color="primary" />
|
||
</Grid>
|
||
<Grid item xs={12}>
|
||
<Trans>Retrieving stream data ...</Trans>
|
||
</Grid>
|
||
</Grid>
|
||
</Paper>
|
||
);
|
||
}
|
||
|
||
if ($state.valid === false) {
|
||
return <Welcome />;
|
||
}
|
||
|
||
const storage = $metadata.control.hls.storage;
|
||
const channel = props.restreamer.GetChannel(_channelid);
|
||
const manifest = props.restreamer.GetChannelAddress('hls+' + storage, _channelid);
|
||
const poster = props.restreamer.GetChannelAddress('snapshot+' + storage, _channelid);
|
||
|
||
let title = <Trans>Main channel</Trans>;
|
||
if (channel && channel.name && channel.name.length !== 0) {
|
||
title = channel.name;
|
||
}
|
||
|
||
return (
|
||
<React.Fragment>
|
||
<Grid container justifyContent="center" spacing={1} className={classes.gridContainerL1}>
|
||
<Grid item xs={12} sm={12} md={8}>
|
||
<Paper marginBottom="0">
|
||
<PaperHeader title={title} onEdit={() => navigate(`/${_channelid}/edit`)} onHelp={handleHelp('main')} />
|
||
<Grid container spacing={1} className={classes.gridContainerL2}>
|
||
<Grid item xs={12}>
|
||
<Grid container spacing={0} className={classes.playerL1}>
|
||
<Grid item xs={12} className={classes.playerL2}>
|
||
{/* ── WHIP source → WHEP real-time preview ── */}
|
||
{$whipSource.active ? (
|
||
<React.Fragment>
|
||
{($state.state === 'disconnected' || $state.state === 'disconnecting') && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item><Typography variant="h2"><Trans>No video</Trans></Typography></Grid>
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'connecting' && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item><CircularProgress color="inherit" /></Grid>
|
||
<Grid item><Typography><Trans>Connecting ...</Trans></Typography></Grid>
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'error' && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item><WarningIcon className={classes.playerWarningIcon} /></Grid>
|
||
<Grid item>
|
||
<Typography><Trans>Error: {anonymize($state.progress.error) || 'unknown'}</Trans></Typography>
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'connected' && (
|
||
<React.Fragment>
|
||
{/*
|
||
Video always in DOM (no display:none) so whepVideoRef is
|
||
set and autoPlay works reliably across all browsers.
|
||
The whepOverlay sits on top until the stream is live.
|
||
*/}
|
||
<video
|
||
ref={whepVideoRef}
|
||
autoPlay
|
||
playsInline
|
||
muted
|
||
controls
|
||
className={classes.whepVideo}
|
||
/>
|
||
{!$whipSource.previewActive && (
|
||
<div className={classes.whepOverlay}>
|
||
{($whipSource.whepConnState === 'gathering' || $whipSource.whepConnState === 'connecting') ? (
|
||
<React.Fragment>
|
||
<CircularProgress color="inherit" size={36} />
|
||
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.6)', marginTop: 8 }}>
|
||
{$whipSource.whepConnState === 'gathering'
|
||
? <Trans>Gathering network candidates…</Trans>
|
||
: <Trans>Connecting via WebRTC…</Trans>}
|
||
</Typography>
|
||
</React.Fragment>
|
||
) : (
|
||
<React.Fragment>
|
||
<Button
|
||
variant="contained"
|
||
startIcon={<PlayArrowIcon />}
|
||
onClick={startWhepPreview}
|
||
style={{ background: '#27ae60' }}
|
||
>
|
||
<Trans>Live Preview</Trans>
|
||
</Button>
|
||
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||
<Trans>Real-time via WHEP (~300ms)</Trans>
|
||
</Typography>
|
||
{$whipSource.previewError && (
|
||
<Typography variant="caption" color="error" style={{ maxWidth: 300, textAlign: 'center' }}>
|
||
{$whipSource.previewError}
|
||
</Typography>
|
||
)}
|
||
</React.Fragment>
|
||
)}
|
||
</div>
|
||
)}
|
||
{$whipSource.previewActive && (
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
startIcon={<StopIcon />}
|
||
onClick={stopWhepPreview}
|
||
style={{ position: 'absolute', top: 8, right: 8, zIndex: 10, opacity: 0.85 }}
|
||
>
|
||
<Trans>Stop</Trans>
|
||
</Button>
|
||
)}
|
||
</React.Fragment>
|
||
)}
|
||
</React.Fragment>
|
||
) : $webrtcRoom.active ? (
|
||
/* ── WebRTC Room source ── */
|
||
$webrtcRoom.relayActive && $state.state === 'connected' ? (
|
||
/* Relay activo → mostrar HLS preview normal */
|
||
<Player type="videojs-internal" source={manifest} poster={poster} autoplay mute controls />
|
||
) : (
|
||
/* Relay inactivo → panel de control */
|
||
<div className={classes.webrtcPanel}>
|
||
<ScreenShareIcon style={{ fontSize: '3rem', color: '#4f8ef7', opacity: 0.8 }} />
|
||
<Typography variant="h3" style={{ color: '#e0e0ee', textAlign: 'center' }}>
|
||
<Trans>WebRTC Room</Trans>
|
||
</Typography>
|
||
<Typography variant="body2" style={{ color: '#6e6e8a', textAlign: 'center', maxWidth: 320 }}>
|
||
{$state.state === 'connecting' ? (
|
||
<Trans>Esperando señal del presentador…</Trans>
|
||
) : $state.state === 'connected' ? (
|
||
<Trans>Canal activo — esperando que el presentador inicie la transmisión en la sala.</Trans>
|
||
) : (
|
||
<Trans>Comparte el enlace de la sala con el presentador para iniciar la transmisión.</Trans>
|
||
)}
|
||
</Typography>
|
||
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
|
||
<Button
|
||
variant="contained"
|
||
size="small"
|
||
startIcon={<OpenInNewIcon />}
|
||
onClick={handleOpenRoom}
|
||
style={{ background: '#4f8ef7', color: '#fff' }}
|
||
>
|
||
<Trans>Abrir sala</Trans>
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
size="small"
|
||
startIcon={<ContentCopyIcon />}
|
||
onClick={handleCopyRoomUrl}
|
||
style={{ borderColor: '#4f8ef7', color: '#4f8ef7' }}
|
||
>
|
||
{$webrtcRoom.copied ? <Trans>¡Copiado!</Trans> : <Trans>Copiar URL</Trans>}
|
||
</Button>
|
||
</Stack>
|
||
{$webrtcRoom.relayActive && (
|
||
<Typography variant="caption" style={{ color: '#2ecc71' }}>
|
||
<span className={classes.webrtcLiveDot} />
|
||
<Trans>Relay activo</Trans>
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
)
|
||
) : (
|
||
/* Source normal: estados HLS estándar */
|
||
<React.Fragment>
|
||
{($state.state === 'disconnected' || $state.state === 'disconnecting') && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item>
|
||
<Typography variant="h2"><Trans>No video</Trans></Typography>
|
||
</Grid>
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'connecting' && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item><CircularProgress color="inherit" /></Grid>
|
||
<Grid item><Typography><Trans>Connecting ...</Trans></Typography></Grid>
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'error' && (
|
||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||
<Grid item><WarningIcon className={classes.playerWarningIcon} /></Grid>
|
||
<Grid item>
|
||
<Typography>
|
||
<Trans>Error: {anonymize($state.progress.error) || 'unknown'}</Trans>
|
||
</Typography>
|
||
</Grid>
|
||
<Grid item>
|
||
<Typography>
|
||
<Trans>
|
||
Please check the{' '}
|
||
<Link href="#!" onClick={handleProcessDetails}>process log</Link>
|
||
</Trans>
|
||
</Typography>
|
||
</Grid>
|
||
{$state.progress.reconnect !== -1 && (
|
||
<Grid item><Typography><Trans>Reconnecting in {$state.progress.reconnect}s</Trans></Typography></Grid>
|
||
)}
|
||
{$state.progress.reconnect === -1 && (
|
||
<Grid item><Typography><Trans>You have to reconnect manually</Trans></Typography></Grid>
|
||
)}
|
||
</Grid>
|
||
)}
|
||
{$state.state === 'connected' && (
|
||
<Player type="videojs-internal" source={manifest} poster={poster} autoplay mute controls />
|
||
)}
|
||
</React.Fragment>
|
||
)}
|
||
</Grid>
|
||
</Grid>
|
||
</Grid>
|
||
<Grid item xs={12} marginTop="-.3em">
|
||
<Progress progress={$state.progress} />
|
||
</Grid>
|
||
<Grid item xs={12} marginTop="-.2em">
|
||
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={2}>
|
||
<Typography variant="body">
|
||
{$webrtcRoom.active ? (
|
||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||
<ScreenShareIcon fontSize="small" style={{ color: '#4f8ef7', marginBottom: -3 }} />
|
||
<Trans>WebRTC Room</Trans>
|
||
</Stack>
|
||
) : (
|
||
<Trans>Content URL</Trans>
|
||
)}
|
||
</Typography>
|
||
<Stack direction="row" justifyContent="flex-end" alignItems="center" spacing={0.5}>
|
||
{$webrtcRoom.active ? (
|
||
<React.Fragment>
|
||
<Button
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
startIcon={<ContentCopyIcon />}
|
||
onClick={handleCopyRoomUrl}
|
||
>
|
||
{$webrtcRoom.copied ? <Trans>¡Copiado!</Trans> : <Trans>Room URL</Trans>}
|
||
</Button>
|
||
<Button
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
startIcon={<OpenInNewIcon />}
|
||
onClick={handleOpenRoom}
|
||
>
|
||
<Trans>Open room</Trans>
|
||
</Button>
|
||
</React.Fragment>
|
||
) : (
|
||
<React.Fragment>
|
||
<CopyButton
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
value={props.restreamer.GetPublicAddress('hls+' + storage, _channelid)}
|
||
>
|
||
<Trans>HLS</Trans>
|
||
</CopyButton>
|
||
{$metadata.control.rtmp.enable && (
|
||
<CopyButton
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
value={props.restreamer.GetPublicAddress('rtmp', _channelid)}
|
||
>
|
||
<Trans>RTMP</Trans>
|
||
</CopyButton>
|
||
)}
|
||
{$metadata.control.srt.enable && (
|
||
<CopyButton
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
value={props.restreamer.GetPublicAddress('srt', _channelid)}
|
||
>
|
||
<Trans>SRT</Trans>
|
||
</CopyButton>
|
||
)}
|
||
<CopyButton
|
||
variant="outlined"
|
||
color="default"
|
||
size="small"
|
||
value={props.restreamer.GetPublicAddress('snapshot+memfs', _channelid)}
|
||
>
|
||
<Trans>Snapshot</Trans>
|
||
</CopyButton>
|
||
</React.Fragment>
|
||
)}
|
||
</Stack>
|
||
</Stack>
|
||
</Grid>
|
||
<Grid item xs={12} marginTop="0em">
|
||
{$whipSource.active && ($state.state === 'disconnected' || $state.state === 'disconnecting') ? (
|
||
/* For WHIP sources: single "Go Live" button starts FFmpeg + all egresses */
|
||
<Button
|
||
fullWidth
|
||
variant="contained"
|
||
size="large"
|
||
startIcon={<PlayArrowIcon />}
|
||
onClick={connectAndStartPublications}
|
||
style={{ background: '#27ae60', color: '#fff', fontWeight: 700 }}
|
||
>
|
||
<Trans>Go Live</Trans>
|
||
</Button>
|
||
) : (
|
||
<ActionButton
|
||
order={$state.order}
|
||
state={$state.state}
|
||
reconnect={$state.progress.reconnect}
|
||
onDisconnect={disconnect}
|
||
onConnect={connect}
|
||
onReconnect={reconnect}
|
||
/>
|
||
)}
|
||
</Grid>
|
||
<Grid item xs={12} textAlign="right">
|
||
<Link variant="body2" color="textSecondary" href="#!" onClick={handleProcessDetails} className={classes.link}>
|
||
<Trans>Process details</Trans>
|
||
</Link>
|
||
<Link variant="body2" color="textSecondary" href="#!" onClick={handleProcessDebug} className={classes.link}>
|
||
<Trans>Process report</Trans>
|
||
</Link>
|
||
</Grid>
|
||
</Grid>
|
||
</Paper>
|
||
</Grid>
|
||
<Grid item xs={12} sm={12} md={4}>
|
||
<Publication restreamer={props.restreamer} channelid={_channelid} />
|
||
</Grid>
|
||
</Grid>
|
||
<ProcessModal
|
||
open={$processDetails.open}
|
||
onClose={handleProcessDetails}
|
||
title={<Trans>Process details</Trans>}
|
||
progress={$state.progress}
|
||
logdata={$processDetails.data}
|
||
onHelp={handleHelp('process-details')}
|
||
/>
|
||
<DebugModal
|
||
open={$processDebug.open}
|
||
onClose={handleProcessDebug}
|
||
title={<Trans>Process report</Trans>}
|
||
data={$processDebug.data}
|
||
onHelp={handleHelp('process-report')}
|
||
/>
|
||
</React.Fragment>
|
||
);
|
||
}
|
||
|
||
Main.defaultProps = {
|
||
restreamer: null,
|
||
};
|