Expecting the CHANGELOG.md in the UI webroot. The markdown is parsed in order to find the sections with the versions. A section for a version has to start with "### " followed by the version (either with the v prefix or not). Only the sections for the relevant versions from the previous to the current will be displayed.
532 lines
12 KiB
JavaScript
532 lines
12 KiB
JavaScript
import React from 'react';
|
|
|
|
import makeStyles from '@mui/styles/makeStyles';
|
|
import Alert from '@mui/material/Alert';
|
|
import Backdrop from '@mui/material/Backdrop';
|
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
import Grid from '@mui/material/Grid';
|
|
import Snackbar from '@mui/material/Snackbar';
|
|
|
|
import { NotifyProvider } from './contexts/Notify';
|
|
import * as auth0 from './utils/auth0';
|
|
import useInterval from './hooks/useInterval';
|
|
import ChannelList from './misc/ChannelList';
|
|
import Footer from './Footer';
|
|
import I18n from './I18n';
|
|
import Header from './Header';
|
|
import Restreamer from './utils/restreamer';
|
|
import Router from './Router';
|
|
import Views from './views';
|
|
import { UI as Version } from './version';
|
|
import Changelog from './misc/Changelog';
|
|
|
|
import SemverGt from 'semver/functions/gt';
|
|
import SemverValid from 'semver/functions/valid';
|
|
|
|
const useStyles = makeStyles((theme) => ({
|
|
MainHeader: {
|
|
height: '132px',
|
|
},
|
|
// todo: one layer
|
|
MainContent: {
|
|
height: '100%',
|
|
'& .MainContent-container': {
|
|
minHeight: 'calc(100vh - 230px)',
|
|
},
|
|
'& .MainContent-item': {
|
|
maxWidth: '980px',
|
|
},
|
|
},
|
|
}));
|
|
|
|
export default function RestreamerUI(props) {
|
|
const classes = useStyles();
|
|
|
|
const [$state, setState] = React.useState({
|
|
initialized: false,
|
|
valid: false,
|
|
connected: false,
|
|
compatibility: { compatible: false },
|
|
ingest: false,
|
|
password: false,
|
|
updates: false,
|
|
service: false,
|
|
changelog: false,
|
|
});
|
|
const [$ready, setReady] = React.useState(false);
|
|
const [$snack, setSnack] = React.useState({
|
|
open: false,
|
|
message: '',
|
|
severity: 'info',
|
|
});
|
|
const [$channelList, setChannelList] = React.useState({
|
|
open: false,
|
|
channelid: '',
|
|
channels: [],
|
|
});
|
|
const [$metadata, setMetadata] = React.useState({});
|
|
const [$changelog, setChangelog] = React.useState({
|
|
current: '',
|
|
previous: '',
|
|
});
|
|
|
|
const restreamer = React.useRef(null);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
await handleMount();
|
|
})();
|
|
|
|
return () => {};
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useInterval(() => {
|
|
setState({
|
|
...$state,
|
|
updates: restreamer.current.HasUpdates(),
|
|
});
|
|
}, 1000 * 60);
|
|
|
|
const notify = (severity, type, message) => {
|
|
setSnack({
|
|
...$snack,
|
|
open: true,
|
|
message: message,
|
|
severity: severity,
|
|
});
|
|
|
|
if (severity === 'success') {
|
|
if (type === 'save:ingest') {
|
|
setState({
|
|
...$state,
|
|
ingest: true,
|
|
});
|
|
}
|
|
} else if (severity === 'error') {
|
|
if (type === 'network') {
|
|
setState({
|
|
...$state,
|
|
initialized: true,
|
|
valid: false,
|
|
});
|
|
} else if (type === 'auth') {
|
|
(async () => {
|
|
await handleLogout();
|
|
})();
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMount = async () => {
|
|
restreamer.current = new Restreamer(props.address);
|
|
restreamer.current.AddListener((event) => {
|
|
notify(event.severity, event.type, event.message);
|
|
});
|
|
|
|
// Try if there's still an auth0 session
|
|
if (auth0.init() === true) {
|
|
if (await auth0.isAuthenticated()) {
|
|
const token = await auth0.getToken();
|
|
await restreamer.current.LoginWithToken(token);
|
|
} else {
|
|
const result = await auth0.handleRedirectCallback();
|
|
if (result.initialized === true) {
|
|
if (result.error === true) {
|
|
notify('error', 'auth0', 'Auth0: ' + result.description);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const valid = await restreamer.current.Validate();
|
|
|
|
await checkChangelog();
|
|
|
|
setState({
|
|
...$state,
|
|
initialized: true,
|
|
valid: valid,
|
|
connected: restreamer.current.IsConnected(),
|
|
compatibility: restreamer.current.Compatibility(),
|
|
ingest: restreamer.current.HasIngest(),
|
|
password: restreamer.current.Auths().length === 0 && !restreamer.current.ConfigOverrides('api.auth.enable'),
|
|
updates: restreamer.current.HasUpdates(),
|
|
service: restreamer.current.HasService(),
|
|
});
|
|
|
|
setReady(true);
|
|
};
|
|
|
|
const checkChangelog = async () => {
|
|
let showChangelog = false;
|
|
|
|
if (restreamer.current.IsConnected() === true) {
|
|
let metadata = await restreamer.current.GetMetadata();
|
|
let current = Version.replace('restreamer-', '');
|
|
let previous = '';
|
|
|
|
if ('version' in metadata.ui) {
|
|
if (SemverValid(metadata.ui.version) !== null) {
|
|
previous = metadata.ui.version;
|
|
} else {
|
|
showChangelog = true;
|
|
}
|
|
} else {
|
|
showChangelog = true;
|
|
}
|
|
|
|
if (showChangelog === false) {
|
|
if (SemverGt(current, previous)) {
|
|
showChangelog = true;
|
|
}
|
|
}
|
|
|
|
setMetadata({
|
|
...$metadata,
|
|
...metadata,
|
|
});
|
|
|
|
setChangelog({
|
|
...$changelog,
|
|
open: showChangelog,
|
|
current: current,
|
|
previous: previous,
|
|
});
|
|
}
|
|
|
|
return showChangelog;
|
|
};
|
|
|
|
const handleLogin = async (username, password) => {
|
|
const connected = await restreamer.current.Login(username, password);
|
|
|
|
await checkChangelog();
|
|
|
|
setState({
|
|
...$state,
|
|
connected: connected,
|
|
compatibility: restreamer.current.Compatibility(),
|
|
ingest: restreamer.current.HasIngest(),
|
|
});
|
|
|
|
return connected;
|
|
};
|
|
|
|
const handleAuth0 = async () => {
|
|
const token = await auth0.getToken();
|
|
const connected = await restreamer.current.LoginWithToken(token);
|
|
|
|
setState({
|
|
...$state,
|
|
connected: connected,
|
|
compatibility: restreamer.current.Compatibility(),
|
|
});
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
setState({
|
|
...$state,
|
|
initialized: false,
|
|
connected: false,
|
|
});
|
|
|
|
restreamer.current.Logout();
|
|
|
|
if (await auth0.isAuthenticated()) {
|
|
await auth0.logout();
|
|
}
|
|
|
|
restreamer.current.Reset();
|
|
|
|
const valid = await restreamer.current.Validate();
|
|
|
|
setState({
|
|
...$state,
|
|
initialized: true,
|
|
valid: valid,
|
|
connected: restreamer.current.IsConnected(),
|
|
compatibility: restreamer.current.Compatibility(),
|
|
ingest: restreamer.current.HasIngest(),
|
|
});
|
|
};
|
|
|
|
const handlePasswordReset = async (username, loginUsername, password, loginPassword) => {
|
|
const data = {
|
|
api: {
|
|
auth: {
|
|
enable: true,
|
|
},
|
|
},
|
|
version: 3,
|
|
};
|
|
|
|
if (username.length !== 0) {
|
|
data.api.auth.username = username;
|
|
}
|
|
|
|
if (password.length !== 0) {
|
|
data.api.auth.password = password;
|
|
}
|
|
|
|
const [, err] = await restreamer.current.ConfigSet(data);
|
|
if (err !== null) {
|
|
notify('error', 'save:settings', `There was an error resetting the password.`);
|
|
return 'ERROR';
|
|
}
|
|
|
|
const res = await restreamer.current.ConfigReload();
|
|
if (res === false) {
|
|
notify('error', 'restart', `Restarting the application failed.`);
|
|
return 'ERROR';
|
|
}
|
|
|
|
restreamer.current.IgnoreAPIErrors(true);
|
|
|
|
const waitFor = (ms) => {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
};
|
|
|
|
let restarted = false;
|
|
const key = restreamer.current.CreatedAt().toISOString();
|
|
|
|
for (let retries = 0; retries <= 60; retries++) {
|
|
await waitFor(1000);
|
|
|
|
const about = await restreamer.current.About();
|
|
if (about === null) {
|
|
// Restarted API not yet available
|
|
continue;
|
|
}
|
|
|
|
if (about.created_at?.toISOString() === key) {
|
|
// API did not yet restart
|
|
continue;
|
|
}
|
|
|
|
restarted = true;
|
|
break;
|
|
}
|
|
|
|
if (restarted === true) {
|
|
// After the restart the API requires a login and this means the restart happened
|
|
await restreamer.current.Validate();
|
|
await restreamer.current.Login(loginUsername, loginPassword);
|
|
|
|
window.location.reload();
|
|
} else {
|
|
return 'TIMEOUT';
|
|
}
|
|
|
|
return 'OK';
|
|
};
|
|
|
|
const handlePlayersite = () => {
|
|
document.location.hash = '#/playersite';
|
|
};
|
|
|
|
const handleSettings = () => {
|
|
document.location.hash = '#/settings';
|
|
};
|
|
|
|
const handleChannelList = () => {
|
|
const channelid = restreamer.current.GetCurrentChannelID();
|
|
const channels = restreamer.current.ListChannels();
|
|
|
|
setChannelList({
|
|
...$channelList,
|
|
open: true,
|
|
channelid: channelid,
|
|
channels: channels,
|
|
});
|
|
};
|
|
|
|
const handleSelectChannel = (channelid) => {
|
|
restreamer.current.SelectChannel(channelid);
|
|
handleChannelList();
|
|
|
|
document.location.hash = `#/${channelid}`;
|
|
};
|
|
|
|
const handleCloseChannelList = () => {
|
|
setChannelList({
|
|
...$channelList,
|
|
open: false,
|
|
});
|
|
};
|
|
|
|
const handleAddChannel = (name) => {
|
|
const channelid = restreamer.current.CreateChannel(name);
|
|
restreamer.current.SelectChannel(channelid);
|
|
|
|
setChannelList({
|
|
...$channelList,
|
|
open: false,
|
|
});
|
|
|
|
document.location.hash = `#/${channelid}/edit/wizard`;
|
|
};
|
|
|
|
const handleStateChannel = async (channelids) => {
|
|
const processes = await restreamer.current.ListProcesses(['state'], channelids);
|
|
const states = {};
|
|
|
|
for (let p of processes) {
|
|
states[p.id] = p.progress.state;
|
|
}
|
|
|
|
return states;
|
|
};
|
|
|
|
const handleCloseSnack = () => {
|
|
setSnack({
|
|
...$snack,
|
|
open: false,
|
|
});
|
|
};
|
|
|
|
const handleCloseChangelog = async () => {
|
|
await restreamer.current.SetMetadata({
|
|
...$metadata,
|
|
ui: {
|
|
...$metadata.ui,
|
|
version: $changelog.current,
|
|
},
|
|
});
|
|
|
|
setChangelog({
|
|
...$changelog,
|
|
open: false,
|
|
});
|
|
};
|
|
|
|
const handleResources = async () => {
|
|
return await restreamer.current.Resources();
|
|
};
|
|
|
|
if ($ready === false) {
|
|
return (
|
|
<Backdrop open={true}>
|
|
<CircularProgress color="inherit" />
|
|
</Backdrop>
|
|
);
|
|
}
|
|
|
|
let version = {};
|
|
let app = '';
|
|
let name = '';
|
|
if ($state.initialized === true) {
|
|
version = restreamer.current.Version();
|
|
app = restreamer.current.App();
|
|
name = restreamer.current.Name();
|
|
}
|
|
|
|
let resources = () => {
|
|
return null;
|
|
};
|
|
|
|
let view = null;
|
|
if ($state.initialized === false) {
|
|
view = <Views.Initializing />;
|
|
} else {
|
|
if ($state.valid === false) {
|
|
view = <Views.Invalid address={restreamer.current.Address()} />;
|
|
} else if ($state.connected === false) {
|
|
view = (
|
|
<Views.Login
|
|
onLogin={handleLogin}
|
|
auths={restreamer.current.Auths()}
|
|
hasService={$state.service}
|
|
address={restreamer.current.Address()}
|
|
onAuth0={handleAuth0}
|
|
/>
|
|
);
|
|
} else if ($state.compatibility.compatible === false) {
|
|
if ($state.compatibility.core.compatible === false) {
|
|
view = <Views.Incompatible type="core" have={$state.compatibility.core.have} want={$state.compatibility.core.want} />;
|
|
} else if ($state.compatibility.ffmpeg.compatible === false) {
|
|
view = <Views.Incompatible type="ffmpeg" have={$state.compatibility.ffmpeg.have} want={$state.compatibility.ffmpeg.want} />;
|
|
}
|
|
} else if ($state.password === true) {
|
|
view = (
|
|
<Views.Password
|
|
onReset={handlePasswordReset}
|
|
username={restreamer.current.ConfigValue('api.auth.username')}
|
|
usernameOverride={restreamer.current.ConfigOverrides('api.auth.username')}
|
|
password={restreamer.current.ConfigValue('api.auth.password')}
|
|
passwordOverride={restreamer.current.ConfigOverrides('api.auth.password')}
|
|
/>
|
|
);
|
|
} else {
|
|
view = <Router restreamer={restreamer.current} />;
|
|
resources = handleResources;
|
|
}
|
|
}
|
|
|
|
const expand = $state.connected && $state.compatibility.compatible && !$state.password;
|
|
|
|
return (
|
|
<I18n>
|
|
<NotifyProvider value={{ Dispatch: notify }}>
|
|
<Grid container direction="column" justifyContent="flex-start" alignItems="stretch" spacing={0}>
|
|
<Grid className={classes.MainHeader}>
|
|
<Header
|
|
expand={expand}
|
|
showPlayersite={$state.ingest}
|
|
showSettings={$state.compatibility.compatible}
|
|
hasUpdates={$state.updates}
|
|
hasService={$state.service}
|
|
onChannel={handleChannelList}
|
|
onPlayersite={handlePlayersite}
|
|
onSettings={handleSettings}
|
|
onLogout={handleLogout}
|
|
/>
|
|
</Grid>
|
|
<Grid item className={classes.MainContent}>
|
|
<Grid container className="MainContent-container" justifyContent="center" alignItems="center" spacing={0}>
|
|
<Grid item sm={1}></Grid>
|
|
<Grid item xs={12} sm={10} className="MainContent-item">
|
|
{view}
|
|
</Grid>
|
|
<Grid item sm={1}></Grid>
|
|
</Grid>
|
|
</Grid>
|
|
</Grid>
|
|
<Footer expand={$state.connected} app={app} version={version} name={name} resources={resources} />
|
|
<Snackbar
|
|
anchorOrigin={{
|
|
vertical: 'top',
|
|
horizontal: 'right',
|
|
}}
|
|
open={$snack.open}
|
|
autoHideDuration={6000}
|
|
onClose={handleCloseSnack}
|
|
>
|
|
<Alert variant="filled" elevation={6} onClose={handleCloseSnack} severity={$snack.severity}>
|
|
{$snack.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
{expand && (
|
|
<ChannelList
|
|
open={$channelList.open}
|
|
channels={$channelList.channels}
|
|
channelid={$channelList.channelid}
|
|
onClose={handleCloseChannelList}
|
|
onClick={handleSelectChannel}
|
|
onAdd={handleAddChannel}
|
|
onState={handleStateChannel}
|
|
/>
|
|
)}
|
|
{expand && <Changelog open={$changelog.open} onClose={handleCloseChangelog} current={$changelog.current} previous={$changelog.previous} />}
|
|
</NotifyProvider>
|
|
</I18n>
|
|
);
|
|
}
|
|
|
|
RestreamerUI.defaultProps = {
|
|
address: '',
|
|
};
|