371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import React, { useEffect, useMemo } from 'react'
|
|
import {
|
|
Box,
|
|
Paper,
|
|
Typography,
|
|
TextField,
|
|
MenuItem,
|
|
FormControlLabel,
|
|
Switch,
|
|
Collapse,
|
|
IconButton,
|
|
Divider,
|
|
Alert,
|
|
} from '@mui/material'
|
|
import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
|
import { useOptionsStore } from '../stores/optionsStore'
|
|
import { useUploadStore } from '../stores/uploadStore'
|
|
import { useValidationStore } from '../stores/validationStore'
|
|
import { getFormats } from '../api/client'
|
|
import { SplitOptions } from '../types'
|
|
import { useFilterCodecs } from '../hooks/useFilterCodecs'
|
|
import { useFormatValidation } from '../hooks/useFormatValidation'
|
|
|
|
// ------------------------------------------------------------------------------
|
|
// Section component (collapsible)
|
|
// ------------------------------------------------------------------------------
|
|
interface SectionProps {
|
|
title: string
|
|
children: React.ReactNode
|
|
defaultExpanded?: boolean
|
|
}
|
|
|
|
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
|
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
|
return (
|
|
<Box sx={{ mb: 2 }}>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
cursor: 'pointer',
|
|
py: 1,
|
|
}}
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
{title}
|
|
</Typography>
|
|
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
|
|
</Box>
|
|
<Divider />
|
|
<Collapse in={expanded}>
|
|
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
|
|
</Collapse>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------
|
|
// Main OptionsPanel
|
|
// ------------------------------------------------------------------------------
|
|
export const OptionsPanel: React.FC = () => {
|
|
const {
|
|
options,
|
|
setOptions,
|
|
containers,
|
|
codecs,
|
|
compatibility,
|
|
setContainers,
|
|
setCodecs,
|
|
setCompatibility,
|
|
} = useOptionsStore()
|
|
|
|
const { hasVideo } = useUploadStore()
|
|
const { formatError, setFormatError } = useValidationStore()
|
|
|
|
// Fetch formats on mount
|
|
useEffect(() => {
|
|
const fetchFormats = async () => {
|
|
try {
|
|
const data = await getFormats()
|
|
setContainers(data.containers || [])
|
|
setCodecs(data.codecs || [])
|
|
setCompatibility(data.compatibility || {})
|
|
} catch (err) {
|
|
console.error('Failed to fetch formats:', err)
|
|
}
|
|
}
|
|
fetchFormats()
|
|
}, [setContainers, setCodecs, setCompatibility])
|
|
|
|
// --------------------------------------------------------------
|
|
// Filter codec options based on selected container
|
|
// --------------------------------------------------------------
|
|
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(compatibility, options.container, codecs)
|
|
|
|
// --------------------------------------------------------------
|
|
// Validate compatibility
|
|
// --------------------------------------------------------------
|
|
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
|
|
|
|
// --------------------------------------------------------------
|
|
// Handlers
|
|
// --------------------------------------------------------------
|
|
const handleChange = (field: keyof SplitOptions, value: any) => {
|
|
setOptions({ [field]: value })
|
|
}
|
|
|
|
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value === '' ? null : e.target.value
|
|
handleChange('container', value)
|
|
}
|
|
|
|
// --------------------------------------------------------------
|
|
// Render
|
|
// --------------------------------------------------------------
|
|
return (
|
|
<Paper sx={{ p: 3 }}>
|
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
|
⚙️ Options
|
|
</Typography>
|
|
|
|
{formatError && (
|
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
{formatError}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* ------------------------------------------------------------------------
|
|
Output Settings
|
|
------------------------------------------------------------------------ */}
|
|
<Section title="Output Settings" defaultExpanded>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
{/* Container dropdown */}
|
|
<TextField
|
|
label="Container"
|
|
select
|
|
value={options.container ?? ''}
|
|
onChange={handleContainerChange}
|
|
fullWidth
|
|
size="small"
|
|
error={!!formatError}
|
|
helperText={
|
|
formatError || "Select a container (auto-detect if empty)."
|
|
}
|
|
>
|
|
<MenuItem value="">Auto-detect</MenuItem>
|
|
{containers.map((c) => (
|
|
<MenuItem key={c.name} value={c.name}>
|
|
{c.name.toUpperCase()} ({c.extension})
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
|
|
{/* Audio Codec dropdown */}
|
|
<TextField
|
|
label="Audio Codec"
|
|
select
|
|
value={options.audio_codec}
|
|
onChange={(e) => handleChange('audio_codec', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
helperText="Audio codec (copy = keep original)"
|
|
>
|
|
<MenuItem value="copy">Copy (original)</MenuItem>
|
|
{filteredAudioCodecs.map((c) => (
|
|
<MenuItem key={c.name} value={c.name}>
|
|
{c.name.toUpperCase()}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
|
|
{/* Video Codec dropdown */}
|
|
<TextField
|
|
label="Video Codec"
|
|
select
|
|
value={options.video_codec}
|
|
onChange={(e) => handleChange('video_codec', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
helperText="Video codec (copy = keep original)"
|
|
disabled={!hasVideo || options.drop_video}
|
|
>
|
|
<MenuItem value="copy">Copy (original)</MenuItem>
|
|
{filteredVideoCodecs.map((c) => (
|
|
<MenuItem key={c.name} value={c.name}>
|
|
{c.name.toUpperCase()}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
|
|
{/* Video Quality */}
|
|
<TextField
|
|
label="Video Quality"
|
|
type="number"
|
|
value={options.video_quality ?? ''}
|
|
onChange={(e) => {
|
|
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
|
|
handleChange('video_quality', val)
|
|
}}
|
|
fullWidth
|
|
size="small"
|
|
disabled={!hasVideo || options.drop_video}
|
|
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
|
|
InputProps={{
|
|
inputProps: { min: 0, max: 51, step: 1 },
|
|
}}
|
|
/>
|
|
|
|
{/* Drop video (if video present) */}
|
|
{hasVideo && (
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.drop_video}
|
|
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Drop video streams"
|
|
/>
|
|
)}
|
|
|
|
{/* Drop subtitles */}
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.drop_subs}
|
|
onChange={(e) => handleChange('drop_subs', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Drop subtitle streams"
|
|
/>
|
|
</Box>
|
|
</Section>
|
|
|
|
{/* ------------------------------------------------------------------------
|
|
Filename Settings
|
|
------------------------------------------------------------------------ */}
|
|
<Section title="Filename Settings">
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<TextField
|
|
label="Output template"
|
|
value={options.output_template}
|
|
onChange={(e) => handleChange('output_template', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.number_tracks}
|
|
onChange={(e) => handleChange('number_tracks', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Number tracks (01 - )"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.replace_bad_chars}
|
|
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Replace bad characters"
|
|
/>
|
|
<TextField
|
|
label="Replacement character"
|
|
value={options.replacement_char}
|
|
onChange={(e) => handleChange('replacement_char', e.target.value)}
|
|
size="small"
|
|
disabled={!options.replace_bad_chars}
|
|
/>
|
|
<TextField
|
|
label="Bad characters list"
|
|
value={options.bad_chars}
|
|
onChange={(e) => handleChange('bad_chars', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
disabled={!options.replace_bad_chars}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.skip_existing}
|
|
onChange={(e) => handleChange('skip_existing', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Skip existing files"
|
|
/>
|
|
</Box>
|
|
</Section>
|
|
|
|
{/* ------------------------------------------------------------------------
|
|
Metadata Settings
|
|
------------------------------------------------------------------------ */}
|
|
<Section title="Metadata Settings">
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<TextField
|
|
label="Album"
|
|
value={options.album}
|
|
onChange={(e) => handleChange('album', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
/>
|
|
<TextField
|
|
label="Comment"
|
|
value={options.comment}
|
|
onChange={(e) => handleChange('comment', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.no_comment}
|
|
onChange={(e) => handleChange('no_comment', e.target.checked)}
|
|
/>
|
|
}
|
|
label="No comment"
|
|
/>
|
|
<TextField
|
|
label="Comment stream index"
|
|
type="number"
|
|
value={options.comment_stream ?? ''}
|
|
onChange={(e) =>
|
|
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
|
|
}
|
|
size="small"
|
|
disabled={options.no_comment}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={options.merge_comments}
|
|
onChange={(e) => handleChange('merge_comments', e.target.checked)}
|
|
/>
|
|
}
|
|
label="Merge all comments"
|
|
disabled={options.no_comment}
|
|
/>
|
|
<TextField
|
|
label="Comment separator"
|
|
value={options.comment_separator}
|
|
onChange={(e) => handleChange('comment_separator', e.target.value)}
|
|
size="small"
|
|
disabled={!options.merge_comments || options.no_comment}
|
|
/>
|
|
</Box>
|
|
</Section>
|
|
|
|
{/* ------------------------------------------------------------------------
|
|
Tracklist Settings
|
|
------------------------------------------------------------------------ */}
|
|
<Section title="Tracklist Settings" defaultExpanded>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<TextField
|
|
label="Tracklist Format"
|
|
value={options.tracklist_format}
|
|
onChange={(e) => handleChange('tracklist_format', e.target.value)}
|
|
fullWidth
|
|
size="small"
|
|
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
|
|
/>
|
|
</Box>
|
|
</Section>
|
|
</Paper>
|
|
)
|
|
}
|