313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import React, { useEffect } 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'
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
export const OptionsPanel: React.FC = () => {
|
||
const { options, setOptions } = useOptionsStore()
|
||
const { hasVideo } = useUploadStore()
|
||
const { formatError, setFormatError } = useValidationStore()
|
||
|
||
// Audio-only formats from backend constants (hardcoded for now)
|
||
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
|
||
|
||
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newFormat = e.target.value
|
||
setOptions({ format: newFormat })
|
||
|
||
// Validate format
|
||
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
|
||
setFormatError(
|
||
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||
)
|
||
} else {
|
||
setFormatError(null)
|
||
}
|
||
}
|
||
|
||
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const checked = e.target.checked
|
||
setOptions({ drop_video: checked })
|
||
// Re-validate format
|
||
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
|
||
setFormatError(
|
||
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||
)
|
||
} else {
|
||
setFormatError(null)
|
||
}
|
||
}
|
||
|
||
// Re-validate when hasVideo changes (e.g., after upload)
|
||
useEffect(() => {
|
||
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
|
||
const newError = shouldShowError
|
||
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||
: null
|
||
|
||
// Only update if the error state actually changes
|
||
if (newError !== formatError) {
|
||
setFormatError(newError)
|
||
}
|
||
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
|
||
|
||
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 }}>
|
||
<TextField
|
||
label="Container"
|
||
select
|
||
value={options.format}
|
||
onChange={handleFormatChange}
|
||
fullWidth
|
||
size="small"
|
||
error={!!formatError}
|
||
helperText={
|
||
formatError || "Determines the output file extension and container structure."
|
||
}
|
||
>
|
||
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
|
||
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
|
||
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
|
||
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
|
||
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
|
||
<MenuItem value="opus">OPUS (.opus)</MenuItem>
|
||
<MenuItem value="flac">FLAC (.flac)</MenuItem>
|
||
<MenuItem value="wav">WAV (.wav)</MenuItem>
|
||
<MenuItem value="aac">AAC (.aac)</MenuItem>
|
||
</TextField>
|
||
|
||
{/* Transcode option – remains unchanged but clearly labeled */}
|
||
<TextField
|
||
label="Audio Codec (Transcode)"
|
||
select
|
||
value={options.transcode_to || ''}
|
||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||
fullWidth
|
||
size="small"
|
||
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
||
>
|
||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||
<MenuItem value="aac">AAC</MenuItem>
|
||
<MenuItem value="libopus">OPUS</MenuItem>
|
||
</TextField>
|
||
|
||
{hasVideo && (
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={options.drop_video}
|
||
onChange={handleDropVideoChange}
|
||
/>
|
||
}
|
||
label="Drop video streams"
|
||
/>
|
||
)}
|
||
|
||
<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))
|
||
}
|
||
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>
|
||
)
|
||
|
||
// Helper function for option updates
|
||
function handleChange(field: string, value: any) {
|
||
setOptions({ [field]: value })
|
||
}
|
||
}
|