frontend performance, rpc/rest jwt authentication

This commit is contained in:
2023-06-22 11:31:24 +02:00
parent 78c1559e84
commit d6c0646756
24 changed files with 691 additions and 360 deletions

View File

@@ -0,0 +1,256 @@
import {
Backdrop,
Button,
Checkbox,
CircularProgress,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
Typography
} from '@mui/material'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import VideoFileIcon from '@mui/icons-material/VideoFile'
import { Buffer } from 'buffer'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useSelector } from 'react-redux'
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
import { useObservable } from '../hooks/observable'
import { RootState } from '../stores/store'
import { DeleteRequest, DirectoryEntry } from '../types'
import { roundMiB } from '../utils'
import { useNavigate } from 'react-router-dom'
import { ffetch } from '../lib/httpClient'
export default function Downloaded() {
const settings = useSelector((state: RootState) => state.settings)
const navigate = useNavigate()
const [openDialog, setOpenDialog] = useState(false)
const serverAddr =
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
const [isPending, startTransition] = useTransition()
const fetcher = () => ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
(d) => files$.next(d),
() => navigate('/login'),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
subdir: '',
})
}
)
const fetcherSubfolder = (sub: string) => {
const folders = sub.startsWith('/')
? sub.substring(1).split('/')
: sub.split('/')
const relpath = folders.length >= 2
? folders.slice(-(folders.length - 1)).join('/')
: folders.pop()
const _upperLevel = folders.slice(1, -1)
const upperLevel = _upperLevel.length === 2
? ['.', ..._upperLevel].join('/')
: _upperLevel.join('/')
fetch(`${serverAddr}/archive/downloaded`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subdir: relpath })
})
.then(res => res.json())
.then(data => {
files$.next(sub
? [{
name: '..',
isDirectory: true,
path: upperLevel,
}, ...data]
: data
)
})
}
const selectable$ = useMemo(() => files$.pipe(
combineLatestWith(selected$),
map(([data, selected]) => data.map(x => ({
...x,
selected: selected.includes(x.name)
}))),
share()
), [])
const selectable = useObservable(selectable$, [])
const addSelected = (name: string) => {
selected$.value.includes(name)
? selected$.next(selected$.value.filter(val => val !== name))
: selected$.next([...selected$.value, name])
}
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(entry => fetch(`${serverAddr}/archive/delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: entry.path,
shaSum: entry.shaSum,
} as DeleteRequest)
}))
).then(fetcher)
}
useEffect(() => {
fetcher()
}, [settings.serverAddr, settings.serverPort])
const onFileClick = (path: string) => startTransition(() => {
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
})
const onFolderClick = (path: string) => startTransition(() => {
fetcherSubfolder(path)
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={!(files$.observed) || isPending}
>
<CircularProgress color="primary" />
</Backdrop>
<Paper sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}>
<Typography py={1} variant="h5" color="primary">
{'Archive'}
</Typography>
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
{selectable.length === 0 && 'No files found'}
{selectable.map((file, idx) => (
<ListItem
key={idx}
secondaryAction={
<div>
{!file.isDirectory && <Typography
variant="caption"
component="span"
>
{roundMiB(file.size)}
</Typography>
}
{!file.isDirectory && <Checkbox
edge="end"
checked={file.selected}
onChange={() => addSelected(file.name)}
/>}
</div>
}
disablePadding
>
<ListItemButton onClick={
() => file.isDirectory
? onFolderClick(file.path)
: onFileClick(file.path)
}>
<ListItemIcon>
{file.isDirectory
? <FolderIcon />
: file.isVideo
? <VideoFileIcon />
: <InsertDriveFileIcon />
}
</ListItemIcon>
<ListItemText
primary={file.name}
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
/>
</ListItemButton>
</ListItem>
))}
</List>
</Paper>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<DeleteForeverIcon />}
tooltipTitle={`Delete selected`}
tooltipOpen
onClick={() => {
if (selected$.value.length > 0) {
setOpenDialog(true)
}
}}
/>
</SpeedDial>
<Dialog
open={openDialog}
onClose={() => setOpenDialog(false)}
>
<DialogTitle>
Are you sure?
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
You're deleting:
</DialogContentText>
<ul>
{selected$.value.map((entry, idx) => (
<li key={idx}>{entry}</li>
))}
</ul>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={() => {
deleteSelected()
setOpenDialog(false)
}} autoFocus
>
Ok
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

427
frontend/src/views/Home.tsx Normal file
View File

@@ -0,0 +1,427 @@
import { FileUpload } from '@mui/icons-material'
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
import {
Alert,
Backdrop,
Button,
CircularProgress,
Container,
FormControl,
Grid,
IconButton,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
Snackbar,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
styled,
TextField
} from '@mui/material'
import { Buffer } from 'buffer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { DownloadsCardView } from '../components/DownloadsCardView'
import { DownloadsListView } from '../components/DownloadsListView'
import FormatsGrid from '../components/FormatsGrid'
import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient, socket$ } from '../lib/rpcClient'
import { toggleListView } from '../features/settings/settingsSlice'
import { connected, setFreeSpace } from '../features/status/statusSlice'
import { RootState } from '../stores/store'
import type { DLMetadata, RPCResponse, RPCResult } from '../types'
import { isValidURL, toFormatArgs } from '../utils'
export default function Home() {
// redux state
const settings = useSelector((state: RootState) => state.settings)
const status = useSelector((state: RootState) => state.status)
const dispatch = useDispatch()
// ephemeral state
const [activeDownloads, setActiveDownloads] = useState<Array<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 [showToast, setShowToast] = useState(true)
const [socketHasError, setSocketHasError] = useState(false)
// 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 -------------------- */
/* WebSocket connect event handler*/
useEffect(() => {
if (status.connected) { return }
const sub = socket$.subscribe({
next: () => {
dispatch(connected())
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
},
error: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
complete: () => {
setSocketHasError(true)
setShowBackdrop(false)
},
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (status.connected) {
client.running()
const interval = setInterval(() => client.running(), 1000)
return () => clearInterval(interval)
}
}, [status.connected])
useEffect(() => {
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
}, [])
useEffect(() => {
if (!status.connected) { return }
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
switch (typeof event.result) {
case 'object':
setActiveDownloads(
(event.result ?? [])
.filter((r) => !!r.info.url)
.sort((a, b) => a.info.title.localeCompare(b.info.title))
)
break
default:
break
}
})
return () => sub.unsubscribe()
}, [socket$, status.connected])
useEffect(() => {
if (activeDownloads && activeDownloads.length >= 0) {
setShowBackdrop(false)
}
}, [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.
* @param id The download id / pid
* @returns void
*/
const abort = (id?: string) => {
if (id) {
client.kill(id)
return
}
client.killAll()
}
const parseUrlListFile = (event: any) => {
const urlList = event.target.files
const reader = new FileReader()
reader.addEventListener('load', $event => {
const base64 = $event.target?.result!.toString().split(',')[1]
Buffer.from(base64!, 'base64')
.toString()
.trimEnd()
.split('\n')
.filter(_url => isValidURL(_url))
.forEach(_url => sendUrl(_url))
})
reader.readAsDataURL(urlList[0])
}
const resetInput = () => {
urlInputRef.current!.value = '';
if (customFilenameInputRef.current) {
customFilenameInputRef.current!.value = ''
}
}
/* -------------------- styled components -------------------- */
const Input = styled('input')({
display: 'none',
})
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Backdrop
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
open={showBackdrop}
>
<CircularProgress color="primary" />
</Backdrop>
<Grid container spacing={2}>
<Grid item xs={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
}}
>
<Grid container>
<TextField
fullWidth
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 ?
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
}
<Snackbar
open={showToast === status.connected}
autoHideDuration={1500}
onClose={() => setShowToast(false)}
>
<Alert variant="filled" severity="success">
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<Snackbar open={socketHasError}>
<Alert variant="filled" severity="error">
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
</Alert>
</Snackbar>
<SpeedDial
ariaLabel="SpeedDial basic example"
sx={{ position: 'absolute', bottom: 32, right: 32 }}
icon={<SpeedDialIcon />}
>
<SpeedDialAction
icon={<FormatListBulleted />}
tooltipTitle={`Table view`}
tooltipOpen
onClick={() => dispatch(toggleListView())}
/>
</SpeedDial>
</Container>
)
}

View File

@@ -0,0 +1,78 @@
/*
Login view component
*/
import styled from '@emotion/styled'
import {
Button,
Container,
Paper,
Stack,
TextField,
Typography
} from '@mui/material'
import { getHttpEndpoint } from '../utils'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
const LoginContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'center',
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function Login() {
const [secret, setSecret] = useState('')
const [formHasError, setFormHasError] = useState(false)
const navigate = useNavigate()
const login = async () => {
const res = await fetch(`${getHttpEndpoint()}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
secret
})
})
res.ok ? navigate('/') : setFormHasError(true)
}
return (
<LoginContainer>
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
<Stack direction="column" spacing={2}>
<Title fontWeight={'700'} fontSize={32} color={'primary'}>
yt-dlp WebUI
</Title>
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
Authentication token will expire after 30 days.
</Title>
<TextField
id="outlined-password-input"
label="RPC secret"
type="password"
autoComplete="current-password"
error={formHasError}
onChange={e => setSecret(e.currentTarget.value)}
/>
<Button variant="contained" size="large" onClick={() => login()}>
Submit
</Button>
</Stack>
</Paper>
</LoginContainer>
)
}

