migrated from redux to recoil

This commit is contained in:
2023-07-31 12:27:36 +02:00
parent 8327d1e94c
commit b5731759b0
36 changed files with 810 additions and 741 deletions

View File

@@ -13,11 +13,10 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.11.16", "@mui/icons-material": "^5.11.16",
"@mui/material": "^5.13.5", "@mui/material": "^5.13.5",
"@reduxjs/toolkit": "^1.9.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-redux": "^8.1.1",
"react-router-dom": "^6.13.0", "react-router-dom": "^6.13.0",
"recoil": "^0.7.7",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
@@ -28,9 +27,9 @@
"@types/react-dom": "^18.2.6", "@types/react-dom": "^18.2.6",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.2",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.3",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"typescript": "^5.1.3", "typescript": "^5.1.3",
"vite": "^4.3.9" "vite": "^4.4.7"
} }
} }

View File

@@ -1,12 +1,11 @@
import { Provider } from 'react-redux'
import { RouterProvider } from 'react-router-dom' import { RouterProvider } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import { router } from './router' import { router } from './router'
import { store } from './stores/store'
export function App() { export function App() {
return ( return (
<Provider store={store}> <RecoilRoot>
<RouterProvider router={router} /> <RouterProvider router={router} />
</Provider> </RecoilRoot>
) )
} }

View File

@@ -1,12 +1,12 @@
import { ThemeProvider } from '@emotion/react' import { ThemeProvider } from '@emotion/react'
import ChevronLeft from '@mui/icons-material/ChevronLeft' import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard' import Dashboard from '@mui/icons-material/Dashboard'
import DownloadIcon from '@mui/icons-material/Download'
import Menu from '@mui/icons-material/Menu' import Menu from '@mui/icons-material/Menu'
import SettingsIcon from '@mui/icons-material/Settings' import SettingsIcon from '@mui/icons-material/Settings'
import SettingsEthernet from '@mui/icons-material/SettingsEthernet' import SettingsEthernet from '@mui/icons-material/SettingsEthernet'
import Storage from '@mui/icons-material/Storage' import Storage from '@mui/icons-material/Storage'
import { Box, createTheme } from '@mui/material' import { Box, createTheme } from '@mui/material'
import DownloadIcon from '@mui/icons-material/Download'
import CssBaseline from '@mui/material/CssBaseline' import CssBaseline from '@mui/material/CssBaseline'
import Divider from '@mui/material/Divider' import Divider from '@mui/material/Divider'
import IconButton from '@mui/material/IconButton' import IconButton from '@mui/material/IconButton'
@@ -18,23 +18,23 @@ import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { grey } from '@mui/material/colors' import { grey } from '@mui/material/colors'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useSelector } from 'react-redux'
import { Link, Outlet } from 'react-router-dom' import { Link, Outlet } from 'react-router-dom'
import { RootState } from './stores/store' import { useRecoilValue } from 'recoil'
import { settingsState } from './atoms/settings'
import { statusState } from './atoms/status'
import AppBar from './components/AppBar' import AppBar from './components/AppBar'
import Drawer from './components/Drawer' import Drawer from './components/Drawer'
import Logout from './components/Logout' import Logout from './components/Logout'
import ThemeToggler from './components/ThemeToggler' import ThemeToggler from './components/ThemeToggler'
import Toaster from './providers/ToasterProvider' import Toaster from './providers/ToasterProvider'
import I18nProvider from './providers/i18nProvider'
import RPCClientProvider from './providers/rpcClientProvider'
import { formatGiB } from './utils' import { formatGiB } from './utils'
import SocketSubscriber from './components/SocketSubscriber'
export default function Layout() { export default function Layout() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const settings = useSelector((state: RootState) => state.settings) const settings = useRecoilValue(settingsState)
const status = useSelector((state: RootState) => state.status) const status = useRecoilValue(statusState)
const mode = settings.theme const mode = settings.theme
const theme = useMemo(() => const theme = useMemo(() =>
@@ -54,132 +54,130 @@ export default function Layout() {
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<I18nProvider> <SocketSubscriber>
<RPCClientProvider> <Box sx={{ display: 'flex' }}>
<Box sx={{ display: 'flex' }}> <CssBaseline />
<CssBaseline /> <AppBar position="absolute" open={open}>
<AppBar position="absolute" open={open}> <Toolbar sx={{ pr: '24px' }}>
<Toolbar sx={{ pr: '24px' }}> <IconButton
<IconButton edge="start"
edge="start" color="inherit"
color="inherit" aria-label="open drawer"
aria-label="open drawer" onClick={toggleDrawer}
onClick={toggleDrawer}
sx={{
marginRight: '36px',
...(open && { display: 'none' }),
}}
>
<Menu />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
sx={{ flexGrow: 1 }}
>
yt-dlp WebUI
</Typography>
{
status.freeSpace ?
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<Storage />
<span>
&nbsp;{formatGiB(status.freeSpace)}&nbsp;
</span>
</div>
: null
}
<div style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}>
<SettingsEthernet />
<span>
&nbsp;{status.connected ? settings.serverAddr : 'not connected'}
</span>
</div>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<Toolbar
sx={{ sx={{
display: 'flex', marginRight: '36px',
alignItems: 'center', ...(open && { display: 'none' }),
justifyContent: 'flex-end',
px: [1],
}} }}
> >
<IconButton onClick={toggleDrawer}> <Menu />
<ChevronLeft /> </IconButton>
</IconButton> <Typography
</Toolbar> component="h1"
<Divider /> variant="h6"
<List component="nav"> color="inherit"
<Link to={'/'} style={ noWrap
{ sx={{ flexGrow: 1 }}
textDecoration: 'none', >
color: mode === 'dark' ? '#ffffff' : '#000000DE' yt-dlp WebUI
} </Typography>
}> {
<ListItemButton disabled={status.downloading}> status.freeSpace ?
<ListItemIcon> <div style={{
<Dashboard /> display: 'flex',
</ListItemIcon> alignItems: 'center',
<ListItemText primary="Home" /> flexWrap: 'wrap',
</ListItemButton> }}>
</Link> <Storage />
<Link to={'/archive'} style={ <span>
{ &nbsp;{formatGiB(status.freeSpace)}&nbsp;
textDecoration: 'none', </span>
color: mode === 'dark' ? '#ffffff' : '#000000DE' </div>
} : null
}> }
<ListItemButton disabled={status.downloading}> <div style={{
<ListItemIcon> display: 'flex',
<DownloadIcon /> alignItems: 'center',
</ListItemIcon> flexWrap: 'wrap',
<ListItemText primary="Archive" /> }}>
</ListItemButton> <SettingsEthernet />
</Link> <span>
<Link to={'/settings'} style={ &nbsp;{status.connected ? settings.serverAddr : 'not connected'}
{ </span>
textDecoration: 'none', </div>
color: mode === 'dark' ? '#ffffff' : '#000000DE' </Toolbar>
} </AppBar>
}> <Drawer variant="permanent" open={open}>
<ListItemButton disabled={status.downloading}> <Toolbar
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</Link>
<ThemeToggler />
<Logout />
</List>
</Drawer>
<Box
component="main"
sx={{ sx={{
flexGrow: 1, display: 'flex',
height: '100vh', alignItems: 'center',
overflow: 'auto', justifyContent: 'flex-end',
px: [1],
}} }}
> >
<Toolbar /> <IconButton onClick={toggleDrawer}>
<Outlet /> <ChevronLeft />
</Box> </IconButton>
</Toolbar>
<Divider />
<List component="nav">
<Link to={'/'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<Dashboard />
</ListItemIcon>
<ListItemText primary="Home" />
</ListItemButton>
</Link>
<Link to={'/archive'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<DownloadIcon />
</ListItemIcon>
<ListItemText primary="Archive" />
</ListItemButton>
</Link>
<Link to={'/settings'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</Link>
<ThemeToggler />
<Logout />
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
}}
>
<Toolbar />
<Outlet />
</Box> </Box>
<Toaster /> </Box>
</RPCClientProvider> <Toaster />
</I18nProvider> </SocketSubscriber>
</ThemeProvider> </ThemeProvider>
) )
} }

