802 lines
19 KiB
JavaScript
802 lines
19 KiB
JavaScript
import React from 'react';
|
|
|
|
import { Trans } from '@lingui/macro';
|
|
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 Grid from '@mui/material/Grid';
|
|
import Link from '@mui/material/Link';
|
|
import Typography from '@mui/material/Typography';
|
|
import WarningIcon from '@mui/icons-material/Warning';
|
|
|
|
import * as M from '../../utils/metadata';
|
|
import BoxText from '../../misc/BoxText';
|
|
import EncodingSelect from '../../misc/EncodingSelect';
|
|
import PaperFooter from '../../misc/PaperFooter';
|
|
import ProbeModal from '../../misc/modals/Probe';
|
|
import HintModal from '../../misc/modals/Hint';
|
|
import SourceSelect from './SourceSelect';
|
|
import StreamSelect from './StreamSelect';
|
|
|
|
import FilterSelect from '../../misc/FilterSelect';
|
|
|
|
export default function Profile({
|
|
skills = {},
|
|
sources = [],
|
|
profile = {},
|
|
config = {},
|
|
startWith = '',
|
|
onDone = function (sources, profile) {},
|
|
onAbort = function () {},
|
|
onProbe = function (inputs) {
|
|
return {
|
|
streams: [],
|
|
log: ['onProbe function not provided for this component'],
|
|
};
|
|
},
|
|
onRefresh = function () {},
|
|
onStore = function (name, data) {
|
|
return '';
|
|
},
|
|
}) {
|
|
const [$sources, setSources] = React.useState({
|
|
video: M.initSource('video', sources[0]),
|
|
audio: M.initSource('audio', sources[1]),
|
|
});
|
|
const [$profile, setProfile] = React.useState(M.initProfile(profile));
|
|
const [$videoProbe, setVideoProbe] = React.useState({
|
|
probing: false,
|
|
log: [],
|
|
modal: false,
|
|
status: 'none',
|
|
});
|
|
const [$audioProbe, setAudioProbe] = React.useState({
|
|
probing: false,
|
|
log: [],
|
|
modal: false,
|
|
status: 'none',
|
|
});
|
|
const [$skillsRefresh, setSkillsRefresh] = React.useState(false);
|
|
const [$probeModal, setProbeModal] = React.useState({
|
|
open: false,
|
|
data: '',
|
|
});
|
|
const [$hintModal, setHintModal] = React.useState({
|
|
open: false,
|
|
type: '',
|
|
streams: [],
|
|
});
|
|
const [$activeStep, setActiveStep] = React.useState(startWith === 'audio' ? 1 : 0);
|
|
const [$ready, setReady] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
(async () => {
|
|
await load();
|
|
})();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const load = async () => {
|
|
let audio = $sources.audio;
|
|
|
|
let hasAudio = false;
|
|
for (let i = 0; i < $sources.video.streams.length; i++) {
|
|
if ($sources.video.streams[i].type === 'audio') {
|
|
hasAudio = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasAudio === true) {
|
|
skills.sources.videoaudio = [];
|
|
} else {
|
|
delete skills.sources.videoaudio;
|
|
}
|
|
|
|
setSources({
|
|
...$sources,
|
|
audio: audio,
|
|
});
|
|
|
|
setReady(true);
|
|
};
|
|
|
|
const handleNextStep = () => {
|
|
setActiveStep((prevActiveStep) => prevActiveStep + 1);
|
|
};
|
|
|
|
const handleBackStep = () => {
|
|
setActiveStep((prevActiveStep) => prevActiveStep - 1);
|
|
};
|
|
|
|
const handleProbe = async (type, device, settings, inputs) => {
|
|
if (type === 'video') {
|
|
setVideoProbe({
|
|
...$videoProbe,
|
|
probing: true,
|
|
status: 'none',
|
|
});
|
|
} else {
|
|
setAudioProbe({
|
|
...$audioProbe,
|
|
probing: true,
|
|
status: 'none',
|
|
});
|
|
}
|
|
|
|
const res = await onProbe(inputs);
|
|
|
|
const status = handleProbeStreams(type, device, settings, inputs, res);
|
|
|
|
return status === 'success';
|
|
};
|
|
|
|
const handleProbeStreams = (type, device, settings, inputs, res) => {
|
|
let status = M.analyzeStreams(type, res.streams);
|
|
|
|
if (type === 'video') {
|
|
let audio = $sources.audio;
|
|
|
|
const profile = M.preselectProfile('video', res.streams, $profile, skills.encoders, audio.type === '');
|
|
|
|
// Add pseudo sources
|
|
skills.sources.noaudio = [];
|
|
skills.sources.sdp = [];
|
|
|
|
let hasAudio = false;
|
|
for (let i = 0; i < res.streams.length; i++) {
|
|
if (res.streams[i].type === 'audio') {
|
|
hasAudio = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (hasAudio === true) {
|
|
skills.sources.videoaudio = [];
|
|
if (audio.type === '') {
|
|
audio.type = 'videoaudio';
|
|
}
|
|
} else {
|
|
delete skills.sources.videoaudio;
|
|
if (audio.type === '' || audio.type === 'videoaudio') {
|
|
audio.type = 'noaudio';
|
|
profile.audio.source = -1;
|
|
profile.audio.stream = -1;
|
|
profile.custom.selected = false;
|
|
profile.custom.stream = -1;
|
|
}
|
|
}
|
|
|
|
audio = M.initSource('audio', audio);
|
|
|
|
setProfile({
|
|
...$profile,
|
|
...profile,
|
|
});
|
|
|
|
setVideoProbe({
|
|
...$videoProbe,
|
|
probing: false,
|
|
log: res.log,
|
|
status: status,
|
|
});
|
|
|
|
setAudioProbe({
|
|
...$audioProbe,
|
|
status: audio.type === 'noaudio' ? 'success' : 'none',
|
|
});
|
|
|
|
setSources({
|
|
...$sources,
|
|
audio: audio,
|
|
video: {
|
|
type: device,
|
|
settings: settings,
|
|
inputs: inputs,
|
|
streams: res.streams,
|
|
},
|
|
});
|
|
} else {
|
|
const profile = M.preselectProfile('audio', res.streams, $profile, skills.encoders);
|
|
|
|
setProfile({
|
|
...$profile,
|
|
...profile,
|
|
});
|
|
|
|
setAudioProbe({
|
|
...$audioProbe,
|
|
probing: false,
|
|
log: res.log,
|
|
status: status,
|
|
});
|
|
|
|
setSources({
|
|
...$sources,
|
|
audio: {
|
|
type: device,
|
|
settings: settings,
|
|
inputs: inputs,
|
|
streams: res.streams,
|
|
},
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRefresh = async () => {
|
|
setSkillsRefresh(true);
|
|
await onRefresh();
|
|
setSkillsRefresh(false);
|
|
};
|
|
|
|
const handleStore = async (name, data, onprogress) => {
|
|
return await onStore(name, data, onprogress);
|
|
};
|
|
|
|
const handleEncoding = (type) => (encoder, decoder) => {
|
|
const profile = $profile[type];
|
|
|
|
profile.encoder = encoder;
|
|
profile.decoder = decoder;
|
|
|
|
setProfile({
|
|
...$profile,
|
|
[type]: profile,
|
|
});
|
|
};
|
|
|
|
const handleFilter = (type) => (filter) => {
|
|
const profile = $profile[type];
|
|
|
|
profile.filter = filter;
|
|
|
|
setProfile({
|
|
...$profile,
|
|
[type]: profile,
|
|
});
|
|
};
|
|
|
|
const handleDone = () => {
|
|
const sources = M.cleanupSources($sources);
|
|
const profile = M.cleanupProfile($profile);
|
|
|
|
onDone(sources, profile);
|
|
};
|
|
|
|
const handleAbort = () => {
|
|
onAbort();
|
|
};
|
|
|
|
const handleProbeLogModal = (type) => (event) => {
|
|
event.preventDefault();
|
|
|
|
if (type === 'video') {
|
|
setProbeModal({
|
|
...$probeModal,
|
|
open: true,
|
|
data: $videoProbe.log.join('\n'),
|
|
});
|
|
} else if (type === 'audio') {
|
|
setProbeModal({
|
|
...$probeModal,
|
|
open: true,
|
|
data: $audioProbe.log.join('\n'),
|
|
});
|
|
} else {
|
|
setProbeModal({
|
|
...$probeModal,
|
|
open: false,
|
|
data: '',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSourceChange = (type, source) => {
|
|
const profile = $profile[type];
|
|
const custom = $profile.custom;
|
|
|
|
if (type === 'audio') {
|
|
if (source === 'noaudio') {
|
|
custom.selected = false;
|
|
custom.stream = -1;
|
|
profile.source = -1;
|
|
profile.stream = -1;
|
|
} else if (source === 'videoaudio') {
|
|
custom.selected = false;
|
|
profile.source = 0;
|
|
|
|
for (let i = 0; i < $sources.video.streams.length; i++) {
|
|
if ($sources.video.streams[i].type === 'audio') {
|
|
custom.stream = i;
|
|
profile.stream = i;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
custom.selected = true;
|
|
custom.stream = -2;
|
|
|
|
profile.source = 1;
|
|
profile.stream = -1;
|
|
}
|
|
|
|
let audio = $sources.audio;
|
|
audio.type = source;
|
|
|
|
setSources({
|
|
...$sources,
|
|
audio: audio,
|
|
});
|
|
} else {
|
|
let video = $sources.video;
|
|
video.type = source;
|
|
|
|
setSources({
|
|
...$sources,
|
|
video: video,
|
|
});
|
|
}
|
|
|
|
setProfile({
|
|
...$profile,
|
|
[type]: profile,
|
|
custom: custom,
|
|
});
|
|
};
|
|
|
|
const handleSourceSettingsChange = (type, source, settings) => {
|
|
if (type === 'video') {
|
|
setVideoProbe({
|
|
...$videoProbe,
|
|
status: 'none',
|
|
});
|
|
} else {
|
|
setAudioProbe({
|
|
...$audioProbe,
|
|
status: 'none',
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleStreamSelect = (type, what) => (stream) => {
|
|
const profile = $profile;
|
|
|
|
profile[type].stream = stream;
|
|
|
|
if (what === 'custom') {
|
|
profile.custom.stream = stream;
|
|
}
|
|
|
|
setProfile({
|
|
...$profile,
|
|
...profile,
|
|
});
|
|
};
|
|
|
|
const handleHintModal = (type, streams) => (event) => {
|
|
if (event) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (!streams) {
|
|
streams = [];
|
|
}
|
|
|
|
if (streams.length > 0) {
|
|
return streams;
|
|
}
|
|
|
|
if (type === 'video') {
|
|
streams = [
|
|
{
|
|
url: '',
|
|
index: 0,
|
|
stream: 0,
|
|
type: 'video',
|
|
codec: 'h264',
|
|
width: 1920,
|
|
height: 1080,
|
|
pix_fmt: 'yuv420p',
|
|
sampling_hz: 0,
|
|
layout: '',
|
|
channels: 0,
|
|
},
|
|
];
|
|
} else if (type === 'audio') {
|
|
streams = [
|
|
{
|
|
url: '',
|
|
index: 1,
|
|
stream: 0,
|
|
type: 'audio',
|
|
codec: 'aac',
|
|
width: 0,
|
|
height: 0,
|
|
sampling_hz: '44100',
|
|
layout: 'stereo',
|
|
channels: 2,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (type === 'video') {
|
|
setHintModal({
|
|
...$hintModal,
|
|
open: true,
|
|
type: type,
|
|
streams: streams,
|
|
});
|
|
} else if (type === 'audio') {
|
|
setHintModal({
|
|
...$hintModal,
|
|
open: true,
|
|
type: type,
|
|
streams: streams,
|
|
});
|
|
} else {
|
|
setHintModal({
|
|
...$hintModal,
|
|
open: false,
|
|
type: '',
|
|
streams: [],
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleHintChange = (streams) => {
|
|
setHintModal({
|
|
...$hintModal,
|
|
streams: streams,
|
|
});
|
|
};
|
|
|
|
const handleHintCancel = () => {
|
|
setHintModal({
|
|
streams: [],
|
|
});
|
|
|
|
handleHintModal('none')(null);
|
|
};
|
|
|
|
const handleHintDone = () => {
|
|
const type = $hintModal.type;
|
|
|
|
const device = $sources[type].type;
|
|
const settings = $sources[type].settings;
|
|
const inputs = $sources[type].inputs;
|
|
const probe = {
|
|
streams: $hintModal.streams,
|
|
log: [],
|
|
};
|
|
|
|
const url = inputs[0].address;
|
|
|
|
probe.log.push(`Stream hints for input from '${url}'`);
|
|
|
|
for (let s of $hintModal.streams) {
|
|
s.url = url;
|
|
|
|
let stream = `Stream #${s.index}:${s.stream}: `;
|
|
if (s.type === 'video') {
|
|
stream += `Video: ${s.codec}, ${s.pix_fmt}, ${s.width}x${s.height}`;
|
|
} else if (s.type === 'audio') {
|
|
stream += `Audio: ${s.codec}, ${s.sampling_hz} Hz, ${s.layout}`;
|
|
}
|
|
|
|
probe.log.push(stream);
|
|
}
|
|
|
|
handleProbeStreams(type, device, settings, inputs, probe);
|
|
|
|
handleHintModal('none')(null);
|
|
};
|
|
|
|
if ($ready === false) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<React.Fragment>
|
|
{$activeStep === 0 && (
|
|
<React.Fragment>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h3">
|
|
<Trans>Video settings</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<SourceSelect
|
|
type="video"
|
|
skills={skills}
|
|
source={$sources.video}
|
|
config={config}
|
|
onProbe={handleProbe}
|
|
onChange={handleSourceSettingsChange}
|
|
onRefresh={handleRefresh}
|
|
onStore={handleStore}
|
|
/>
|
|
</Grid>
|
|
{$videoProbe.status !== 'none' && (
|
|
<React.Fragment>
|
|
{$videoProbe.status === 'error' && (
|
|
<Grid item xs={12} align="center">
|
|
<BoxText color="dark">
|
|
<WarningIcon fontSize="large" color="error" />
|
|
<Typography>
|
|
<Trans>
|
|
Failed to probe the source. Please check the{' '}
|
|
<Link color="textSecondary" href="#!" onClick={handleProbeLogModal('video')}>
|
|
probe details
|
|
</Link>
|
|
.
|
|
</Trans>
|
|
</Typography>
|
|
<Typography>
|
|
<Trans>
|
|
In order to proceed anyways, you can provide{' '}
|
|
<Link color="textSecondary" href="#!" onClick={handleHintModal('video', [])}>
|
|
hints
|
|
</Link>{' '}
|
|
about the available streams.
|
|
</Trans>
|
|
</Typography>
|
|
</BoxText>
|
|
</Grid>
|
|
)}
|
|
{$videoProbe.status === 'nostream' && (
|
|
<Grid item xs={12} align="center">
|
|
<BoxText color="dark">
|
|
<WarningIcon fontSize="large" color="error" />
|
|
<Typography>
|
|
<Trans>
|
|
The source doesn't provide any video streams. Please check the{' '}
|
|
<Link href="#!" onClick={handleProbeLogModal('video')}>
|
|
probe details
|
|
</Link>
|
|
.
|
|
</Trans>
|
|
</Typography>
|
|
</BoxText>
|
|
</Grid>
|
|
)}
|
|
{$videoProbe.status === 'success' && (
|
|
<React.Fragment>
|
|
<Grid item xs={12}>
|
|
<StreamSelect
|
|
type="video"
|
|
streams={$sources.video.streams}
|
|
selected={$profile.video.stream}
|
|
onChange={handleStreamSelect('video')}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} align="right">
|
|
<Typography>
|
|
<Trans>
|
|
<Link href="#!" onClick={handleProbeLogModal('video')}>
|
|
Show probe details
|
|
</Link>
|
|
</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<EncodingSelect
|
|
type="video"
|
|
streams={$sources.video.streams}
|
|
profile={$profile.video}
|
|
codecs={['copy', 'h264', 'hevc', 'av1', 'vp8', 'vp9']}
|
|
skills={skills}
|
|
onChange={handleEncoding('video')}
|
|
/>
|
|
</Grid>
|
|
{$profile.video.encoder.coder !== 'none' && $profile.video.encoder.coder !== 'copy' && (
|
|
<Grid item xs={12}>
|
|
<FilterSelect
|
|
type="video"
|
|
profile={$profile.video}
|
|
availableFilters={skills.filter}
|
|
onChange={handleFilter('video')}
|
|
/>
|
|
</Grid>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
<Grid item xs={12}>
|
|
<Divider />
|
|
</Grid>
|
|
</Grid>
|
|
<PaperFooter
|
|
buttonsLeft={
|
|
<React.Fragment>
|
|
<Button variant="outlined" color="default" onClick={handleAbort}>
|
|
<Trans>Abort</Trans>
|
|
</Button>
|
|
<Button variant="outlined" color="primary" disabled={$videoProbe.status !== 'success'} onClick={handleNextStep}>
|
|
<Trans>Next: Audio</Trans>
|
|
</Button>
|
|
</React.Fragment>
|
|
}
|
|
/>
|
|
</React.Fragment>
|
|
)}
|
|
{$activeStep === 1 && (
|
|
<React.Fragment>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h3">
|
|
<Trans>Audio settings</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<SourceSelect
|
|
type="audio"
|
|
skills={skills}
|
|
source={$sources.audio}
|
|
config={config}
|
|
onProbe={handleProbe}
|
|
onSelect={handleSourceChange}
|
|
onChange={handleSourceSettingsChange}
|
|
onRefresh={handleRefresh}
|
|
onStore={handleStore}
|
|
/>
|
|
</Grid>
|
|
{$profile.custom.selected === false && $profile.custom.stream >= 0 && (
|
|
<React.Fragment>
|
|
<Grid item xs={12}>
|
|
<StreamSelect
|
|
type="audio"
|
|
streams={$sources.video.streams}
|
|
selected={$profile.custom.stream}
|
|
onChange={handleStreamSelect('audio', 'custom')}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<EncodingSelect
|
|
type="audio"
|
|
streams={$sources.video.streams}
|
|
profile={$profile.audio}
|
|
codecs={['copy', 'aac', 'mp3', 'opus']}
|
|
skills={skills}
|
|
onChange={handleEncoding('audio')}
|
|
/>
|
|
</Grid>
|
|
{$profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy' && $profile.audio.source !== -1 && (
|
|
<Grid item xs={12}>
|
|
<FilterSelect type="audio" profile={$profile.audio} availableFilters={skills.filter} onChange={handleFilter('audio')} />
|
|
</Grid>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
{$profile.custom.selected === true && (
|
|
<React.Fragment>
|
|
{$audioProbe.status !== 'none' && (
|
|
<React.Fragment>
|
|
{$audioProbe.status === 'error' && (
|
|
<Grid item xs={12} align="center">
|
|
<BoxText color="dark">
|
|
<WarningIcon fontSize="large" color="error" />
|
|
<Typography>
|
|
<Trans>
|
|
Failed to probe the source. Please check the{' '}
|
|
<Link href="#!" onClick={handleProbeLogModal('audio')}>
|
|
probe details
|
|
</Link>
|
|
.
|
|
</Trans>
|
|
</Typography>
|
|
<Typography>
|
|
<Trans>
|
|
In order to proceed anyways, you can provide{' '}
|
|
<Link color="textSecondary" href="#!" onClick={handleHintModal('audio', [])}>
|
|
hints
|
|
</Link>{' '}
|
|
about the available streams.
|
|
</Trans>
|
|
</Typography>
|
|
</BoxText>
|
|
</Grid>
|
|
)}
|
|
{$audioProbe.status === 'nostream' && (
|
|
<Grid item xs={12} align="center">
|
|
<BoxText color="dark">
|
|
<WarningIcon fontSize="large" color="error" />
|
|
<Typography>
|
|
<Trans>
|
|
The source doesn't provide any audio streams. Please check the{' '}
|
|
<Link href="#!" onClick={handleProbeLogModal('audio')}>
|
|
probe details
|
|
</Link>
|
|
.
|
|
</Trans>
|
|
</Typography>
|
|
</BoxText>
|
|
</Grid>
|
|
)}
|
|
{$audioProbe.status === 'success' && (
|
|
<React.Fragment>
|
|
<Grid item xs={12}>
|
|
<StreamSelect
|
|
type="audio"
|
|
streams={$sources.audio.streams}
|
|
selected={$profile.audio.stream}
|
|
onChange={handleStreamSelect('audio')}
|
|
/>
|
|
</Grid>
|
|
<Grid item xs={12} align="right">
|
|
<Typography>
|
|
<Trans>
|
|
<Link href="#!" onClick={handleProbeLogModal('audio')}>
|
|
Show probe details
|
|
</Link>
|
|
</Trans>
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<EncodingSelect
|
|
type="audio"
|
|
streams={$sources.audio.streams}
|
|
profile={$profile.audio}
|
|
codecs={['copy', 'aac', 'mp3']}
|
|
skills={skills}
|
|
onChange={handleEncoding('audio')}
|
|
/>
|
|
</Grid>
|
|
{$profile.audio.encoder.coder !== 'none' && $profile.audio.encoder.coder !== 'copy' && (
|
|
<Grid item xs={12}>
|
|
<FilterSelect
|
|
type="audio"
|
|
profile={$profile.audio}
|
|
availableFilters={skills.filter}
|
|
onChange={handleFilter('audio')}
|
|
/>
|
|
</Grid>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
</React.Fragment>
|
|
)}
|
|
<Grid item xs={12}>
|
|
<Divider />
|
|
</Grid>
|
|
</Grid>
|
|
<PaperFooter
|
|
buttonsLeft={
|
|
<React.Fragment>
|
|
<Button variant="outlined" onClick={handleBackStep}>
|
|
<Trans>Back</Trans>
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
color="primary"
|
|
disabled={$profile.custom.selected === true && $audioProbe.status !== 'success'}
|
|
onClick={handleDone}
|
|
>
|
|
<Trans>Finish</Trans>
|
|
</Button>
|
|
</React.Fragment>
|
|
}
|
|
/>
|
|
</React.Fragment>
|
|
)}
|
|
<Backdrop open={$videoProbe.probing || $audioProbe.probing || $skillsRefresh}>
|
|
<CircularProgress color="inherit" />
|
|
</Backdrop>
|
|
<ProbeModal open={$probeModal.open} onClose={handleProbeLogModal('none')} data={$probeModal.data} />
|
|
<HintModal
|
|
open={$hintModal.open}
|
|
onClose={handleHintCancel}
|
|
onChange={handleHintChange}
|
|
onDone={handleHintDone}
|
|
title="Stream hints"
|
|
type={$hintModal.type}
|
|
streams={$hintModal.streams}
|
|
/>
|
|
</React.Fragment>
|
|
);
|
|
}
|