View File

@@ -0,0 +1,288 @@
import {
Button,
Container,
FormControl,
FormControlLabel,
FormGroup,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Paper,
Select,
SelectChangeEvent,
Snackbar,
Stack,
Switch,
TextField,
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
Subject,
debounceTime,
distinctUntilChanged,
map,
takeWhile
} from 'rxjs'
import { CliArguments } from '../lib/argsParser'
import I18nBuilder from '../lib/intl'
import { RPCClient } from '../lib/rpcClient'
import {
LanguageUnion,
ThemeUnion,
setCliArgs,
setEnableCustomArgs,
setFileRenaming,
setFormatSelection,
setLanguage,
setPathOverriding,
setServerAddr,
setServerPort,
setTheme
} from '../features/settings/settingsSlice'
import { updated } from '../features/status/statusSlice'
import { RootState } from '../stores/store'
import { validateDomain, validateIP } from '../utils'
export default function Settings() {
const dispatch = useDispatch()
const status = useSelector((state: RootState) => state.status)
const settings = useSelector((state: RootState) => state.settings)
const [invalidIP, setInvalidIP] = useState(false);
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
const client = useMemo(() => new RPCClient(), [])
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
const serverAddr$ = useMemo(() => new Subject<string>(), [])
const serverPort$ = useMemo(() => new Subject<string>(), [])
useEffect(() => {
const sub = serverAddr$
.pipe(
debounceTime(500),
distinctUntilChanged()
)
.subscribe(addr => {
if (validateIP(addr)) {
setInvalidIP(false)
dispatch(setServerAddr(addr))
} else if (validateDomain(addr)) {
setInvalidIP(false)
dispatch(setServerAddr(addr))
} else {
setInvalidIP(true)
}
})
return () => sub.unsubscribe()
}, [serverAddr$])
useEffect(() => {
const sub = serverPort$
.pipe(
debounceTime(500),
map(val => Number(val)),
takeWhile(val => isFinite(val) && val <= 65535),
)
.subscribe(port => {
dispatch(setServerPort(port.toString()))
})
return () => sub.unsubscribe()
}, [])
/**
* Language toggler handler
*/
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => {
dispatch(setLanguage(event.target.value as LanguageUnion));
}
/**
* Theme toggler handler
*/
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => {
dispatch(setTheme(event.target.value as ThemeUnion));
}
/**
* Send via WebSocket a message to update yt-dlp binary
*/
const updateBinary = () => {
client.updateExecutable().then(() => dispatch(updated()))
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={12} lg={12}>
<Paper
sx={{
p: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 240,
}}
>
<Typography pb={3} variant="h5" color="primary">
{i18n.t('settingsAnchor')}
</Typography>
<FormGroup>
<Grid container spacing={2}>
<Grid item xs={12} md={11}>
<TextField
fullWidth
label={i18n.t('serverAddressTitle')}
defaultValue={settings.serverAddr}
error={invalidIP}
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
InputProps={{
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
}}
sx={{ mb: 2 }}
/>
</Grid>
<Grid item xs={12} md={1}>
<TextField
fullWidth
label={i18n.t('serverPortTitle')}
defaultValue={settings.serverPort}
onChange={(e) => serverPort$.next(e.currentTarget.value)}
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
sx={{ mb: 2 }}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
<Select
defaultValue={settings.language}
label={i18n.t('languageSelect')}
onChange={handleLanguageChange}
>
<MenuItem value="english">English</MenuItem>
<MenuItem value="spanish">Spanish</MenuItem>
<MenuItem value="italian">Italian</MenuItem>
<MenuItem value="chinese">Chinese</MenuItem>
<MenuItem value="russian">Russian</MenuItem>
<MenuItem value="korean">Korean</MenuItem>
<MenuItem value="japanese">Japanese</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
<Select
defaultValue={settings.theme}
label={i18n.t('themeSelect')}
onChange={handleThemeChange}
>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.noMTime}
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))}
/>
}
label={i18n.t('noMTimeCheckbox')}
sx={{ mt: 3 }}
/>
<FormControlLabel
control={
<Switch
defaultChecked={cliArgs.extractAudio}
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))}
disabled={settings.formatSelection}
/>
}
label={i18n.t('extractAudioCheckbox')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={settings.formatSelection}
onChange={() => {
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString()))
dispatch(setFormatSelection(!settings.formatSelection))
}}
/>
}
label={i18n.t('formatSelectionEnabler')}
/>
<Grid>
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
{i18n.t('overridesAnchor')}
</Typography>
<Stack direction="column">
<FormControlLabel
control={
<Switch
defaultChecked={settings.pathOverriding}
onChange={() => {
dispatch(setPathOverriding(!settings.pathOverriding))
}}
/>
}
label={i18n.t('pathOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={settings.fileRenaming}
onChange={() => {
dispatch(setFileRenaming(!settings.fileRenaming))
}}
/>
}
label={i18n.t('filenameOverrideOption')}
/>
<FormControlLabel
control={
<Switch
defaultChecked={settings.enableCustomArgs}
onChange={() => {
dispatch(setEnableCustomArgs(!settings.enableCustomArgs))
}}
/>
}
label={i18n.t('customArgs')}
/>
</Stack>
</Grid>
<Grid>
<Stack direction="row">
<Button
sx={{ mr: 1, mt: 3 }}
variant="contained"
onClick={() => dispatch(updated())}
>
{i18n.t('updateBinButton')}
</Button>
</Stack>
</Grid>
</FormGroup>
</Paper>
</Grid>
</Grid>
<Snackbar
open={status.updated}
autoHideDuration={1500}
message={i18n.t('toastUpdated')}
onClose={updateBinary}
/>
</Container>
);
}