View File

@@ -0,0 +1,9 @@
import { atom } from 'recoil'
export const downloadTemplateState = atom({
key: 'downloadTemplateState',
default: localStorage.getItem('lastDownloadTemplate') ?? '',
effects: [
({ onSet }) => onSet(e => localStorage.setItem('lastDownloadTemplate', e))
]
})

View File

@@ -0,0 +1,7 @@
import { atom } from 'recoil'
import { RPCResult } from '../types'
export const activeDownloadsState = atom<RPCResult[] | undefined>({
key: 'activeDownloadsState',
default: undefined
})

View File

@@ -0,0 +1,7 @@
import { atom } from 'recoil'
import { DLMetadata } from '../types'
export const selectedFormatState = atom<Partial<DLMetadata>>({
key: 'selectedFormatState',
default: {},
})

View File

@@ -0,0 +1,9 @@
import { selector } from 'recoil'
import I18nBuilder from '../lib/intl'
import { languageState } from './settings'
export const i18nBuilderState = selector({
key: 'i18nBuilderState',
get: ({ get }) => new I18nBuilder(get(languageState)),
dangerouslyAllowMutability: true,
})

12
frontend/src/atoms/rpc.ts Normal file
View File

@@ -0,0 +1,12 @@
import { selector } from 'recoil'
import { RPCClient } from '../lib/rpcClient'
import { rpcHTTPEndpoint, rpcWebSocketEndpoint } from './settings'
export const rpcClientState = selector({
key: 'rpcClientState',
get: ({ get }) =>
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
set: ({ get }) =>
new RPCClient(get(rpcHTTPEndpoint), get(rpcWebSocketEndpoint)),
dangerouslyAllowMutability: true,
})

View File

@@ -0,0 +1,175 @@
import { atom, selector } from 'recoil'
export type Language =
| 'english'
| 'chinese'
| 'russian'
| 'italian'
| 'spanish'
| 'korean'
| 'japanese'
| 'catalan'
| 'ukrainian'
| 'polish'
export const languages = [
'english',
'chinese',
'russian',
'italian',
'spanish',
'korean',
'japanese',
'catalan',
'ukrainian',
'polish',
] as const
export type Theme = 'light' | 'dark'
export interface SettingsState {
serverAddr: string
serverPort: number
language: Language
theme: Theme
cliArgs: string
formatSelection: boolean
fileRenaming: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
}
export const languageState = atom<Language>({
key: 'languageState',
default: localStorage.getItem('language') as Language ?? 'english',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('language', l.toString()))
]
})
export const themeState = atom<Theme>({
key: 'themeStateState',
default: localStorage.getItem('theme') as Theme ?? 'system',
effects: [
({ onSet }) =>
onSet(l => localStorage.setItem('theme', l.toString()))
]
})
export const serverAddressState = atom<string>({
key: 'serverAddressState',
default: localStorage.getItem('server-addr') ?? window.location.hostname,
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-addr', a.toString()))
]
})
export const serverPortState = atom<number>({
key: 'serverPortState',
default: Number(localStorage.getItem('server-port')) ??
Number(window.location.port),
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('server-port', a.toString()))
]
})
export const latestCliArgumentsState = atom<string>({
key: 'latestCliArgumentsState',
default: localStorage.getItem('cli-args') ?? '',
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('cli-args', a.toString()))
]
})
export const formatSelectionState = atom({
key: 'formatSelectionState',
default: localStorage.getItem('format-selection') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('format-selection', a.toString()))
]
})
export const fileRenamingState = atom({
key: 'fileRenamingState',
default: localStorage.getItem('file-renaming') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('file-renaming', a.toString()))
]
})
export const pathOverridingState = atom({
key: 'pathOverridingState',
default: localStorage.getItem('path-overriding') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('path-overriding', a.toString()))
]
})
export const enableCustomArgsState = atom({
key: 'enableCustomArgsState',
default: localStorage.getItem('enable-custom-args') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('enable-custom-args', a.toString()))
]
})
export const listViewState = atom({
key: 'listViewState',
default: localStorage.getItem('listview') === "true",
effects: [
({ onSet }) =>
onSet(a => localStorage.setItem('listview', a.toString()))
]
})
export const serverAddressAndPortState = selector({
key: 'serverAddressAndPortState',
get: ({ get }) => `${get(serverAddressState)}:${get(serverPortState)}`
})
export const serverURL = selector({
key: 'serverURL',
get: ({ get }) =>
`${window.location.protocol}//${get(serverAddressState)}:${get(serverPortState)}`
})
export const rpcWebSocketEndpoint = selector({
key: 'rpcWebSocketEndpoint',
get: ({ get }) => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${get(serverAddressAndPortState)}/rpc/ws`
}
})
export const rpcHTTPEndpoint = selector({
key: 'rpcHTTPEndpoint',
get: ({ get }) => {
const proto = window.location.protocol
return `${proto}//${get(serverAddressAndPortState)}/rpc/http`
}
})
export const settingsState = selector<SettingsState>({
key: 'settingsState',
get: ({ get }) => ({
serverAddr: get(serverAddressState),
serverPort: get(serverPortState),
language: get(languageState),
theme: get(themeState),
cliArgs: get(latestCliArgumentsState),
formatSelection: get(formatSelectionState),
fileRenaming: get(fileRenamingState),
pathOverriding: get(pathOverridingState),
enableCustomArgs: get(enableCustomArgsState),
listView: get(listViewState),
})
})

