diff --git a/overlay/src/views/Edit/index.js b/overlay/src/views/Edit/index.js new file mode 100644 index 0000000..02de4ed --- /dev/null +++ b/overlay/src/views/Edit/index.js @@ -0,0 +1,707 @@ +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 AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; +import Backdrop from '@mui/material/Backdrop'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Divider from '@mui/material/Divider'; +import EditIcon from '@mui/icons-material/Edit'; +import Grid from '@mui/material/Grid'; +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; + +import * as M from '../../utils/metadata'; +import sourceThumb from '../../assets/images/livesource.png'; +import Dialog from '../../misc/modals/Dialog'; +import H from '../../utils/help'; +import HLSControl from '../../misc/controls/HLS'; +import LicenseControl from '../../misc/controls/License'; +import LimitsControl from '../../misc/controls/Limits'; +import MetadataControl from '../../misc/controls/Metadata'; +import NotifyContext from '../../contexts/Notify'; +import Paper from '../../misc/Paper'; +import PaperHeader from '../../misc/PaperHeader'; +import PaperFooter from '../../misc/PaperFooter'; +import PaperThumb from '../../misc/PaperThumb'; +import ProcessControl from '../../misc/controls/Process'; +import Profile from './Profile'; +import ProfileSummary from './ProfileSummary'; +import RTMPControl from '../../misc/controls/RTMP'; +import SnapshotControl from '../../misc/controls/Snapshot'; +import SRTControl from '../../misc/controls/SRT'; +import WHEPControl from '../../misc/controls/WHEP'; +import TabPanel from '../../misc/TabPanel'; +import TabsVerticalGrid from '../../misc/TabsVerticalGrid'; + +const useStyles = makeStyles((theme) => ({ + wizardButtonElement: { + display: 'flex', + alignItems: 'left', + }, + wizardButton: { + marginLeft: '1em', + padding: ' 0em 2em 0em 2em', + }, + link: { + color: theme.palette.common.white, + }, + inlineIcon: { + marginBottom: '-.2rem', + }, +})); + +export default function Edit(props) { + const classes = useStyles(); + const { i18n } = useLingui(); + const navigate = useNavigate(); + const { channelid: _channelid, tab: _tab } = useParams(); + const notify = React.useContext(NotifyContext); + const [$tab, setTab] = React.useState(_tab ? _tab : 'general'); + const [$state, setState] = React.useState({ + editing: false, + edit: '', + complete: false, + saving: false, + }); + const [$data, setData] = React.useState(M.getDefaultIngestMetadata()); + const [$skills, setSkills] = React.useState({}); + const [$config, setConfig] = React.useState({}); + const [$process, setProcess] = React.useState({}); + const [$ready, setReady] = React.useState(false); + const [$deleteDialog, setDeleteDialog] = React.useState(false); + const [$editDialog, setEditDialog] = React.useState({ + open: false, + target: '', + what: '', + }); + const [$invalid, setInvalid] = React.useState(false); + + React.useEffect(() => { + (async () => { + await load(); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + React.useEffect(() => { + if ($invalid === true) { + navigate('/', { replace: true }); + } + }, [navigate, $invalid]); + + const load = async () => { + const channelid = props.restreamer.SelectChannel(_channelid); + if (channelid === '' || channelid !== _channelid) { + setInvalid(true); + return; + } + + const proc = await props.restreamer.GetIngestProgress(_channelid); + setProcess(proc); + + let metadata = await props.restreamer.GetIngestMetadata(_channelid); + setData({ + ...$data, + ...metadata, + }); + + const skills = await props.restreamer.Skills(); + setSkills(skills); + + const config = await props.restreamer.ConfigActive(); + setConfig(config); + + const complete = M.validateProfile(metadata.sources, metadata.profiles[0]); + + const state = { + complete: complete, + }; + + if (metadata.sources.length === 0) { + state.editing = true; + state.edit = 'video'; + } + + setState({ + ...$state, + ...state, + }); + + setReady(true); + }; + + const handleChangeTab = (event, value) => { + setTab(value); + }; + + const handleSourceEditDialog = (target) => (what) => { + if ($process.order === 'start') { + setEditDialog({ + ...$editDialog, + open: true, + target: target, + what: what, + }); + + return; + } + + if (target === 'wizard') { + handleWizard(); + } else { + handleSourceEdit(what); + } + }; + + const handleSourceEditDialogAbort = () => { + setEditDialog({ + ...$editDialog, + open: false, + target: '', + what: '', + }); + }; + + const handleSourceEditDialogDone = async () => { + let stopped = false; + + stopped = await props.restreamer.StopIngest(_channelid); + stopped = stopped ? await props.restreamer.StopIngestSnapshot(_channelid) : false; + + const target = $editDialog.target; + const what = $editDialog.what; + + setEditDialog({ + ...$editDialog, + open: false, + target: '', + what: '', + }); + + if (stopped === false) { + notify.Dispatch('error', 'edit:ingest', t`Failed to stop process`); + return; + } + + if (target === 'wizard') { + handleWizard(); + } else { + handleSourceEdit(what); + } + }; + + const handleSourceEdit = (what) => { + setState({ + ...$state, + editing: true, + edit: what, + }); + }; + + const handleSkillsRefresh = async () => { + await props.restreamer.RefreshSkills(); + + const skills = await props.restreamer.Skills(); + setSkills(skills); + }; + + const handleSourceStore = async (name, data) => { + return await props.restreamer.UploadData('', name, data); + }; + + const handleSourceProbe = async (inputs) => { + let [res, err] = await props.restreamer.Probe(_channelid, inputs); + if (err !== null) { + res = { + streams: [], + log: [err.message], + }; + } + + return res; + }; + + const handleSourceDone = (sources, profile) => { + const complete = M.validateProfile(sources, profile); + + let streams = []; + if (complete === true) { + streams = M.createOutputStreams(sources, [profile]); + } + + setData({ + ...$data, + sources: sources, + profiles: [profile], + streams: streams, + }); + + setState({ + ...$state, + editing: false, + complete: complete, + }); + }; + + const handleSourceAbort = () => { + setState({ + ...$state, + editing: false, + }); + }; + + const handleWizard = () => { + navigate(`/${_channelid}/edit/wizard`); + }; + + const handleControlChange = (what) => (settings) => { + const control = { + ...$data.control, + [what]: settings, + }; + + setData({ + ...$data, + control: control, + }); + }; + + const handleMetadataChange = (settings) => { + setData({ + ...$data, + meta: settings, + }); + }; + + const handleLicenseChange = (license) => { + setData({ + ...$data, + license: license, + }); + }; + + const handleDone = async () => { + setState({ + ...$state, + saving: true, + }); + + const save = async () => { + const sources = $data.sources; + const profiles = $data.profiles; + const control = $data.control; + + const [global, inputs, outputs] = M.createInputsOutputs(sources, profiles, true); + + if (inputs.length === 0 || outputs.length === 0) { + notify.Dispatch('error', 'save:ingest', i18n._(t`The input profile is not complete. Please define a video and audio source.`)); + return false; + } + + // Create/update the ingest + let [, err] = await props.restreamer.UpsertIngest(_channelid, global, inputs, outputs, control); + if (err !== null) { + notify.Dispatch('error', 'save:ingest', i18n._(t`Failed to update ingest process (${err.message})`)); + return false; + } + + // Save the metadata + let res = await props.restreamer.SetIngestMetadata(_channelid, $data); + if (res === false) { + notify.Dispatch('warning', 'save:ingest', i18n._(t`Failed to save ingest metadata`)); + } + + // Create/update the ingest snapshot process + [, err] = await props.restreamer.UpsertIngestSnapshot(_channelid, control); + if (err !== null) { + notify.Dispatch('error', 'save:ingest', i18n._(t`Failed to update ingest snapshot process (${err.message})`)); + } + + // Create/update the player + res = await props.restreamer.UpdatePlayer(_channelid); + if (res === false) { + notify.Dispatch('warning', 'save:ingest', i18n._(t`Failed to update the player`)); + } + + // Create/update the playersite + res = await props.restreamer.UpdatePlayersite(); + if (res === false) { + notify.Dispatch('warning', 'save:ingest', i18n._(t`Failed to update the playersite`)); + } + + return true; + }; + + const res = await save(); + + setState({ + ...$state, + saving: false, + }); + + if (res === false) { + return; + } + + notify.Dispatch('success', 'save:ingest', i18n._(t`Channel "${$data.meta.name}" saved`)); + + navigate(`/${_channelid}/`); + }; + + const handleAbort = () => { + navigate(`/${_channelid}/`); + }; + + const handleChannelDeleteDialog = () => { + setDeleteDialog(!$deleteDialog); + }; + + const handleChannelDelete = async () => { + setState({ + ...$state, + saving: true, + }); + + const res = await props.restreamer.DeleteChannel(_channelid); + if (res === false) { + setState({ + ...$state, + saving: false, + }); + notify.Dispatch('warning', 'delete:ingest', i18n._(t`The channel "${$data.meta.name}" could not be deleted`)); + return; + } + + setState({ + ...$state, + saving: false, + }); + + notify.Dispatch('success', 'delete:ingest', i18n._(t`The channel "${$data.meta.name}" has been deleted`)); + + navigate('/'); + }; + + const handleHelp = () => { + H('edit-' + $tab); + }; + + if ($ready === false) { + return null; + } + + let title = Main Source; + if ($data.meta.name.length !== '') { + title = $data.meta.name; + } + + return ( + + + + Edit: {title} + + } + onAbort={handleAbort} + onHelp={handleHelp} + /> + + + + General} value="general" /> + Processing & Control} value="control" /> + Meta information} value="meta" /> + License} value="license" /> + + + + + + + + + General + + + + + + Edit the audio and video sources for the live stream. Add a description, and set your desired content license. + + + + {$state.editing === false && ( + +
+ + + Use the wizard () for a quick and easy setup, or edit ( + ) the sources directly in custom mode. + + +
+
+ )} + + + +
+ {$state.editing === false ? ( + + ) : ( + + )} +
+ + + + + Processing & Control + + + + + + + + HLS output + + + + + + + + + + + + + + + RTMP output + + + + + + + + + + + + + + + SRT output + + + + + + + + + + + + + + + WebRTC output (WHEP) + + + + + + + + + + + Snapshot + + + + + + + + + + + Process + + + + + + + + + + + Limits + + + + + + + + + + + + Metadata + + + + + Briefly describe what the audience will see during the live stream. + + + + + + + + + + + + + + + License + + + + + + Use your copyright and choose the right image licence. Whether free for all or highly restricted. Briefly discuss + what others are allowed to do with your image. + + + + + + + + + + + +
+
+ + Close + + } + buttonsRight={ + + + + + } + /> + + Do you want to disconnect "{$data.meta.name}"?} + buttonsLeft={ + + } + buttonsRight={ + + } + > + + This source cannot be edited while it is in use. To continue, you have to disconnect the source. + + + Do you want to delete "{$data.meta.name}"?} + buttonsLeft={ + + } + buttonsRight={ + + } + > + + The deletion of this channel can not be recovered. All publications of this channel will be removed. + + + + + + + ); +} + +Edit.defaultProps = { + restreamer: null, +}; + +Edit.propTypes = { + restreamer: PropTypes.object.isRequired, +};