New home view layout (#58)
* Home layout refactor, moved new download to dialog * sort downloads by date
This commit is contained in:
335
frontend/src/components/DownloadDialog.tsx
Normal file
335
frontend/src/components/DownloadDialog.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { FileUpload } from '@mui/icons-material'
|
||||||
|
import CloseIcon from '@mui/icons-material/Close'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
InputAdornment,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
styled,
|
||||||
|
TextField
|
||||||
|
} from '@mui/material'
|
||||||
|
import AppBar from '@mui/material/AppBar'
|
||||||
|
import Dialog from '@mui/material/Dialog'
|
||||||
|
import Slide from '@mui/material/Slide'
|
||||||
|
import Toolbar from '@mui/material/Toolbar'
|
||||||
|
import { TransitionProps } from '@mui/material/transitions'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { Buffer } from 'buffer'
|
||||||
|
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import FormatsGrid from '../components/FormatsGrid'
|
||||||
|
import { CliArguments } from '../lib/argsParser'
|
||||||
|
import I18nBuilder from '../lib/intl'
|
||||||
|
import { RPCClient } from '../lib/rpcClient'
|
||||||
|
import { RootState } from '../stores/store'
|
||||||
|
import type { DLMetadata } from '../types'
|
||||||
|
import { isValidURL, toFormatArgs } from '../utils'
|
||||||
|
|
||||||
|
const Transition = forwardRef(function Transition(
|
||||||
|
props: TransitionProps & {
|
||||||
|
children: React.ReactElement
|
||||||
|
},
|
||||||
|
ref: React.Ref<unknown>,
|
||||||
|
) {
|
||||||
|
return <Slide direction="up" ref={ref} {...props} />
|
||||||
|
})
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadDialog({ open, onClose }: Props) {
|
||||||
|
// redux state
|
||||||
|
const settings = useSelector((state: RootState) => state.settings)
|
||||||
|
const status = useSelector((state: RootState) => state.status)
|
||||||
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
|
// ephemeral state
|
||||||
|
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||||
|
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||||
|
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||||
|
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||||
|
|
||||||
|
const [customArgs, setCustomArgs] = useState('')
|
||||||
|
const [downloadPath, setDownloadPath] = useState(0)
|
||||||
|
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
|
||||||
|
|
||||||
|
const [fileNameOverride, setFilenameOverride] = useState('')
|
||||||
|
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [workingUrl, setWorkingUrl] = useState('')
|
||||||
|
|
||||||
|
// memos
|
||||||
|
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||||
|
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||||
|
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||||
|
|
||||||
|
// refs
|
||||||
|
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// effects
|
||||||
|
useEffect(() => {
|
||||||
|
client.directoryTree()
|
||||||
|
.then(data => {
|
||||||
|
setAvailableDownloadPaths(data.result)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
||||||
|
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)} ${customArgs}`,
|
||||||
|
availableDownloadPaths[downloadPath] ?? '',
|
||||||
|
fileNameOverride
|
||||||
|
)
|
||||||
|
|
||||||
|
setUrl('')
|
||||||
|
setWorkingUrl('')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resetInput()
|
||||||
|
setDownloadFormats(undefined)
|
||||||
|
onClose()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrive url from input and display the formats selection view
|
||||||
|
*/
|
||||||
|
const sendUrlFormatSelection = () => {
|
||||||
|
setWorkingUrl(url)
|
||||||
|
setUrl('')
|
||||||
|
setPickedAudioFormat('')
|
||||||
|
setPickedVideoFormat('')
|
||||||
|
setPickedBestFormat('')
|
||||||
|
|
||||||
|
client.formats(url)
|
||||||
|
?.then(formats => {
|
||||||
|
setDownloadFormats(formats.result)
|
||||||
|
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)
|
||||||
|
localStorage.setItem('last-filename-override', e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the custom args state whenever the input value changes
|
||||||
|
* @param e Input change event
|
||||||
|
*/
|
||||||
|
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setCustomArgs(e.target.value)
|
||||||
|
localStorage.setItem("last-input-args", e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = () => {
|
||||||
|
urlInputRef.current!.value = ''
|
||||||
|
if (customFilenameInputRef.current) {
|
||||||
|
customFilenameInputRef.current!.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- styled components -------------------- */
|
||||||
|
|
||||||
|
const Input = styled('input')({
|
||||||
|
display: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog
|
||||||
|
fullScreen
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
TransitionComponent={Transition}
|
||||||
|
>
|
||||||
|
<AppBar sx={{ position: 'relative' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="close"
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ ml: 2, flex: 1 }} variant="h6" component="div">
|
||||||
|
Download
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Container sx={{ mt: 4 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid container>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
ref={urlInputRef}
|
||||||
|
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.enableCustomArgs &&
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('customArgsInput')}
|
||||||
|
variant="outlined"
|
||||||
|
onChange={handleCustomArgsChange}
|
||||||
|
value={customArgs}
|
||||||
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
settings.fileRenaming &&
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<TextField
|
||||||
|
ref={customFilenameInputRef}
|
||||||
|
fullWidth
|
||||||
|
label={i18n.t('customFilename')}
|
||||||
|
variant="outlined"
|
||||||
|
value={fileNameOverride}
|
||||||
|
onChange={handleFilenameOverrideChange}
|
||||||
|
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} pt={2}>
|
||||||
|
<Grid item>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={url === ''}
|
||||||
|
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
||||||
|
>
|
||||||
|
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
</Grid >
|
||||||
|
{/* Format Selection grid */}
|
||||||
|
{downloadFormats && <FormatsGrid
|
||||||
|
downloadFormats={downloadFormats}
|
||||||
|
onBestQualitySelected={(id) => {
|
||||||
|
setPickedBestFormat(id)
|
||||||
|
setPickedVideoFormat('')
|
||||||
|
setPickedAudioFormat('')
|
||||||
|
}}
|
||||||
|
onVideoSelected={(id) => {
|
||||||
|
setPickedVideoFormat(id)
|
||||||
|
setPickedBestFormat('')
|
||||||
|
}}
|
||||||
|
onAudioSelected={(id) => {
|
||||||
|
setPickedAudioFormat(id)
|
||||||
|
setPickedBestFormat('')
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
setPickedAudioFormat('')
|
||||||
|
setPickedVideoFormat('')
|
||||||
|
setPickedBestFormat('')
|
||||||
|
}}
|
||||||
|
onSubmit={sendUrl}
|
||||||
|
pickedBestFormat={pickedBestFormat}
|
||||||
|
pickedVideoFormat={pickedVideoFormat}
|
||||||
|
pickedAudioFormat={pickedAudioFormat}
|
||||||
|
/>}
|
||||||
|
</Container>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export function DownloadsListView({ downloads, abortFunction }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12 }} pt={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TableContainer component={Paper} sx={{ minHeight: '65vh' }} elevation={2}>
|
<TableContainer component={Paper} sx={{ minHeight: '80vh' }} elevation={2}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export default function Logout() {
|
|||||||
<ListItemIcon>
|
<ListItemIcon>
|
||||||
<LogoutIcon />
|
<LogoutIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText primary="Authentication" />
|
<ListItemText primary="RPC authentication" />
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
34
frontend/src/components/Splash.tsx
Normal file
34
frontend/src/components/Splash.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
|
import { Container, SvgIcon, Typography, styled } from '@mui/material'
|
||||||
|
|
||||||
|
const FlexContainer = styled(Container)({
|
||||||
|
display: 'flex',
|
||||||
|
minWidth: '100%',
|
||||||
|
minHeight: '80vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column'
|
||||||
|
})
|
||||||
|
|
||||||
|
const Title = styled(Typography)({
|
||||||
|
display: 'flex',
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingBottom: '0.5rem'
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function Splash() {
|
||||||
|
return (
|
||||||
|
<FlexContainer>
|
||||||
|
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
|
||||||
|
<SvgIcon sx={{ fontSize: '200px' }}>
|
||||||
|
<CloudDownloadIcon />
|
||||||
|
</SvgIcon>
|
||||||
|
</Title>
|
||||||
|
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
|
||||||
|
No active downloads
|
||||||
|
</Title>
|
||||||
|
</FlexContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||||
|
|
||||||
|
export interface FormatSelectionState {
|
||||||
|
bestFormat: string
|
||||||
|
audioFormat: string
|
||||||
|
videoFormat: string
|
||||||
|
}
|
||||||
35
frontend/src/types/index.d.ts
vendored
35
frontend/src/types/index.d.ts
vendored
@@ -21,23 +21,28 @@ export type RPCResponse<T> = {
|
|||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DownloadInfo = {
|
||||||
|
url: string
|
||||||
|
filesize_approx?: number
|
||||||
|
resolution?: string
|
||||||
|
thumbnail: string
|
||||||
|
title: string
|
||||||
|
vcodec?: string
|
||||||
|
acodec?: string
|
||||||
|
ext?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadProgress = {
|
||||||
|
speed: number
|
||||||
|
eta: number
|
||||||
|
percentage: string
|
||||||
|
}
|
||||||
|
|
||||||
export type RPCResult = {
|
export type RPCResult = {
|
||||||
id: string
|
id: string
|
||||||
progress: {
|
progress: DownloadProgress
|
||||||
speed: number
|
info: DownloadInfo
|
||||||
eta: number
|
|
||||||
percentage: string
|
|
||||||
}
|
|
||||||
info: {
|
|
||||||
url: string
|
|
||||||
filesize_approx?: number
|
|
||||||
resolution?: string
|
|
||||||
thumbnail: string
|
|
||||||
title: string
|
|
||||||
vcodec?: string
|
|
||||||
acodec?: string
|
|
||||||
ext?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RPCParams = {
|
export type RPCParams = {
|
||||||
|
|||||||
@@ -91,4 +91,6 @@ export function formatGiB(bytes: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
export const roundMiB = (bytes: number) => `${(bytes / 1_000_000).toFixed(2)} MiB`
|
||||||
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
export const formatSpeedMiB = (val: number) => `${roundMiB(val)}/s`
|
||||||
|
|
||||||
|
export const dateTimeComparatorFunc = (a: string, b: string) => new Date(a).getTime() - new Date(b).getTime()
|
||||||
|
|||||||
@@ -1,40 +1,30 @@
|
|||||||
import { FileUpload } from '@mui/icons-material'
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
|
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
Button,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Container,
|
Container,
|
||||||
FormControl,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
InputAdornment,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Snackbar,
|
Snackbar,
|
||||||
SpeedDial,
|
SpeedDial,
|
||||||
SpeedDialAction,
|
SpeedDialAction,
|
||||||
SpeedDialIcon,
|
SpeedDialIcon,
|
||||||
styled,
|
styled
|
||||||
TextField
|
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Buffer } from 'buffer'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useDispatch, useSelector } from 'react-redux'
|
||||||
|
import DownloadDialog from '../components/DownloadDialog'
|
||||||
import { DownloadsCardView } from '../components/DownloadsCardView'
|
import { DownloadsCardView } from '../components/DownloadsCardView'
|
||||||
import { DownloadsListView } from '../components/DownloadsListView'
|
import { DownloadsListView } from '../components/DownloadsListView'
|
||||||
import FormatsGrid from '../components/FormatsGrid'
|
import Splash from '../components/Splash'
|
||||||
import { CliArguments } from '../lib/argsParser'
|
|
||||||
import I18nBuilder from '../lib/intl'
|
|
||||||
import { RPCClient, socket$ } from '../lib/rpcClient'
|
|
||||||
import { toggleListView } from '../features/settings/settingsSlice'
|
import { toggleListView } from '../features/settings/settingsSlice'
|
||||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
||||||
|
import I18nBuilder from '../lib/intl'
|
||||||
|
import { RPCClient, socket$ } from '../lib/rpcClient'
|
||||||
import { RootState } from '../stores/store'
|
import { RootState } from '../stores/store'
|
||||||
import type { DLMetadata, RPCResponse, RPCResult } from '../types'
|
import type { RPCResponse, RPCResult } from '../types'
|
||||||
import { isValidURL, toFormatArgs } from '../utils'
|
import { dateTimeComparatorFunc } from '../utils'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// redux state
|
// redux state
|
||||||
@@ -43,34 +33,17 @@ export default function Home() {
|
|||||||
const dispatch = useDispatch()
|
const dispatch = useDispatch()
|
||||||
|
|
||||||
// ephemeral state
|
// ephemeral state
|
||||||
const [activeDownloads, setActiveDownloads] = useState<Array<RPCResult>>()
|
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
|
||||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
|
||||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
|
||||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
|
||||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
|
||||||
|
|
||||||
const [customArgs, setCustomArgs] = 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(true)
|
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||||
const [showToast, setShowToast] = useState(true)
|
const [showToast, setShowToast] = useState(true)
|
||||||
|
|
||||||
|
const [openDialog, setOpenDialog] = useState(false)
|
||||||
const [socketHasError, setSocketHasError] = useState(false)
|
const [socketHasError, setSocketHasError] = useState(false)
|
||||||
|
|
||||||
// memos
|
// memos
|
||||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||||
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
|
||||||
|
|
||||||
// refs
|
|
||||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
|
|
||||||
/* -------------------- Effects -------------------- */
|
/* -------------------- Effects -------------------- */
|
||||||
|
|
||||||
@@ -81,8 +54,6 @@ export default function Home() {
|
|||||||
const sub = socket$.subscribe({
|
const sub = socket$.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
dispatch(connected())
|
dispatch(connected())
|
||||||
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
|
||||||
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
|
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
setSocketHasError(true)
|
setSocketHasError(true)
|
||||||
@@ -117,7 +88,10 @@ export default function Home() {
|
|||||||
setActiveDownloads(
|
setActiveDownloads(
|
||||||
(event.result ?? [])
|
(event.result ?? [])
|
||||||
.filter((r) => !!r.info.url)
|
.filter((r) => !!r.info.url)
|
||||||
.sort((a, b) => a.info.title.localeCompare(b.info.title))
|
.sort((a, b) => dateTimeComparatorFunc(
|
||||||
|
b.info.created_at,
|
||||||
|
a.info.created_at,
|
||||||
|
))
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -133,88 +107,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [activeDownloads?.length])
|
}, [activeDownloads?.length])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
client.directoryTree()
|
|
||||||
.then(data => {
|
|
||||||
setAvailableDownloadPaths(data.result)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* -------------------- callbacks-------------------- */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)} ${customArgs}`,
|
|
||||||
availableDownloadPaths[downloadPath] ?? '',
|
|
||||||
fileNameOverride
|
|
||||||
)
|
|
||||||
|
|
||||||
setUrl('')
|
|
||||||
setWorkingUrl('')
|
|
||||||
setShowBackdrop(true)
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
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)
|
|
||||||
localStorage.setItem('last-filename-override', e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the custom args state whenever the input value changes
|
|
||||||
* @param e Input change event
|
|
||||||
*/
|
|
||||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setCustomArgs(e.target.value)
|
|
||||||
localStorage.setItem("last-input-args", e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||||
* @param id The download id / pid
|
* @param id The download id / pid
|
||||||
@@ -228,27 +120,6 @@ export default function Home() {
|
|||||||
client.killAll()
|
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 = () => {
|
|
||||||
urlInputRef.current!.value = '';
|
|
||||||
if (customFilenameInputRef.current) {
|
|
||||||
customFilenameInputRef.current!.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------- styled components -------------------- */
|
/* -------------------- styled components -------------------- */
|
||||||
|
|
||||||
@@ -264,133 +135,9 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
<CircularProgress color="primary" />
|
<CircularProgress color="primary" />
|
||||||
</Backdrop>
|
</Backdrop>
|
||||||
<Grid container spacing={2}>
|
{activeDownloads?.length === 0 &&
|
||||||
<Grid item xs={12}>
|
<Splash />
|
||||||
<Paper
|
}
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid container>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
ref={urlInputRef}
|
|
||||||
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.enableCustomArgs &&
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customArgsInput')}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleCustomArgsChange}
|
|
||||||
value={customArgs}
|
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
settings.fileRenaming &&
|
|
||||||
<Grid item xs={8}>
|
|
||||||
<TextField
|
|
||||||
ref={customFilenameInputRef}
|
|
||||||
fullWidth
|
|
||||||
label={i18n.t('customFilename')}
|
|
||||||
variant="outlined"
|
|
||||||
value={fileNameOverride}
|
|
||||||
onChange={handleFilenameOverrideChange}
|
|
||||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
</Grid>
|
|
||||||
<Grid container spacing={1} pt={2}>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={url === ''}
|
|
||||||
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
|
||||||
>
|
|
||||||
{settings.formatSelection ? i18n.t('selectFormatButton') : 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 && <FormatsGrid
|
|
||||||
downloadFormats={downloadFormats}
|
|
||||||
onBestQualitySelected={(id) => {
|
|
||||||
setPickedBestFormat(id)
|
|
||||||
setPickedVideoFormat('')
|
|
||||||
setPickedAudioFormat('')
|
|
||||||
}}
|
|
||||||
onVideoSelected={(id) => {
|
|
||||||
setPickedVideoFormat(id)
|
|
||||||
setPickedBestFormat('')
|
|
||||||
}}
|
|
||||||
onAudioSelected={(id) => {
|
|
||||||
setPickedAudioFormat(id)
|
|
||||||
setPickedBestFormat('')
|
|
||||||
}}
|
|
||||||
onClear={() => {
|
|
||||||
setPickedAudioFormat('');
|
|
||||||
setPickedVideoFormat('');
|
|
||||||
setPickedBestFormat('');
|
|
||||||
}}
|
|
||||||
onSubmit={sendUrl}
|
|
||||||
pickedBestFormat={pickedBestFormat}
|
|
||||||
pickedVideoFormat={pickedVideoFormat}
|
|
||||||
pickedAudioFormat={pickedAudioFormat}
|
|
||||||
/>}
|
|
||||||
{
|
{
|
||||||
settings.listView ?
|
settings.listView ?
|
||||||
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
|
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
|
||||||
@@ -418,10 +165,25 @@ export default function Home() {
|
|||||||
<SpeedDialAction
|
<SpeedDialAction
|
||||||
icon={<FormatListBulleted />}
|
icon={<FormatListBulleted />}
|
||||||
tooltipTitle={`Table view`}
|
tooltipTitle={`Table view`}
|
||||||
tooltipOpen
|
|
||||||
onClick={() => dispatch(toggleListView())}
|
onClick={() => dispatch(toggleListView())}
|
||||||
/>
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<DeleteForeverIcon />}
|
||||||
|
tooltipTitle={i18n.t('abortAllButton')}
|
||||||
|
onClick={() => abort()}
|
||||||
|
/>
|
||||||
|
<SpeedDialAction
|
||||||
|
icon={<AddCircleIcon />}
|
||||||
|
tooltipTitle={`New download`}
|
||||||
|
onClick={() => setOpenDialog(true)}
|
||||||
|
/>
|
||||||
</SpeedDial>
|
</SpeedDial>
|
||||||
|
<DownloadDialog open={openDialog} onClose={() => {
|
||||||
|
setOpenDialog(false)
|
||||||
|
activeDownloads?.length === 0
|
||||||
|
? setShowBackdrop(false)
|
||||||
|
: setShowBackdrop(true)
|
||||||
|
}} />
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ func (p *Process) Start(path, filename string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Cannot retrieve info for", p.url)
|
log.Println("Cannot retrieve info for", p.url)
|
||||||
}
|
}
|
||||||
info := DownloadInfo{URL: p.url}
|
info := DownloadInfo{
|
||||||
|
URL: p.url,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
json.Unmarshal(stdout, &info)
|
json.Unmarshal(stdout, &info)
|
||||||
p.mem.UpdateInfo(p.id, info)
|
p.mem.UpdateInfo(p.id, info)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Progress for the Running call
|
// Progress for the Running call
|
||||||
type DownloadProgress struct {
|
type DownloadProgress struct {
|
||||||
Percentage string `json:"percentage"`
|
Percentage string `json:"percentage"`
|
||||||
@@ -9,14 +11,15 @@ type DownloadProgress struct {
|
|||||||
|
|
||||||
// Used to deser the yt-dlp -J output
|
// Used to deser the yt-dlp -J output
|
||||||
type DownloadInfo struct {
|
type DownloadInfo struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Thumbnail string `json:"thumbnail"`
|
Thumbnail string `json:"thumbnail"`
|
||||||
Resolution string `json:"resolution"`
|
Resolution string `json:"resolution"`
|
||||||
Size int32 `json:"filesize_approx"`
|
Size int32 `json:"filesize_approx"`
|
||||||
VCodec string `json:"vcodec"`
|
VCodec string `json:"vcodec"`
|
||||||
ACodec string `json:"acodec"`
|
ACodec string `json:"acodec"`
|
||||||
Extension string `json:"ext"`
|
Extension string `json:"ext"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used to deser the formats in the -J output
|
// Used to deser the formats in the -J output
|
||||||
|
|||||||
Reference in New Issue
Block a user