Files
audio_splitter/web/frontend/src/components/OptionsPanel.tsx
T

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>
)
}