460 lines
15 KiB
TypeScript
460 lines
15 KiB
TypeScript
import { FileUpload } from "@mui/icons-material";
|
|
import {
|
|
Backdrop,
|
|
Button,
|
|
ButtonGroup,
|
|
CircularProgress,
|
|
Container,
|
|
FormControl,
|
|
Grid,
|
|
IconButton,
|
|
InputAdornment,
|
|
InputLabel,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
Snackbar,
|
|
styled,
|
|
TextField,
|
|
Typography
|
|
} from "@mui/material";
|
|
import { Buffer } from 'buffer';
|
|
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
import { useDispatch, useSelector } from "react-redux";
|
|
import { CliArguments } from "./classes";
|
|
import { StackableResult } from "./components/StackableResult";
|
|
import { serverStates } from "./events";
|
|
import { connected, setFreeSpace } from "./features/status/statusSlice";
|
|
import { I18nBuilder } from "./i18n";
|
|
import { IDLMetadata, IMessage } from "./interfaces";
|
|
import { RPCClient } from "./rpcClient";
|
|
import { RootState } from "./stores/store";
|
|
import { RPCResult } from "./types";
|
|
import { isValidURL, toFormatArgs, updateInStateMap } from "./utils";
|
|
|
|
type Props = {
|
|
socket: WebSocket
|
|
}
|
|
|
|
export default function Home({ socket }: Props) {
|
|
// redux state
|
|
const settings = useSelector((state: RootState) => state.settings)
|
|
const status = useSelector((state: RootState) => state.status)
|
|
const dispatch = useDispatch()
|
|
|
|
// ephemeral state
|
|
const [progressMap, setProgressMap] = useState(new Map<number, number>());
|
|
const [messageMap, setMessageMap] = useState(new Map<number, IMessage>());
|
|
|
|
const [activeDownloads, setActiveDownloads] = useState(new Array<RPCResult>());
|
|
const [downloadInfoMap, setDownloadInfoMap] = useState(new Map<number, IDLMetadata>());
|
|
const [downloadFormats, setDownloadFormats] = useState<IDLMetadata>();
|
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('');
|
|
const [pickedAudioFormat, setPickedAudioFormat] = useState('');
|
|
const [pickedBestFormat, setPickedBestFormat] = useState('');
|
|
|
|
const [downloadPath, setDownloadPath] = useState(0);
|
|
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([]);
|
|
|
|
const [fileNameOverride, setFilenameOverride] = useState('');
|
|
|
|
const [url, setUrl] = useState('');
|
|
const [workingUrl, setWorkingUrl] = useState('');
|
|
const [showBackdrop, setShowBackdrop] = useState(false);
|
|
const [showToast, setShowToast] = useState(true);
|
|
|
|
// memos
|
|
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
|
const client = useMemo(() => new RPCClient(socket), [settings.serverAddr, settings.serverPort])
|
|
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
|
|
|
/* -------------------- Effects -------------------- */
|
|
/* WebSocket connect event handler*/
|
|
useEffect(() => {
|
|
socket.onopen = () => {
|
|
dispatch(connected())
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => client.running(), 1000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
client.freeSpace()
|
|
.then(bytes => dispatch(setFreeSpace(bytes.result)))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
socket.onmessage = (event) => {
|
|
const res = client.decode(event.data)
|
|
if (showBackdrop) {
|
|
setShowBackdrop(false)
|
|
}
|
|
switch (typeof res.result) {
|
|
case 'object':
|
|
setActiveDownloads(
|
|
res.result
|
|
.filter((r: RPCResult) => !!r.info.url)
|
|
.sort((a: RPCResult, b: RPCResult) => a.info.title.localeCompare(b.info.title))
|
|
)
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
client.directoryTree()
|
|
.then(data => {
|
|
setAvailableDownloadPaths(data.result)
|
|
})
|
|
}, [])
|
|
|
|
/* -------------------- component functions -------------------- */
|
|
|
|
/**
|
|
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
|
*/
|
|
const sendUrl = (immediate?: string) => {
|
|
const codes = new Array<string>();
|
|
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
|
|
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
|
|
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
|
|
|
|
client.download(
|
|
immediate || url || workingUrl,
|
|
cliArgs.toString() + toFormatArgs(codes),
|
|
)
|
|
|
|
setUrl('')
|
|
setWorkingUrl('')
|
|
setFilenameOverride('')
|
|
|
|
setTimeout(() => {
|
|
resetInput()
|
|
setShowBackdrop(true)
|
|
setDownloadFormats(undefined)
|
|
}, 250);
|
|
}
|
|
|
|
/**
|
|
* Retrive url from input and display the formats selection view
|
|
*/
|
|
const sendUrlFormatSelection = () => {
|
|
setWorkingUrl(url)
|
|
setUrl('')
|
|
setPickedAudioFormat('')
|
|
setPickedVideoFormat('')
|
|
setPickedBestFormat('')
|
|
|
|
setShowBackdrop(true)
|
|
|
|
client.formats(url)
|
|
?.then(formats => {
|
|
console.log(formats)
|
|
setDownloadFormats(formats.result)
|
|
setShowBackdrop(false)
|
|
resetInput()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Update the url state whenever the input value changes
|
|
* @param e Input change event
|
|
*/
|
|
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setUrl(e.target.value)
|
|
}
|
|
|
|
/**
|
|
* Update the filename override state whenever the input value changes
|
|
* @param e Input change event
|
|
*/
|
|
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setFilenameOverride(e.target.value)
|
|
}
|
|
|
|
/**
|
|
* Abort a specific download if id's provided, other wise abort all running ones.
|
|
* @param id The download id / pid
|
|
* @returns void
|
|
*/
|
|
const abort = (id?: string) => {
|
|
if (id) {
|
|
client.kill(id)
|
|
return
|
|
}
|
|
client.killAll()
|
|
}
|
|
|
|
const parseUrlListFile = (event: any) => {
|
|
const urlList = event.target.files
|
|
const reader = new FileReader()
|
|
reader.addEventListener('load', $event => {
|
|
const base64 = $event.target?.result!.toString().split(',')[1]
|
|
Buffer.from(base64!, 'base64')
|
|
.toString()
|
|
.trimEnd()
|
|
.split('\n')
|
|
.filter(_url => isValidURL(_url))
|
|
.forEach(_url => sendUrl(_url))
|
|
})
|
|
reader.readAsDataURL(urlList[0])
|
|
}
|
|
|
|
const resetInput = () => {
|
|
const input = document.getElementById('urlInput') as HTMLInputElement;
|
|
input.value = '';
|
|
|
|
const filename = document.getElementById('customFilenameInput') as HTMLInputElement;
|
|
if (filename) {
|
|
filename.value = '';
|
|
}
|
|
}
|
|
|
|
/* -------------------- styled components -------------------- */
|
|
|
|
const Input = styled('input')({
|
|
display: 'none',
|
|
});
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
|
<Backdrop
|
|
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
open={showBackdrop}
|
|
>
|
|
<CircularProgress color="primary" />
|
|
</Backdrop>
|
|
<Grid container spacing={2}>
|
|
<Grid item xs={12}>
|
|
<Paper
|
|
sx={{
|
|
p: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Grid container>
|
|
<TextField
|
|
fullWidth
|
|
id="urlInput"
|
|
label={i18n.t('urlInput')}
|
|
variant="outlined"
|
|
onChange={handleUrlChange}
|
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
|
InputProps={{
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<label htmlFor="icon-button-file">
|
|
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
|
<IconButton color="primary" aria-label="upload file" component="span">
|
|
<FileUpload />
|
|
</IconButton>
|
|
</label>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
/>
|
|
</Grid>
|
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
|
{
|
|
settings.fileRenaming ?
|
|
<Grid item xs={8}>
|
|
<TextField
|
|
id="customFilenameInput"
|
|
fullWidth
|
|
label={i18n.t('customFilename')}
|
|
variant="outlined"
|
|
onChange={handleFilenameOverrideChange}
|
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
|
/>
|
|
</Grid> :
|
|
null
|
|
}
|
|
{
|
|
settings.pathOverriding ?
|
|
<Grid item xs={4}>
|
|
<FormControl fullWidth>
|
|
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
|
<Select
|
|
label={i18n.t('customPath')}
|
|
defaultValue={0}
|
|
variant={'outlined'}
|
|
value={downloadPath}
|
|
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
|
>
|
|
{availableDownloadPaths.map((val: string, idx: number) => (
|
|
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
</Grid> :
|
|
null
|
|
}
|
|
</Grid>
|
|
<Grid container spacing={1} pt={2}>
|
|
<Grid item>
|
|
<Button
|
|
variant="contained"
|
|
disabled={url === ''}
|
|
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
|
>
|
|
{i18n.t('startButton')}
|
|
</Button>
|
|
</Grid>
|
|
<Grid item>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => abort()}
|
|
>
|
|
{i18n.t('abortAllButton')}
|
|
</Button>
|
|
</Grid>
|
|
</Grid>
|
|
</Paper>
|
|
</Grid>
|
|
</Grid >
|
|
{/* Format Selection grid */}
|
|
{
|
|
downloadFormats ? <Grid container spacing={2} mt={2}>
|
|
<Grid item xs={12}>
|
|
<Paper
|
|
sx={{
|
|
p: 2,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Grid container>
|
|
<Grid item xs={12}>
|
|
<Typography variant="h6" component="div" pb={1}>
|
|
{downloadFormats.title}
|
|
</Typography>
|
|
{/* <Skeleton variant="rectangular" height={180} /> */}
|
|
</Grid>
|
|
<Grid item xs={12} pb={1}>
|
|
<img src={downloadFormats.thumbnail} height={260} width="100%" style={{ objectFit: 'cover' }} />
|
|
</Grid>
|
|
{/* video only */}
|
|
<Grid item xs={12}>
|
|
<Typography variant="body1" component="div">
|
|
Best quality
|
|
</Typography>
|
|
</Grid>
|
|
<Grid item pr={2} py={1}>
|
|
<Button
|
|
variant="contained"
|
|
disabled={pickedBestFormat !== ''}
|
|
onClick={() => {
|
|
setPickedBestFormat(downloadFormats.best.format_id)
|
|
setPickedVideoFormat('')
|
|
setPickedAudioFormat('')
|
|
}}>
|
|
{downloadFormats.best.format_note || downloadFormats.best.format_id} - {downloadFormats.best.vcodec}+{downloadFormats.best.acodec}
|
|
</Button>
|
|
</Grid>
|
|
{/* video only */}
|
|
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
|
<Grid item xs={12}>
|
|
<Typography variant="body1" component="div">
|
|
Video data {downloadFormats.formats[1].acodec}
|
|
</Typography>
|
|
</Grid>
|
|
: null
|
|
}
|
|
{downloadFormats.formats
|
|
.filter(format => format.acodec === 'none' && format.vcodec !== 'none')
|
|
.map((format, idx) => (
|
|
<Grid item pr={2} py={1} key={idx}>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => {
|
|
setPickedVideoFormat(format.format_id)
|
|
setPickedBestFormat('')
|
|
}}
|
|
disabled={pickedVideoFormat === format.format_id}
|
|
>
|
|
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
|
</Button>
|
|
</Grid>
|
|
))
|
|
}
|
|
{downloadFormats.formats.filter(format => format.acodec === 'none' && format.vcodec !== 'none').length ?
|
|
<Grid item xs={12}>
|
|
<Typography variant="body1" component="div">
|
|
Audio data
|
|
</Typography>
|
|
</Grid>
|
|
: null
|
|
}
|
|
{downloadFormats.formats
|
|
.filter(format => format.acodec !== 'none' && format.vcodec === 'none')
|
|
.map((format, idx) => (
|
|
<Grid item pr={2} py={1} key={idx}>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => {
|
|
setPickedAudioFormat(format.format_id)
|
|
setPickedBestFormat('')
|
|
}}
|
|
disabled={pickedAudioFormat === format.format_id}
|
|
>
|
|
{format.format_note} - {format.vcodec === 'none' ? format.acodec : format.vcodec}
|
|
</Button>
|
|
</Grid>
|
|
))
|
|
}
|
|
<Grid item xs={12} pt={2}>
|
|
<ButtonGroup disableElevation variant="contained">
|
|
<Button
|
|
onClick={() => sendUrl()}
|
|
disabled={!pickedBestFormat && !(pickedAudioFormat || pickedVideoFormat)}
|
|
> Download
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setPickedAudioFormat('');
|
|
setPickedVideoFormat('');
|
|
setPickedBestFormat('');
|
|
}}
|
|
> Clear
|
|
</Button>
|
|
</ButtonGroup>
|
|
</Grid>
|
|
</Grid>
|
|
</Paper>
|
|
</Grid>
|
|
</Grid> : null
|
|
}
|
|
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
|
{
|
|
activeDownloads.map(download => (
|
|
<Grid item xs={4} sm={8} md={6} key={download.id}>
|
|
<Fragment>
|
|
<StackableResult
|
|
title={download.info.title}
|
|
thumbnail={download.info.thumbnail}
|
|
percentage={download.progress.percentage}
|
|
stopCallback={() => abort(download.id)}
|
|
resolution={download.info.resolution ?? ''}
|
|
speed={download.progress.speed}
|
|
size={download.info.filesize_approx ?? 0}
|
|
/>
|
|
</Fragment>
|
|
</Grid>
|
|
))
|
|
}
|
|
</Grid>
|
|
<Snackbar
|
|
open={showToast === status.connected}
|
|
autoHideDuration={1500}
|
|
message="Connected"
|
|
onClose={() => setShowToast(false)}
|
|
/>
|
|
</Container >
|
|
);
|
|
} |