645 lines
18 KiB
JavaScript
645 lines
18 KiB
JavaScript
import React from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import PropTypes from 'prop-types';
|
|
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans, t } from '@lingui/macro';
|
|
import makeStyles from '@mui/styles/makeStyles';
|
|
import Backdrop from '@mui/material/Backdrop';
|
|
import Button from '@mui/material/Button';
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
import Grid from '@mui/material/Grid';
|
|
import Link from '@mui/material/Link';
|
|
import Tab from '@mui/material/Tab';
|
|
import Tabs from '@mui/material/Tabs';
|
|
import TextField from '@mui/material/TextField';
|
|
import Typography from '@mui/material/Typography';
|
|
|
|
import * as helper from './helper';
|
|
import * as M from '../../utils/metadata';
|
|
import useInterval from '../../hooks/useInterval';
|
|
import BoxText from '../../misc/BoxText';
|
|
import DebugModal from '../../misc/modals/Debug';
|
|
import Dialog from '../../misc/modals/Dialog';
|
|
import EncodingSelect from '../../misc/EncodingSelect';
|
|
import FilterSelect from '../../misc/FilterSelect';
|
|
import H from '../../utils/help';
|
|
import NotifyContext from '../../contexts/Notify';
|
|
import Paper from '../../misc/Paper';
|
|
import PaperHeader from '../../misc/PaperHeader';
|
|
import PaperFooter from '../../misc/PaperFooter';
|
|
import Process from './Process';
|
|
import ProcessControl from '../../misc/controls/Process';
|
|
import ProcessModal from '../../misc/modals/Process';
|
|
import Services from './Services';
|
|
import SourceControl from '../../misc/controls/Source';
|
|
import TabContent from './TabContent';
|
|
import TabPanel from '../../misc/TabPanel';
|
|
import TabsVerticalGrid from '../../misc/TabsVerticalGrid';
|
|
|
|
const useStyles = makeStyles((theme) => ({
|
|
gridContainer: {
|
|
marginTop: '0.5em',
|
|
marginBottom: '1em',
|
|
},
|
|
link: {
|
|
marginLeft: 10,
|
|
wordWrap: 'anywhere',
|
|
},
|
|
}));
|
|
|
|
export default function Edit(props) {
|
|
const classes = useStyles();
|
|
const { i18n } = useLingui();
|
|
const { channelid: _channelid, service: _service, index: _index } = useParams();
|
|
const id = props.restreamer.GetEgressId(_service, _index);
|
|
const navigate = useNavigate();
|
|
const notify = React.useContext(NotifyContext);
|
|
const [$ready, setReady] = React.useState(false);
|
|
const [$settings, setSettings] = React.useState(M.getDefaultEgressMetadata());
|
|
const [$sources, setSources] = React.useState([]);
|
|
const [$localSources, setLocalSources] = React.useState([]);
|
|
const [$tab, setTab] = React.useState('general');
|
|
const [$progress, setProgress] = React.useState({});
|
|
const [$processDetails, setProcessDetails] = React.useState({
|
|
open: false,
|
|
data: {
|
|
prelude: [],
|
|
log: [],
|
|
},
|
|
});
|
|
const processLogTimer = React.useRef();
|
|
const [$processDebug, setProcessDebug] = React.useState({
|
|
open: false,
|
|
data: '',
|
|
});
|
|
const [$unsavedChanges, setUnsavedChanges] = React.useState(false);
|
|
const [$skills, setSkills] = React.useState(null);
|
|
const [$metadata, setMetadata] = React.useState({
|
|
name: '',
|
|
description: '',
|
|
license: '',
|
|
});
|
|
const [$deleteDialog, setDeleteDialog] = React.useState(false);
|
|
const [$saving, setSaving] = React.useState(false);
|
|
const [$service, setService] = React.useState(null);
|
|
const [$serviceSkills, setServiceSkills] = React.useState(null);
|
|
const [$invalid, setInvalid] = React.useState('');
|
|
|
|
useInterval(async () => {
|
|
await update(false);
|
|
}, 1000);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
await update(true);
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if ($invalid.length !== 0) {
|
|
navigate($invalid, { replace: true });
|
|
}
|
|
}, [navigate, $invalid]);
|
|
|
|
const update = async (isFirst) => {
|
|
const channelid = props.restreamer.SelectChannel(_channelid);
|
|
if (channelid === '' || channelid !== _channelid) {
|
|
setInvalid('/');
|
|
return;
|
|
}
|
|
|
|
const proc = await props.restreamer.GetEgress(_channelid, id, ['state']);
|
|
if (proc === null) {
|
|
notify.Dispatch('warning', 'notfound:egress:' + _service, i18n._(t`Publication service not found`));
|
|
setInvalid(`/${_channelid}`);
|
|
return;
|
|
}
|
|
|
|
setProgress(proc.progress);
|
|
|
|
if (isFirst === true) {
|
|
const s = Services.Get(_service);
|
|
if (s === null) {
|
|
notify.Dispatch('warning', 'notfound:egress:' + _service, i18n._(t`Publication service not found`));
|
|
setInvalid(`/${_channelid}/`);
|
|
return null;
|
|
}
|
|
|
|
setService(s);
|
|
|
|
const skills = await props.restreamer.Skills();
|
|
setSkills(skills);
|
|
|
|
const serviceSkills = helper.conflateServiceSkills(s.requires, skills);
|
|
setServiceSkills(serviceSkills);
|
|
|
|
const ingest = await props.restreamer.GetIngestMetadata(_channelid);
|
|
setMetadata({
|
|
...$metadata,
|
|
name: ingest.meta.name,
|
|
description: ingest.meta.description,
|
|
license: ingest.license,
|
|
});
|
|
|
|
const localSources = [];
|
|
|
|
localSources.push('hls+' + ingest.control.hls.storage);
|
|
|
|
if (ingest.control.rtmp.enable) {
|
|
localSources.push('rtmp');
|
|
}
|
|
|
|
if (ingest.control.srt.enable) {
|
|
localSources.push('srt');
|
|
}
|
|
|
|
setLocalSources(localSources);
|
|
|
|
const sources = helper.createSourcesFromStreams(ingest.streams);
|
|
setSources(sources);
|
|
|
|
const settings = await props.restreamer.GetEgressMetadata(_channelid, id);
|
|
|
|
const profiles = settings.profiles;
|
|
profiles[0].video = helper.preselectProfile(profiles[0].video, 'video', ingest.streams, serviceSkills.codecs.video, skills);
|
|
profiles[0].audio = helper.preselectProfile(profiles[0].audio, 'audio', ingest.streams, serviceSkills.codecs.audio, skills);
|
|
|
|
settings.profiles = profiles;
|
|
settings.streams = M.createOutputStreams(sources, profiles, false);
|
|
|
|
setSettings(settings);
|
|
|
|
setReady(true);
|
|
}
|
|
};
|
|
|
|
const handleServiceAction = async (action) => {
|
|
let state = 'disconnected';
|
|
|
|
if (action === 'connect') {
|
|
await props.restreamer.StartEgress(_channelid, id);
|
|
state = 'connecting';
|
|
} else if (action === 'disconnect') {
|
|
await props.restreamer.StopEgress(_channelid, id);
|
|
state = 'disconnecting';
|
|
} else if (action === 'reconnect') {
|
|
await props.restreamer.StopEgress(_channelid, id);
|
|
await props.restreamer.StartEgress(_channelid, id);
|
|
state = 'connecting';
|
|
}
|
|
|
|
setProgress({
|
|
...$progress,
|
|
state: state,
|
|
});
|
|
};
|
|
|
|
const handleServiceChange = (outputs, settings) => {
|
|
if (!Array.isArray(outputs)) {
|
|
outputs = [outputs];
|
|
}
|
|
|
|
setSettings({
|
|
...$settings,
|
|
outputs: outputs,
|
|
settings: settings,
|
|
});
|
|
|
|
setUnsavedChanges(true);
|
|
};
|
|
|
|
const handleEncoding = (type) => (encoder, decoder, automatic) => {
|
|
const profiles = $settings.profiles;
|
|
|
|
profiles[0][type].encoder = encoder;
|
|
profiles[0][type].decoder = decoder;
|
|
|
|
const streams = M.createOutputStreams($sources, profiles, false);
|
|
|
|
let outputs = $settings.outputs;
|
|
|
|
if ('createOutputs' in $service) {
|
|
outputs = $service.createOutputs($settings.settings, $serviceSkills, $metadata, streams);
|
|
}
|
|
|
|
setSettings({
|
|
...$settings,
|
|
profiles: profiles,
|
|
streams: streams,
|
|
outputs: outputs,
|
|
});
|
|
|
|
if (!automatic) {
|
|
setUnsavedChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleFilter = (type) => (filter, automatic) => {
|
|
const profiles = $settings.profiles;
|
|
|
|
profiles[0][type].filter = filter;
|
|
|
|
setSettings({
|
|
...$settings,
|
|
profiles: profiles,
|
|
});
|
|
|
|
if (!automatic) {
|
|
setUnsavedChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleServiceDone = async () => {
|
|
setSaving(true);
|
|
|
|
const [global, inputs, outputs] = helper.createInputsOutputs($sources, $settings.profiles, $settings.outputs, false);
|
|
if (inputs.length === 0 || outputs.length === 0) {
|
|
setSaving(false);
|
|
notify.Dispatch('error', 'save:egress:' + _service, i18n._(t`The input profile is not complete. Please define a video and audio source.`));
|
|
return;
|
|
}
|
|
|
|
const [, err] = await props.restreamer.UpdateEgress(_channelid, id, global, inputs, outputs, $settings.control);
|
|
if (err !== null) {
|
|
setSaving(false);
|
|
notify.Dispatch('error', 'save:egress:' + _service, i18n._(t`Failed to store publication service (${err.message})`));
|
|
return;
|
|
}
|
|
|
|
await props.restreamer.SetEgressMetadata(_channelid, id, $settings);
|
|
|
|
setSaving(false);
|
|
|
|
notify.Dispatch('success', 'save:egress:' + _service, i18n._(t`The settings for "${$settings.name}" have been saved`));
|
|
|
|
setUnsavedChanges(false);
|
|
};
|
|
|
|
const handleServiceName = (event) => {
|
|
const name = event.target.value;
|
|
|
|
setSettings({
|
|
...$settings,
|
|
name: name,
|
|
});
|
|
|
|
setUnsavedChanges(true);
|
|
};
|
|
|
|
const handleControlChange = (what) => (control, automatic) => {
|
|
setSettings({
|
|
...$settings,
|
|
control: {
|
|
...$settings.control,
|
|
[what]: control,
|
|
},
|
|
});
|
|
|
|
if (automatic === false) {
|
|
setUnsavedChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleServiceDeleteDialog = () => {
|
|
setDeleteDialog(!$deleteDialog);
|
|
};
|
|
|
|
const handleServiceDelete = async () => {
|
|
setSaving(true);
|
|
|
|
const res = await props.restreamer.DeleteEgress(_channelid, id);
|
|
if (res === false) {
|
|
setSaving(false);
|
|
notify.Dispatch('warning', 'delete:egress:' + _service, i18n._(t`The publication service "${$settings.name}" could not be deleted`));
|
|
return;
|
|
}
|
|
|
|
setSaving(false);
|
|
|
|
notify.Dispatch('success', 'delete:egress:' + _service, i18n._(t`The publication service "${$settings.name}" has been deleted`));
|
|
|
|
navigate(`/${_channelid}`);
|
|
};
|
|
|
|
const handleAbort = () => {
|
|
navigate(`/${_channelid}/`);
|
|
};
|
|
|
|
const handleChangeTab = (event, value) => {
|
|
setTab(value);
|
|
};
|
|
|
|
const handleHelp = (topic) => () => {
|
|
if (!topic) {
|
|
H('publication-' + $tab);
|
|
return;
|
|
}
|
|
|
|
H(topic);
|
|
};
|
|
|
|
const handleProcessDetails = async (event) => {
|
|
event.preventDefault();
|
|
|
|
const open = !$processDetails.open;
|
|
let logdata = {
|
|
prelude: [],
|
|
log: [],
|
|
};
|
|
|
|
if (open === true) {
|
|
const data = await props.restreamer.GetEgressLog(_channelid, id);
|
|
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.GetEgressLog(_channelid, id);
|
|
if (data !== null) {
|
|
setProcessDetails({
|
|
...$processDetails,
|
|
open: true,
|
|
data: data,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleProcessDebug = async (event) => {
|
|
event.preventDefault();
|
|
|
|
const show = !$processDebug.open;
|
|
let data = '';
|
|
|
|
if (show === true) {
|
|
const debug = await props.restreamer.GetEgressDebug(_channelid, id);
|
|
data = JSON.stringify(debug, null, 2);
|
|
}
|
|
|
|
setProcessDebug({
|
|
...$processDebug,
|
|
open: show,
|
|
data: data,
|
|
});
|
|
};
|
|
|
|
if ($ready === false) {
|
|
return null;
|
|
}
|
|
|
|
const ServiceControl = $service.component;
|
|
|
|
const title = $settings.name.length === 0 ? $service.name : $settings.name;
|
|
|
|
return (
|
|
<React.Fragment>
|
|
<Paper xs={12} md={10}>
|
|
<PaperHeader
|
|
title={
|
|
<React.Fragment>
|
|
<Trans>Edit: {title}</Trans>
|
|
</React.Fragment>
|
|
}
|
|
onAbort={handleAbort}
|
|
onHelp={handleHelp()}
|
|
/>
|
|
<Grid container spacing={1}>
|
|
<TabsVerticalGrid>
|
|
<Tabs orientation="vertical" variant="scrollable" value={$tab} onChange={handleChangeTab} className="tabs">
|
|
<Tab className="tab" label={<Trans>General</Trans>} value="general" />
|
|
<Tab className="tab" label={<Trans>Source & Encoding</Trans>} value="encoding" />
|
|
<Tab className="tab" label={<Trans>Process control</Trans>} value="process" />
|
|
</Tabs>
|
|
<TabPanel value={$tab} index="general" className="panel">
|
|
<TabContent service={$service}>
|
|
<Grid item xs={12} sx={{ margin: '1em 0em 1em 0em' }}>
|
|
<Typography>{$service.description}</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<TextField
|
|
variant="outlined"
|
|
fullWidth
|
|
label={<Trans>Service name</Trans>}
|
|
value={$settings.name}
|
|
onChange={handleServiceName}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<ServiceControl
|
|
settings={$settings.settings}
|
|
skills={$serviceSkills}
|
|
metadata={$metadata}
|
|
streams={$settings.streams}
|
|
onChange={handleServiceChange}
|
|
/>
|
|
</Grid>
|
|
</TabContent>
|
|
</TabPanel>
|
|
<TabPanel value={$tab} index="process" className="panel">
|
|
<TabContent service={$service}>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h2">
|
|
<Trans>Process</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<ProcessControl settings={$settings.control.process} onChange={handleControlChange('process')} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Grid container spacing={1} className={classes.gridContainer}>
|
|
{$unsavedChanges === true && (
|
|
<Grid item xs={12}>
|
|
<BoxText>
|
|
<Typography variant="body2" gutterBottom>
|
|
<Trans>You have unsaved changes. Please save them before you can control the service again.</Trans>
|
|
</Typography>
|
|
</BoxText>
|
|
</Grid>
|
|
)}
|
|
{$unsavedChanges === false && (
|
|
<React.Fragment>
|
|
<Grid item xs={12}>
|
|
<Process onAction={handleServiceAction} progress={$progress} />
|
|
</Grid>
|
|
<Grid item xs={12} align="right">
|
|
<Link color="textSecondary" href="#!" onClick={handleProcessDetails} className={classes.link}>
|
|
<Trans>Process details</Trans>
|
|
</Link>
|
|
<Link color="textSecondary" href="#!" onClick={handleProcessDebug} className={classes.link}>
|
|
<Trans>Process report</Trans>
|
|
</Link>
|
|
</Grid>
|
|
</React.Fragment>
|
|
)}
|
|
</Grid>
|
|
</Grid>
|
|
</TabContent>
|
|
</TabPanel>
|
|
<TabPanel value={$tab} index="encoding" className="panel">
|
|
<TabContent service={$service}>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h2">
|
|
<Trans>Source & Encoding</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h3">
|
|
<Trans>Source</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Typography variant="subheading">
|
|
<Trans>Select RTMP or SRT (if enabled) for less latency.</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<SourceControl settings={$settings.control.source} sources={$localSources} onChange={handleControlChange('source')} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h3">
|
|
<Trans>Encoding</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Typography variant="subheading">
|
|
<Trans>
|
|
Passthrough (copy) should only be disabled if necessary. Each encoding requires additional CPU/GPU resources.
|
|
</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h4">
|
|
<Trans>Video settings</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<EncodingSelect
|
|
type="video"
|
|
streams={$sources[0].streams}
|
|
profile={$settings.profiles[0].video}
|
|
codecs={$serviceSkills.codecs.video}
|
|
skills={$skills}
|
|
onChange={handleEncoding('video')}
|
|
/>
|
|
</Grid>
|
|
{$settings.profiles[0].video.encoder.coder !== 'copy' && (
|
|
<Grid item xs={12}>
|
|
<FilterSelect
|
|
type="video"
|
|
profile={$settings.profiles[0].video}
|
|
availableFilters={$skills.filter}
|
|
onChange={handleFilter('video')}
|
|
/>
|
|
</Grid>
|
|
)}
|
|
<Grid item xs={12}>
|
|
<Typography variant="h4">
|
|
<Trans>Audio settings</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<EncodingSelect
|
|
type="audio"
|
|
streams={$sources[0].streams}
|
|
profile={$settings.profiles[0].audio}
|
|
codecs={$serviceSkills.codecs.audio}
|
|
skills={$skills}
|
|
onChange={handleEncoding('audio')}
|
|
/>
|
|
</Grid>
|
|
{$settings.profiles[0].audio.encoder.coder !== 'copy' && (
|
|
<Grid item xs={12}>
|
|
<FilterSelect
|
|
type="audio"
|
|
profile={$settings.profiles[0].audio}
|
|
availableFilters={$skills.filter}
|
|
onChange={handleFilter('audio')}
|
|
/>
|
|
</Grid>
|
|
)}
|
|
</TabContent>
|
|
</TabPanel>
|
|
</TabsVerticalGrid>
|
|
</Grid>
|
|
<PaperFooter
|
|
buttonsLeft={
|
|
<Button variant="outlined" color="default" onClick={handleAbort}>
|
|
<Trans>Close</Trans>
|
|
</Button>
|
|
}
|
|
buttonsRight={
|
|
<React.Fragment>
|
|
<Button variant="outlined" color="primary" disabled={$unsavedChanges === false || $saving === true} onClick={handleServiceDone}>
|
|
<Trans>Save</Trans>
|
|
</Button>
|
|
<Button variant="outlined" color="secondary" disabled={$saving === true} onClick={handleServiceDeleteDialog}>
|
|
<Trans>Delete</Trans>
|
|
</Button>
|
|
</React.Fragment>
|
|
}
|
|
/>
|
|
</Paper>
|
|
<ProcessModal
|
|
open={$processDetails.open}
|
|
onClose={handleProcessDetails}
|
|
title={<Trans>Process details</Trans>}
|
|
progress={$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')}
|
|
/>
|
|
<Dialog
|
|
open={$deleteDialog}
|
|
onClose={handleServiceDeleteDialog}
|
|
title={<Trans>Do you want to delete {title}?</Trans>}
|
|
buttonsLeft={
|
|
<Button variant="outlined" color="default" onClick={handleServiceDeleteDialog}>
|
|
<Trans>Abort</Trans>
|
|
</Button>
|
|
}
|
|
buttonsRight={
|
|
<Button variant="outlined" color="secondary" onClick={handleServiceDelete}>
|
|
<Trans>Delete</Trans>
|
|
</Button>
|
|
}
|
|
>
|
|
<Typography>
|
|
<Trans>Deleting a publication service cannot be reversed. The publication stops immediately.</Trans>
|
|
</Typography>
|
|
</Dialog>
|
|
<Backdrop open={$saving}>
|
|
<CircularProgress color="inherit" />
|
|
</Backdrop>
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
Edit.defaultProps = {
|
|
restreamer: null,
|
|
};
|
|
|
|
Edit.propTypes = {
|
|
restreamer: PropTypes.object.isRequired,
|
|
};
|