923 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. 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 { 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 Cores 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,
};