View File

@@ -0,0 +1,39 @@
import { atom, selector } from 'recoil'
type StatusState = {
connected: boolean,
updated: boolean,
downloading: boolean,
freeSpace: number,
}
export const connectedState = atom({
key: 'connectedState',
default: false
})
export const updatedBinaryState = atom({
key: 'updatedBinaryState',
default: false
})
export const isDownloadingState = atom({
key: 'isDownloadingState',
default: false
})
export const freeSpaceBytesState = atom({
key: 'freeSpaceBytesState',
default: 0
})
export const statusState = selector<StatusState>({
key: 'statusState',
get: ({ get }) => ({
connected: get(connectedState),
updated: get(updatedBinaryState),
downloading: get(isDownloadingState),
freeSpace: get(freeSpaceBytesState),
})
})

View File

@@ -0,0 +1,15 @@
import { AlertColor } from '@mui/material'
import { atom } from 'recoil'
type Toast = {
open: boolean,
message: string
autoClose: boolean
createdAt: number,
severity?: AlertColor
}
export const toastListState = atom<Toast[]>({
key: 'toastListState',
default: [],
})

View File

@@ -1,37 +0,0 @@
import {
Card,
CardActionArea,
CardContent,
CardMedia,
Skeleton,
Typography
} from '@mui/material'
import { ellipsis } from '../utils'
type Props = {
title: string,
thumbnail: string,
url: string,
}
export function ArchiveResult({ title, thumbnail, url }: Props) {
return (
<Card>
<CardActionArea onClick={() => window.open(url)}>
{thumbnail ?
<CardMedia
component="img"
height={180}
image={thumbnail}
/> :
<Skeleton variant="rectangular" height={180} />
}
<CardContent>
<Typography gutterBottom variant="body2" component="div">
{ellipsis(title, 72)}
</Typography>
</CardContent>
</CardActionArea>
</Card>
)
}

View File

@@ -26,19 +26,19 @@ import { TransitionProps } from '@mui/material/transitions'
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
import { import {
forwardRef, forwardRef,
useContext,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useTransition useTransition
} from 'react' } from 'react'
import { useSelector } from 'react-redux' import { useRecoilState, useRecoilValue } from 'recoil'
import { settingsState } from '../atoms/settings'
import { connectedState } from '../atoms/status'
import FormatsGrid from '../components/FormatsGrid' import FormatsGrid from '../components/FormatsGrid'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import { I18nContext } from '../providers/i18nProvider'
import { RPCClientContext } from '../providers/rpcClientProvider'
import { RootState } from '../stores/store'
import type { DLMetadata } from '../types' import type { DLMetadata } from '../types'
import { isValidURL, toFormatArgs } from '../utils' import { isValidURL, toFormatArgs } from '../utils'
@@ -62,9 +62,9 @@ export default function DownloadDialog({
onClose, onClose,
onDownloadStart onDownloadStart
}: Props) { }: Props) {
// redux state // recoil state
const settings = useSelector((state: RootState) => state.settings) const settings = useRecoilValue(settingsState)
const status = useSelector((state: RootState) => state.status) const [isConnected] = useRecoilState(connectedState)
// ephemeral state // ephemeral state
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>() const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
@@ -85,11 +85,12 @@ export default function DownloadDialog({
// memos // memos
const cliArgs = useMemo(() => const cliArgs = useMemo(() =>
new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]) new CliArguments().fromString(settings.cliArgs), [settings.cliArgs]
)
// context // context
const { i18n } = useContext(I18nContext) const { i18n } = useI18n()
const { client } = useContext(RPCClientContext) const { client } = useRPC()
// refs // refs
const urlInputRef = useRef<HTMLInputElement>(null) const urlInputRef = useRef<HTMLInputElement>(null)
@@ -254,7 +255,7 @@ export default function DownloadDialog({
variant="outlined" variant="outlined"
onChange={handleUrlChange} onChange={handleUrlChange}
disabled={ disabled={
!status.connected !isConnected
|| (settings.formatSelection && downloadFormats != null) || (settings.formatSelection && downloadFormats != null)
} }
InputProps={{ InputProps={{
@@ -290,7 +291,10 @@ export default function DownloadDialog({
variant="outlined" variant="outlined"
onChange={handleCustomArgsChange} onChange={handleCustomArgsChange}
value={customArgs} value={customArgs}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
}
/> />
</Grid> </Grid>
} }
@@ -304,7 +308,10 @@ export default function DownloadDialog({
variant="outlined" variant="outlined"
value={fileNameOverride} value={fileNameOverride}
onChange={handleFilenameOverrideChange} onChange={handleFilenameOverrideChange}
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)} disabled={
!isConnected ||
(settings.formatSelection && downloadFormats != null)
}
/> />
</Grid> </Grid>
} }
@@ -338,7 +345,11 @@ export default function DownloadDialog({
: sendUrl() : sendUrl()
} }
> >
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')} {
settings.formatSelection
? i18n.t('selectFormatButton')
: i18n.t('startButton')
}
</Button> </Button>
</Grid> </Grid>
<Grid item> <Grid item>

View File

@@ -0,0 +1,72 @@
import { useRecoilState, useRecoilValue } from 'recoil'
import { activeDownloadsState } from '../atoms/downloads'
import { listViewState } from '../atoms/settings'
import { useRPC } from '../hooks/useRPC'
import { DownloadsCardView } from './DownloadsCardView'
import { DownloadsListView } from './DownloadsListView'
import { useEffect } from 'react'
import { connectedState, isDownloadingState } from '../atoms/status'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
import { RPCResponse, RPCResult } from '../types'
const Downloads: React.FC = () => {
const [active, setActive] = useRecoilState(activeDownloadsState)
const isConnected = useRecoilValue(connectedState)
const listView = useRecoilValue(listViewState)
const { client, socket$ } = useRPC()
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
useEffect(() => {
if (!isConnected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
if (!isRPCResponse(event)) { return }
if (!Array.isArray(event.result)) { return }
setActive(
(event.result || [])
.filter(f => !!f.info.url)
.sort((a, b) => datetimeCompareFunc(
b.info.created_at,
a.info.created_at,
))
)
})
return () => sub.unsubscribe()
}, [socket$, isConnected])
const [, setIsDownloading] = useRecoilState(isDownloadingState)
useEffect(() => {
if (active) {
setIsDownloading(true)
}
}, [active?.length])
if (listView) {
return (
<DownloadsListView
downloads={active ?? []}
onStop={abort}
/>
)
}
return (
<DownloadsCardView
downloads={active ?? []}
onStop={abort}
/>
)
}
export default Downloads

View File

@@ -1,7 +1,7 @@
import { Grid } from "@mui/material" import { Grid } from "@mui/material"
import { Fragment, useContext } from "react" import { Fragment } from "react"
import { useToast } from "../hooks/toast" import { useToast } from "../hooks/toast"
import { I18nContext } from "../providers/i18nProvider" import { useI18n } from '../hooks/useI18n'
import type { RPCResult } from "../types" import type { RPCResult } from "../types"
import { StackableResult } from "./StackableResult" import { StackableResult } from "./StackableResult"
@@ -11,7 +11,7 @@ type Props = {
} }
export function DownloadsCardView({ downloads, onStop }: Props) { export function DownloadsCardView({ downloads, onStop }: Props) {
const { i18n } = useContext(I18nContext) const { i18n } = useI18n()
const { pushMessage } = useToast() const { pushMessage } = useToast()
return ( return (

View File

@@ -0,0 +1,51 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import {
SpeedDial,
SpeedDialAction,
SpeedDialIcon
} from '@mui/material'
import { useRecoilState } from 'recoil'
import { listViewState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
type Props = {
onOpen: () => void
}
const HomeSpeedDial: React.FC<Props> = ({ onOpen }) => {
const [, setListView] = useRecoilState(listViewState)
const { i18n } = useI18n()
const { client } = useRPC()
const abort = () => client.killAll()
return (
<SpeedDial
ariaLabel="Home speed dial"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
onClick={() => setListView(state => !state)}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={abort}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={`New download`}
onClick={onOpen}
/>
</SpeedDial>
)
}
export default HomeSpeedDial

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import { connectedState } from '../atoms/status'
import { useRPC } from '../hooks/useRPC'
import { useToast } from '../hooks/toast'
import { serverAddressAndPortState } from '../atoms/settings'
import { useI18n } from '../hooks/useI18n'
interface Props extends React.HTMLAttributes<HTMLBaseElement> { }
const SocketSubscriber: React.FC<Props> = ({ children }) => {
const [isConnected, setIsConnected] = useRecoilState(connectedState)
const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const { i18n } = useI18n()
const { socket$ } = useRPC()
const { pushMessage } = useToast()
useEffect(() => {
if (isConnected) { return }
const sub = socket$.subscribe({
next: () => {
setIsConnected(true)
pushMessage(
`Connected to (${serverAddressAndPort})`,
"success"
)
},
error: (e) => {
console.error(e)
pushMessage(
`${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error"
)
}
})
return () => sub.unsubscribe()
}, [isConnected])
return (
<>{children}</>
)
}
export default SocketSubscriber

View File

@@ -1,7 +1,8 @@
import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import { Container, SvgIcon, Typography, styled } from '@mui/material' import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { useContext } from 'react' import { useRecoilValue } from 'recoil'
import { I18nContext } from '../providers/i18nProvider' import { activeDownloadsState } from '../atoms/downloads'
import { useI18n } from '../hooks/useI18n'
const FlexContainer = styled(Container)({ const FlexContainer = styled(Container)({
display: 'flex', display: 'flex',
@@ -21,7 +22,12 @@ const Title = styled(Typography)({
}) })
export default function Splash() { export default function Splash() {
const { i18n } = useContext(I18nContext) const { i18n } = useI18n()
const activeDownloads = useRecoilValue(activeDownloadsState)
if (!activeDownloads || activeDownloads.length !== 0) {
return null
}
return ( return (
<FlexContainer> <FlexContainer>

View File

@@ -1,22 +1,20 @@
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useDispatch, useSelector } from 'react-redux'
import { setTheme } from '../features/settings/settingsSlice'
import { RootState } from '../stores/store'
import { Brightness4, Brightness5 } from '@mui/icons-material' import { Brightness4, Brightness5 } from '@mui/icons-material'
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
import { useRecoilState } from 'recoil'
import { themeState } from '../atoms/settings'
export default function ThemeToggler() { export default function ThemeToggler() {
const settings = useSelector((state: RootState) => state.settings) const [theme, setTheme] = useRecoilState(themeState)
const dispatch = useDispatch()
return ( return (
<ListItemButton onClick={() => { <ListItemButton onClick={() => {
settings.theme === 'light' theme === 'light'
? dispatch(setTheme('dark')) ? setTheme('dark')
: dispatch(setTheme('light')) : setTheme('light')
}}> }}>
<ListItemIcon> <ListItemIcon>
{ {
settings.theme === 'light' theme === 'light'
? <Brightness4 /> ? <Brightness4 />
: <Brightness5 /> : <Brightness5 />
} }

View File

@@ -1,7 +0,0 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface FormatSelectionState {
bestFormat: string
audioFormat: string
videoFormat: string
}

View File

@@ -1,110 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
export type LanguageUnion =
| "english"
| "chinese"
| "russian"
| "italian"
| "spanish"
| "korean"
| "japanese"
| "catalan"
| "ukrainian"
| "polish"
export type ThemeUnion = "light" | "dark"
export interface SettingsState {
serverAddr: string
serverPort: string
language: LanguageUnion
theme: ThemeUnion
cliArgs: string
formatSelection: boolean
ratelimit: string
fileRenaming: boolean
pathOverriding: boolean
enableCustomArgs: boolean
listView: boolean
}
const initialState: SettingsState = {
serverAddr: localStorage.getItem("server-addr") || window.location.hostname,
serverPort: localStorage.getItem("server-port") || window.location.port,
language: (localStorage.getItem("language") || "english") as LanguageUnion,
theme: (localStorage.getItem("theme") || "light") as ThemeUnion,
cliArgs: localStorage.getItem("cli-args") ?? "",
formatSelection: localStorage.getItem("format-selection") === "true",
ratelimit: localStorage.getItem("rate-limit") ?? "",
fileRenaming: localStorage.getItem("file-renaming") === "true",
pathOverriding: localStorage.getItem("path-overriding") === "true",
enableCustomArgs: localStorage.getItem("enable-custom-args") === "true",
listView: localStorage.getItem("listview") === "true",
}
export const settingsSlice = createSlice({
name: "settings",
initialState,
reducers: {
setServerAddr: (state, action: PayloadAction<string>) => {
state.serverAddr = action.payload
localStorage.setItem("server-addr", action.payload)
},
setServerPort: (state, action: PayloadAction<string>) => {
state.serverPort = action.payload
localStorage.setItem("server-port", action.payload)
},
setLanguage: (state, action: PayloadAction<LanguageUnion>) => {
state.language = action.payload
localStorage.setItem("language", action.payload)
},
setCliArgs: (state, action: PayloadAction<string>) => {
state.cliArgs = action.payload
localStorage.setItem("cli-args", action.payload)
},
setTheme: (state, action: PayloadAction<ThemeUnion>) => {
state.theme = action.payload
localStorage.setItem("theme", action.payload)
},
setFormatSelection: (state, action: PayloadAction<boolean>) => {
state.formatSelection = action.payload
localStorage.setItem("format-selection", action.payload.toString())
},
setRateLimit: (state, action: PayloadAction<string>) => {
state.ratelimit = action.payload
localStorage.setItem("rate-limit", action.payload)
},
setPathOverriding: (state, action: PayloadAction<boolean>) => {
state.pathOverriding = action.payload
localStorage.setItem("path-overriding", action.payload.toString())
},
setFileRenaming: (state, action: PayloadAction<boolean>) => {
state.fileRenaming = action.payload
localStorage.setItem("file-renaming", action.payload.toString())
},
setEnableCustomArgs: (state, action: PayloadAction<boolean>) => {
state.enableCustomArgs = action.payload
localStorage.setItem("enable-custom-args", action.payload.toString())
},
toggleListView: (state) => {
state.listView = !state.listView
localStorage.setItem("listview", state.listView.toString())
},
}
})
export const {
setLanguage,
setCliArgs,
setTheme,
setServerAddr,
setServerPort,
setFormatSelection,
setRateLimit,
setFileRenaming,
setPathOverriding,
setEnableCustomArgs,
toggleListView
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@@ -1,55 +0,0 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
export interface StatusState {
connected: boolean,
updated: boolean,
downloading: boolean,
freeSpace: number,
}
const initialState: StatusState = {
connected: false,
updated: false,
downloading: false,
freeSpace: 0,
}
export const statusSlice = createSlice({
name: 'status',
initialState,
reducers: {
connected: (state) => {
state.connected = true
},
disconnected: (state) => {
state.connected = false
},
updated: (state) => {
state.updated = true
},
alreadyUpdated: (state) => {
state.updated = false
},
downloading: (state) => {
state.downloading = true
},
finished: (state) => {
state.downloading = false
},
setFreeSpace: (state, action: PayloadAction<number>) => {
state.freeSpace = action.payload
}
}
})
export const {
connected,
disconnected,
updated,
alreadyUpdated,
downloading,
finished,
setFreeSpace
} = statusSlice.actions
export default statusSlice.reducer

View File

@@ -1,46 +0,0 @@
import { AlertColor } from '@mui/material'
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface ToastState {
message: string
open: boolean
autoClose: boolean
severity?: AlertColor
}
type MessageAction = {
message: string,
severity?: AlertColor
}
const initialState: ToastState = {
message: '',
open: false,
autoClose: true,
}
export const toastSlice = createSlice({
name: 'toast',
initialState,
reducers: {
setMessage: (state, action: PayloadAction<MessageAction>) => {
state.message = action.payload.message
state.severity = action.payload.severity
state.open = true
},
setOpen: (state) => {
state.open = true
},
setClose: (state) => {
state.open = false
},
}
})
export const {
setMessage,
setClose,
setOpen,
} = toastSlice.actions
export default toastSlice.reducer

View File

@@ -1,16 +1,19 @@
import { useDispatch } from "react-redux" import { AlertColor } from '@mui/material'
import { setMessage } from "../features/ui/toastSlice" import { useRecoilState } from 'recoil'
import { AlertColor } from "@mui/material" import { toastListState } from '../atoms/toast'
export const useToast = () => { export const useToast = () => {
const dispatch = useDispatch() const [toasts, setToasts] = useRecoilState(toastListState)
return { return {
pushMessage: (message: string, severity?: AlertColor) => { pushMessage: (message: string, severity?: AlertColor) => {
dispatch(setMessage({ setToasts([{
open: true,
message: message, message: message,
severity: severity severity: severity,
})) autoClose: true,
createdAt: Date.now()
}, ...toasts])
} }
} }
} }

View File

@@ -0,0 +1,10 @@
import { useRecoilValue } from 'recoil'
import { i18nBuilderState } from '../atoms/i18n'
export const useI18n = () => {
const instance = useRecoilValue(i18nBuilderState)
return {
i18n: instance
}
}

View File

@@ -0,0 +1,11 @@
import { useRecoilValue } from 'recoil'
import { rpcClientState } from '../atoms/rpc'
export const useRPC = () => {
const client = useRecoilValue(rpcClientState)
return {
client,
socket$: client.socket$
}
}

View File

@@ -2,27 +2,27 @@
import i18n from "../assets/i18n.yaml" import i18n from "../assets/i18n.yaml"
export default class I18nBuilder { export default class I18nBuilder {
private language: string private language: string
private textMap = i18n.languages private textMap = i18n.languages
constructor(language: string) { constructor(language: string) {
this.language = language this.language = language
} }
getLanguage(): string { getLanguage(): string {
return this.language return this.language
} }
setLanguage(language: string): void { setLanguage(language: string): void {
this.language = language this.language = language
} }
t(key: string): string { t(key: string): string {
const map = this.textMap[this.language] const map = this.textMap[this.language]
if (map) { if (map) {
const translation = map[key] const translation = map[key]
return translation ?? 'caption not defined' return translation ?? 'caption not defined'
}
return 'caption not defined'
} }
return 'caption not defined'
}
} }

View File

@@ -1,15 +1,20 @@
import type { DLMetadata, RPCRequest, RPCResponse } from '../types' import type { DLMetadata, RPCRequest, RPCResponse } from '../types'
import { webSocket } from 'rxjs/webSocket' import { WebSocketSubject, webSocket } from 'rxjs/webSocket'
import { getHttpRPCEndpoint, getWebSocketEndpoint } from '../utils'
export const socket$ = webSocket<any>(getWebSocketEndpoint())
export class RPCClient { export class RPCClient {
private seq: number private seq: number
private httpEndpoint: string
private _socket$: WebSocketSubject<any>
constructor() { constructor(httpEndpoint: string, webSocketEndpoint: string) {
this.seq = 0 this.seq = 0
this.httpEndpoint = httpEndpoint
this._socket$ = webSocket<any>(webSocketEndpoint)
}
public get socket$() {
return this._socket$
} }
private incrementSeq() { private incrementSeq() {
@@ -17,14 +22,14 @@ export class RPCClient {
} }
private send(req: RPCRequest) { private send(req: RPCRequest) {
socket$.next({ this._socket$.next({
...req, ...req,
id: this.incrementSeq(), id: this.incrementSeq(),
}) })
} }
private async sendHTTP<T>(req: RPCRequest) { private async sendHTTP<T>(req: RPCRequest) {
const res = await fetch(getHttpRPCEndpoint(), { const res = await fetch(this.httpEndpoint, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
...req, ...req,

View File

@@ -1,22 +1,34 @@
import { Alert, Snackbar } from "@mui/material" import { Alert, Snackbar } from "@mui/material"
import { useDispatch, useSelector } from "react-redux" import { useRecoilState } from 'recoil'
import { setClose } from "../features/ui/toastSlice" import { toastListState } from '../atoms/toast'
import { RootState } from "../stores/store" import { useEffect } from 'react'
const Toaster: React.FC = () => { const Toaster: React.FC = () => {
const toast = useSelector((state: RootState) => state.toast) const [toasts, setToasts] = useRecoilState(toastListState)
const dispatch = useDispatch()
useEffect(() => {
if (toasts.length > 0) {
const interval = setInterval(() => {
setToasts(t => t.filter((x) => (Date.now() - x.createdAt) < 1500))
}, 1500)
return () => clearInterval(interval)
}
}, [setToasts, toasts])
return ( return (
<Snackbar <>
open={toast.open} {toasts.map((toast, index) => (
autoHideDuration={toast.severity === 'error' ? 10000 : 1500} <Snackbar
onClose={() => dispatch(setClose())} key={index}
> open={toast.open}
<Alert variant="filled" severity={toast.severity}> >
{toast.message} <Alert variant="filled" severity={toast.severity}>
</Alert> {toast.message}
</Snackbar> </Alert>
</Snackbar>
))}
</>
) )
} }

View File

@@ -1,30 +0,0 @@
import { createContext, useMemo } from 'react'
import { useSelector } from 'react-redux'
import I18nBuilder from '../lib/intl'
import { RootState, store } from '../stores/store'
type Props = {
children: React.ReactNode
}
interface Context {
i18n: I18nBuilder
}
export const I18nContext = createContext<Context>({
i18n: new I18nBuilder(store.getState().settings.language)
})
export default function I18nProvider({ children }: Props) {
const settings = useSelector((state: RootState) => state.settings)
const i18n = useMemo(() => new I18nBuilder(
settings.language
), [settings.language])
return (
<I18nContext.Provider value={{ i18n }}>
{children}
</I18nContext.Provider>
)
}

View File

@@ -1,31 +0,0 @@
import { createContext, useMemo } from 'react'
import { useSelector } from 'react-redux'
import { RPCClient } from '../lib/rpcClient'
import type { RootState } from '../stores/store'
type Props = {
children: React.ReactNode
}
interface Context {
client: RPCClient
}
export const RPCClientContext = createContext<Context>({
client: new RPCClient()
})
export default function RPCClientProvider({ children }: Props) {
const settings = useSelector((state: RootState) => state.settings)
const client = useMemo(() => new RPCClient(), [
settings.serverAddr,
settings.serverPort,
])
return (
<RPCClientContext.Provider value={{ client }}>
{children}
</RPCClientContext.Provider>
)
}

View File

@@ -1,17 +0,0 @@
import { configureStore } from '@reduxjs/toolkit'
import settingsReducer from '../features/settings/settingsSlice'
import statussReducer from '../features/status/statusSlice'
import toastReducer from '../features/ui/toastSlice'
export const store = configureStore({
reducer: {
settings: settingsReducer,
status: statussReducer,
toast: toastReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch

View File

@@ -27,28 +27,25 @@ import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile' import VideoFileIcon from '@mui/icons-material/VideoFile'
import { Buffer } from 'buffer' import { Buffer } from 'buffer'
import { useContext, useEffect, useMemo, useState, useTransition } from 'react' import { useEffect, useMemo, useState, useTransition } from 'react'
import { useSelector } from 'react-redux' import { useNavigate } from 'react-router-dom'
import { useRecoilValue } from 'recoil'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs' import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { serverURL } from '../atoms/settings'
import { useObservable } from '../hooks/observable' import { useObservable } from '../hooks/observable'
import { RootState } from '../stores/store' import { useI18n } from '../hooks/useI18n'
import { ffetch } from '../lib/httpClient'
import { DeleteRequest, DirectoryEntry } from '../types' import { DeleteRequest, DirectoryEntry } from '../types'
import { roundMiB } from '../utils' import { roundMiB } from '../utils'
import { useNavigate } from 'react-router-dom'
import { ffetch } from '../lib/httpClient'
import { I18nContext } from '../providers/i18nProvider'
export default function Downloaded() { export default function Downloaded() {
const settings = useSelector((state: RootState) => state.settings) const serverAddr = useRecoilValue(serverURL)
const navigate = useNavigate() const navigate = useNavigate()
const { i18n } = useContext(I18nContext) const { i18n } = useI18n()
const [openDialog, setOpenDialog] = useState(false) const [openDialog, setOpenDialog] = useState(false)
const serverAddr =
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), []) const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), []) const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
@@ -138,8 +135,7 @@ export default function Downloaded() {
useEffect(() => { useEffect(() => {
fetcher() fetcher()
}, [settings.serverAddr, settings.serverPort]) }, [serverAddr])
const onFileClick = (path: string) => startTransition(() => { const onFileClick = (path: string) => startTransition(() => {
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`) window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)

View File

@@ -1,168 +1,77 @@
import AddCircleIcon from '@mui/icons-material/AddCircle'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import { import {
Backdrop, Backdrop,
CircularProgress, CircularProgress,
Container, Container
SpeedDial,
SpeedDialAction,
SpeedDialIcon
} from '@mui/material' } from '@mui/material'
import { useContext, useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useRecoilState, useRecoilValue } from 'recoil'
import { serverAddressAndPortState } from '../atoms/settings'
import { connectedState, freeSpaceBytesState, isDownloadingState } from '../atoms/status'
import DownloadDialog from '../components/DownloadDialog' import DownloadDialog from '../components/DownloadDialog'
import { DownloadsCardView } from '../components/DownloadsCardView' import Downloads from '../components/Downloads'
import { DownloadsListView } from '../components/DownloadsListView' import HomeSpeedDial from '../components/HomeSpeedDial'
import Splash from '../components/Splash' import Splash from '../components/Splash'
import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { socket$ } from '../lib/rpcClient' import { useI18n } from '../hooks/useI18n'
import { I18nContext } from '../providers/i18nProvider' import { useRPC } from '../hooks/useRPC'
import { RPCClientContext } from '../providers/rpcClientProvider'
import { RootState } from '../stores/store'
import type { RPCResponse, RPCResult } from '../types'
import { datetimeCompareFunc, isRPCResponse } from '../utils'
export default function Home() { export default function Home() {
const settings = useSelector((state: RootState) => state.settings) const isDownloading = useRecoilValue(isDownloadingState)
const status = useSelector((state: RootState) => state.status) const serverAddressAndPort = useRecoilValue(serverAddressAndPortState)
const dispatch = useDispatch()
const [, setFreeSpace] = useRecoilState(freeSpaceBytesState)
const [isConnected, setIsDownloading] = useRecoilState(connectedState)
const [activeDownloads, setActiveDownloads] = useState<RPCResult[]>()
const [showBackdrop, setShowBackdrop] = useState(true)
const [openDialog, setOpenDialog] = useState(false) const [openDialog, setOpenDialog] = useState(false)
const { i18n } = useContext(I18nContext) const { i18n } = useI18n()
const { client } = useContext(RPCClientContext) const { client } = useRPC()
const { pushMessage } = useToast() const { pushMessage } = useToast()
/* -------------------- Effects -------------------- */
/* WebSocket connect event handler*/
useEffect(() => { useEffect(() => {
if (status.connected) { return } if (isConnected) {
const sub = socket$.subscribe({
next: () => {
dispatch(connected())
},
error: () => {
pushMessage(
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`,
"error"
)
setShowBackdrop(false)
}
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (status.connected) {
client.running() client.running()
const interval = setInterval(() => client.running(), 1000) const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval) return () => clearInterval(interval)
} }
}, [status.connected]) }, [isConnected])
useEffect(() => { useEffect(() => {
client client
.freeSpace() .freeSpace()
.then(bytes => dispatch(setFreeSpace(bytes.result))) .then(bytes => setFreeSpace(bytes.result))
.catch(() => { .catch(() => {
pushMessage( pushMessage(
`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`, `${i18n.t('rpcConnErr')} (${serverAddressAndPort})`,
"error" "error"
) )
setShowBackdrop(false) setIsDownloading(false)
}) })
}, []) }, [])
useEffect(() => {
if (!status.connected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
if (!isRPCResponse(event)) { return }
setActiveDownloads((event.result ?? [])
.filter(f => !!f.info.url)
.sort((a, b) => datetimeCompareFunc(
b.info.created_at,
a.info.created_at,
)))
})
pushMessage(
`Connected to (${settings.serverAddr}:${settings.serverPort})`,
"success"
)
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (activeDownloads && activeDownloads.length >= 0) {
setShowBackdrop(false)
}
}, [activeDownloads?.length])
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
return ( return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}> <Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop <Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }} sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop} open={!isDownloading}
> >
<CircularProgress color="primary" /> <CircularProgress color="primary" />
</Backdrop> </Backdrop>
{activeDownloads?.length === 0 && <Splash />
<Splash /> <Downloads />
} <HomeSpeedDial
{ onOpen={() => setOpenDialog(true)}
settings.listView ? />
<DownloadsListView downloads={activeDownloads ?? []} onStop={abort} /> :
<DownloadsCardView downloads={activeDownloads ?? []} onStop={abort} />
}
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
onClick={() => dispatch(toggleListView())}
/>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={i18n.t('abortAllButton')}
onClick={() => abort()}
/>
<SpeedDialAction
icon={<AddCircleIcon />}
tooltipTitle={`New download`}
onClick={() => setOpenDialog(true)}
/>
</SpeedDial>
<DownloadDialog <DownloadDialog
open={openDialog} open={openDialog}
onClose={() => { onClose={() => {
setOpenDialog(false) setOpenDialog(false)
setShowBackdrop(false) setIsDownloading(false)
}} }}
onDownloadStart={() => { onDownloadStart={() => {
setOpenDialog(false) setOpenDialog(false)
setShowBackdrop(true) setIsDownloading(false)
}} }}
/> />
</Container> </Container>

View File

@@ -11,9 +11,9 @@ import {
TextField, TextField,
Typography Typography
} from '@mui/material' } from '@mui/material'
import { getHttpEndpoint } from '../utils'
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { getHttpEndpoint } from '../utils'
const LoginContainer = styled(Container)({ const LoginContainer = styled(Container)({
display: 'flex', display: 'flex',

View File

@@ -14,10 +14,11 @@ import {
Stack, Stack,
Switch, Switch,
TextField, TextField,
Typography Typography,
capitalize
} from '@mui/material' } from '@mui/material'
import { useContext, useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useRecoilState } from 'recoil'
import { import {
Subject, Subject,
debounceTime, debounceTime,
@@ -26,38 +27,45 @@ import {
takeWhile takeWhile
} from 'rxjs' } from 'rxjs'
import { import {
LanguageUnion, Language,
ThemeUnion, Theme,
setCliArgs, enableCustomArgsState,
setEnableCustomArgs, fileRenamingState,
setFileRenaming, formatSelectionState,
setFormatSelection, languageState,
setLanguage, languages,
setPathOverriding, latestCliArgumentsState,
setServerAddr, pathOverridingState,
setServerPort, serverAddressState,
setTheme serverPortState,
} from '../features/settings/settingsSlice' themeState
} from '../atoms/settings'
import { useToast } from '../hooks/toast' import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { CliArguments } from '../lib/argsParser' import { CliArguments } from '../lib/argsParser'
import { I18nContext } from '../providers/i18nProvider'
import { RPCClientContext } from '../providers/rpcClientProvider'
import { RootState } from '../stores/store'
import { validateDomain, validateIP } from '../utils' import { validateDomain, validateIP } from '../utils'
// NEED ABSOLUTELY TO BE SPLIT IN MULTIPLE COMPONENTS
export default function Settings() { export default function Settings() {
const dispatch = useDispatch() const [formatSelection, setFormatSelection] = useRecoilState(formatSelectionState)
const [pathOverriding, setPathOverriding] = useRecoilState(pathOverridingState)
const [fileRenaming, setFileRenaming] = useRecoilState(fileRenamingState)
const [enableArgs, setEnableArgs] = useRecoilState(enableCustomArgsState)
const [serverAddr, setServerAddr] = useRecoilState(serverAddressState)
const [serverPort, setServerPort] = useRecoilState(serverPortState)
const [language, setLanguage] = useRecoilState(languageState)
const [cliArgs, setCliArgs] = useRecoilState(latestCliArgumentsState)
const [theme, setTheme] = useRecoilState(themeState)
const settings = useSelector((state: RootState) => state.settings) const [invalidIP, setInvalidIP] = useState(false)
const [invalidIP, setInvalidIP] = useState(false); const { i18n } = useI18n()
const { client } = useRPC()
const { i18n } = useContext(I18nContext)
const { client } = useContext(RPCClientContext)
const { pushMessage } = useToast() const { pushMessage } = useToast()
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), []) const argsBuilder = useMemo(() => new CliArguments().fromString(cliArgs), [])
const serverAddr$ = useMemo(() => new Subject<string>(), []) const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), []) const serverPort$ = useMemo(() => new Subject<string>(), [])
@@ -71,10 +79,10 @@ export default function Settings() {
.subscribe(addr => { .subscribe(addr => {
if (validateIP(addr)) { if (validateIP(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) setServerAddr(addr)
} else if (validateDomain(addr)) { } else if (validateDomain(addr)) {
setInvalidIP(false) setInvalidIP(false)
dispatch(setServerAddr(addr)) setServerAddr(addr)
} else { } else {
setInvalidIP(true) setInvalidIP(true)
} }
@@ -90,7 +98,7 @@ export default function Settings() {
takeWhile(val => isFinite(val) && val <= 65535), takeWhile(val => isFinite(val) && val <= 65535),
) )
.subscribe(port => { .subscribe(port => {
dispatch(setServerPort(port.toString())) setServerPort(port)
}) })
return () => sub.unsubscribe() return () => sub.unsubscribe()
}, []) }, [])
@@ -98,15 +106,15 @@ export default function Settings() {
/** /**
* Language toggler handler * Language toggler handler
*/ */
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => { const handleLanguageChange = (event: SelectChangeEvent<Language>) => {
dispatch(setLanguage(event.target.value as LanguageUnion)); setLanguage(event.target.value as Language)
} }
/** /**
* Theme toggler handler * Theme toggler handler
*/ */
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => { const handleThemeChange = (event: SelectChangeEvent<Theme>) => {
dispatch(setTheme(event.target.value as ThemeUnion)); setTheme(event.target.value as Theme)
} }
/** /**
@@ -137,7 +145,7 @@ export default function Settings() {
<TextField <TextField
fullWidth fullWidth
label={i18n.t('serverAddressTitle')} label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr} defaultValue={serverAddr}
error={invalidIP} error={invalidIP}
onChange={(e) => serverAddr$.next(e.currentTarget.value)} onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{ InputProps={{
@@ -150,9 +158,9 @@ export default function Settings() {
<TextField <TextField
fullWidth fullWidth
label={i18n.t('serverPortTitle')} label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort} defaultValue={serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)} onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535} error={isNaN(Number(serverPort)) || Number(serverPort) > 65535}
sx={{ mb: 2 }} sx={{ mb: 2 }}
/> />
</Grid> </Grid>
@@ -162,20 +170,15 @@ export default function Settings() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel> <InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select <Select
defaultValue={settings.language} defaultValue={language}
label={i18n.t('languageSelect')} label={i18n.t('languageSelect')}
onChange={handleLanguageChange} onChange={handleLanguageChange}
> >
<MenuItem value="english">English</MenuItem> {languages.map(l => (
<MenuItem value="spanish">Spanish</MenuItem> <MenuItem value={l} key={l}>
<MenuItem value="italian">Italian</MenuItem> {capitalize(l)}
<MenuItem value="chinese">Chinese</MenuItem> </MenuItem>
<MenuItem value="russian">Russian</MenuItem> ))}
<MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem>
<MenuItem value="catalan">Catalan</MenuItem>
<MenuItem value="ukrainian">Ukrainian</MenuItem>
<MenuItem value="polish">Polish</MenuItem>
</Select> </Select>
</FormControl> </FormControl>
</Grid> </Grid>
@@ -183,7 +186,7 @@ export default function Settings() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>{i18n.t('themeSelect')}</InputLabel> <InputLabel>{i18n.t('themeSelect')}</InputLabel>
<Select <Select
defaultValue={settings.theme} defaultValue={theme}
label={i18n.t('themeSelect')} label={i18n.t('themeSelect')}
onChange={handleThemeChange} onChange={handleThemeChange}
> >
@@ -196,8 +199,8 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={cliArgs.noMTime} defaultChecked={argsBuilder.noMTime}
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))} onChange={() => setCliArgs(argsBuilder.toggleNoMTime().toString())}
/> />
} }
label={i18n.t('noMTimeCheckbox')} label={i18n.t('noMTimeCheckbox')}
@@ -206,9 +209,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={cliArgs.extractAudio} defaultChecked={argsBuilder.extractAudio}
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))} onChange={() => setCliArgs(argsBuilder.toggleExtractAudio().toString())}
disabled={settings.formatSelection} disabled={formatSelection}
/> />
} }
label={i18n.t('extractAudioCheckbox')} label={i18n.t('extractAudioCheckbox')}
@@ -216,10 +219,10 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.formatSelection} defaultChecked={formatSelection}
onChange={() => { onChange={() => {
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString())) setCliArgs(argsBuilder.disableExtractAudio().toString())
dispatch(setFormatSelection(!settings.formatSelection)) setFormatSelection(!formatSelection)
}} }}
/> />
} }
@@ -233,9 +236,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.pathOverriding} defaultChecked={!!pathOverriding}
onChange={() => { onChange={() => {
dispatch(setPathOverriding(!settings.pathOverriding)) setPathOverriding(state => !state)
}} }}
/> />
} }
@@ -244,9 +247,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.fileRenaming} defaultChecked={fileRenaming}
onChange={() => { onChange={() => {
dispatch(setFileRenaming(!settings.fileRenaming)) setFileRenaming(state => !state)
}} }}
/> />
} }
@@ -255,9 +258,9 @@ export default function Settings() {
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
defaultChecked={settings.enableCustomArgs} defaultChecked={enableArgs}
onChange={() => { onChange={() => {
dispatch(setEnableCustomArgs(!settings.enableCustomArgs)) setEnableArgs(state => !state)
}} }}
/> />
} }
@@ -281,5 +284,5 @@ export default function Settings() {
</Grid> </Grid>
</Grid> </Grid>
</Container> </Container>
); )
} }