This major update adds powerful format-specific controls, quality presets, file metadata display, and enhanced progress feedback to significantly improve the user experience. New Components: - ConversionOptionsPanel: Format-specific controls with collapsible advanced options - Video options: codec selection (H.264, H.265, VP8, VP9), bitrate, resolution, FPS - Audio options: codec selection, bitrate, sample rate, channels - Image options: quality slider, width/height controls - Quality Presets: One-click presets (High Quality, Balanced, Small File, Web Optimized) - FileInfo: Displays file metadata including size, duration, dimensions - Slider: Reusable slider component for quality/bitrate controls - Select: Reusable dropdown component for codec/format selection Enhanced Features: - ConversionPreview improvements: - Real-time elapsed time display - Estimated time remaining calculation - File size comparison (input vs output with % reduction/increase) - Better visual status indicators with icons - Enhanced loading states with detailed progress - FileConverter integration: - Passes conversion options to converter services - Manages conversion options state - Resets options on file reset UI/UX Improvements: - Format-specific option panels that adapt to selected output format - Visual preset buttons with icons and descriptions - Collapsible advanced options to reduce clutter - Better progress feedback with time estimates - File size comparison badges showing compression results - Smooth animations and transitions (existing animations already in place) - Responsive design for all new components Technical Details: - Options are properly typed and integrated with ConversionOptions interface - All components support disabled states during conversion - Preview component calculates speed and estimates remaining time - Metadata extraction for video/audio/image files using browser APIs - Proper cleanup of object URLs and timers User Benefits: - Power users can fine-tune codec, bitrate, resolution settings - Beginners can use quality presets with confidence - Better understanding of conversion progress and file size impact - Informed decisions with file metadata display - Professional-grade control over output quality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Slider } from '@/components/ui/Slider';
|
|
import { Select } from '@/components/ui/Select';
|
|
import type { ConversionOptions, ConversionFormat } from '@/types/conversion';
|
|
|
|
interface ConversionOptionsProps {
|
|
inputFormat: ConversionFormat;
|
|
outputFormat: ConversionFormat;
|
|
options: ConversionOptions;
|
|
onOptionsChange: (options: ConversionOptions) => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface QualityPreset {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
options: ConversionOptions;
|
|
}
|
|
|
|
export function ConversionOptionsPanel({
|
|
inputFormat,
|
|
outputFormat,
|
|
options,
|
|
onOptionsChange,
|
|
disabled = false,
|
|
}: ConversionOptionsProps) {
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
const [selectedPreset, setSelectedPreset] = React.useState<string | null>(null);
|
|
|
|
// Quality presets based on output format category
|
|
const getPresets = (): QualityPreset[] => {
|
|
const category = outputFormat.category;
|
|
|
|
if (category === 'video') {
|
|
return [
|
|
{
|
|
id: 'high-quality',
|
|
name: 'High Quality',
|
|
description: 'Best quality, larger file size',
|
|
icon: '⭐',
|
|
options: {
|
|
videoBitrate: '5M',
|
|
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
|
audioBitrate: '192k',
|
|
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
|
},
|
|
},
|
|
{
|
|
id: 'balanced',
|
|
name: 'Balanced',
|
|
description: 'Good quality, moderate size',
|
|
icon: '⚖️',
|
|
options: {
|
|
videoBitrate: '2M',
|
|
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
|
audioBitrate: '128k',
|
|
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
|
},
|
|
},
|
|
{
|
|
id: 'small-file',
|
|
name: 'Small File',
|
|
description: 'Smaller size, lower quality',
|
|
icon: '📦',
|
|
options: {
|
|
videoBitrate: '1M',
|
|
videoCodec: outputFormat.extension === 'webm' ? 'libvpx' : 'libx264',
|
|
audioBitrate: '96k',
|
|
audioCodec: outputFormat.extension === 'webm' ? 'libvorbis' : 'aac',
|
|
},
|
|
},
|
|
{
|
|
id: 'web-optimized',
|
|
name: 'Web Optimized',
|
|
description: 'Fast loading for web',
|
|
icon: '🌐',
|
|
options: {
|
|
videoBitrate: '1.5M',
|
|
videoCodec: 'libvpx',
|
|
audioBitrate: '128k',
|
|
audioCodec: 'libvorbis',
|
|
videoResolution: '720x-1',
|
|
},
|
|
},
|
|
];
|
|
} else if (category === 'audio') {
|
|
return [
|
|
{
|
|
id: 'high-quality',
|
|
name: 'High Quality',
|
|
description: 'Best audio quality',
|
|
icon: '⭐',
|
|
options: {
|
|
audioBitrate: '320k',
|
|
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
|
},
|
|
},
|
|
{
|
|
id: 'balanced',
|
|
name: 'Balanced',
|
|
description: 'Good quality, smaller size',
|
|
icon: '⚖️',
|
|
options: {
|
|
audioBitrate: '192k',
|
|
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
|
},
|
|
},
|
|
{
|
|
id: 'small-file',
|
|
name: 'Small File',
|
|
description: 'Minimum file size',
|
|
icon: '📦',
|
|
options: {
|
|
audioBitrate: '128k',
|
|
audioCodec: outputFormat.extension === 'mp3' ? 'libmp3lame' : 'default',
|
|
},
|
|
},
|
|
];
|
|
} else if (category === 'image') {
|
|
return [
|
|
{
|
|
id: 'high-quality',
|
|
name: 'High Quality',
|
|
description: 'Best image quality',
|
|
icon: '⭐',
|
|
options: {
|
|
imageQuality: 95,
|
|
},
|
|
},
|
|
{
|
|
id: 'balanced',
|
|
name: 'Balanced',
|
|
description: 'Good quality',
|
|
icon: '⚖️',
|
|
options: {
|
|
imageQuality: 85,
|
|
},
|
|
},
|
|
{
|
|
id: 'web-optimized',
|
|
name: 'Web Optimized',
|
|
description: 'Optimized for web',
|
|
icon: '🌐',
|
|
options: {
|
|
imageQuality: 75,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
};
|
|
|
|
const presets = getPresets();
|
|
|
|
const handlePresetClick = (preset: QualityPreset) => {
|
|
setSelectedPreset(preset.id);
|
|
onOptionsChange({ ...options, ...preset.options });
|
|
};
|
|
|
|
const handleOptionChange = (key: string, value: any) => {
|
|
setSelectedPreset(null); // Clear preset when manual change
|
|
onOptionsChange({ ...options, [key]: value });
|
|
};
|
|
|
|
const renderVideoOptions = () => (
|
|
<div className="space-y-4">
|
|
{/* Video Codec */}
|
|
<Select
|
|
label="Video Codec"
|
|
value={options.videoCodec || 'default'}
|
|
onValueChange={(value) => handleOptionChange('videoCodec', value === 'default' ? undefined : value)}
|
|
options={[
|
|
{ value: 'default', label: 'Auto (Recommended)' },
|
|
{ value: 'libx264', label: 'H.264 (MP4, AVI, MOV)' },
|
|
{ value: 'libx265', label: 'H.265 (MP4)' },
|
|
{ value: 'libvpx', label: 'VP8 (WebM)' },
|
|
{ value: 'libvpx-vp9', label: 'VP9 (WebM)' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Video Bitrate */}
|
|
<div>
|
|
<label className="text-sm font-medium text-foreground mb-2 block">Video Bitrate</label>
|
|
<div className="space-y-2">
|
|
<Slider
|
|
min={0.5}
|
|
max={10}
|
|
step={0.5}
|
|
value={parseFloat(options.videoBitrate?.replace('M', '') || '2')}
|
|
onValueChange={(value) => handleOptionChange('videoBitrate', `${value}M`)}
|
|
showValue={true}
|
|
unit="M"
|
|
disabled={disabled}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Higher bitrate = better quality, larger file</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resolution */}
|
|
<Select
|
|
label="Resolution"
|
|
value={options.videoResolution || 'original'}
|
|
onValueChange={(value) => handleOptionChange('videoResolution', value === 'original' ? undefined : value)}
|
|
options={[
|
|
{ value: 'original', label: 'Original' },
|
|
{ value: '1920x-1', label: '1080p (1920x1080)' },
|
|
{ value: '1280x-1', label: '720p (1280x720)' },
|
|
{ value: '854x-1', label: '480p (854x480)' },
|
|
{ value: '640x-1', label: '360p (640x360)' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* FPS */}
|
|
<Select
|
|
label="Frame Rate (FPS)"
|
|
value={options.videoFps?.toString() || 'original'}
|
|
onValueChange={(value) => handleOptionChange('videoFps', value === 'original' ? undefined : parseInt(value))}
|
|
options={[
|
|
{ value: 'original', label: 'Original' },
|
|
{ value: '60', label: '60 fps' },
|
|
{ value: '30', label: '30 fps' },
|
|
{ value: '24', label: '24 fps' },
|
|
{ value: '15', label: '15 fps' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Audio Bitrate */}
|
|
<Slider
|
|
label="Audio Bitrate"
|
|
min={64}
|
|
max={320}
|
|
step={32}
|
|
value={parseInt(options.audioBitrate?.replace('k', '') || '128')}
|
|
onValueChange={(value) => handleOptionChange('audioBitrate', `${value}k`)}
|
|
showValue={true}
|
|
unit="k"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const renderAudioOptions = () => (
|
|
<div className="space-y-4">
|
|
{/* Audio Codec */}
|
|
<Select
|
|
label="Audio Codec"
|
|
value={options.audioCodec || 'default'}
|
|
onValueChange={(value) => handleOptionChange('audioCodec', value === 'default' ? undefined : value)}
|
|
options={[
|
|
{ value: 'default', label: 'Auto (Recommended)' },
|
|
{ value: 'libmp3lame', label: 'MP3 (LAME)' },
|
|
{ value: 'aac', label: 'AAC' },
|
|
{ value: 'libvorbis', label: 'Vorbis (OGG)' },
|
|
{ value: 'libopus', label: 'Opus' },
|
|
{ value: 'flac', label: 'FLAC (Lossless)' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Bitrate */}
|
|
<Slider
|
|
label="Bitrate"
|
|
min={64}
|
|
max={320}
|
|
step={32}
|
|
value={parseInt(options.audioBitrate?.replace('k', '') || '192')}
|
|
onValueChange={(value) => handleOptionChange('audioBitrate', `${value}k`)}
|
|
showValue={true}
|
|
unit="k"
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Sample Rate */}
|
|
<Select
|
|
label="Sample Rate"
|
|
value={options.audioSampleRate?.toString() || 'original'}
|
|
onValueChange={(value) => handleOptionChange('audioSampleRate', value === 'original' ? undefined : parseInt(value))}
|
|
options={[
|
|
{ value: 'original', label: 'Original' },
|
|
{ value: '48000', label: '48 kHz (Studio)' },
|
|
{ value: '44100', label: '44.1 kHz (CD Quality)' },
|
|
{ value: '22050', label: '22.05 kHz' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Channels */}
|
|
<Select
|
|
label="Channels"
|
|
value={options.audioChannels?.toString() || 'original'}
|
|
onValueChange={(value) => handleOptionChange('audioChannels', value === 'original' ? undefined : parseInt(value))}
|
|
options={[
|
|
{ value: 'original', label: 'Original' },
|
|
{ value: '2', label: 'Stereo (2 channels)' },
|
|
{ value: '1', label: 'Mono (1 channel)' },
|
|
]}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const renderImageOptions = () => (
|
|
<div className="space-y-4">
|
|
{/* Quality */}
|
|
<Slider
|
|
label="Quality"
|
|
min={1}
|
|
max={100}
|
|
step={1}
|
|
value={options.imageQuality || 85}
|
|
onValueChange={(value) => handleOptionChange('imageQuality', value)}
|
|
showValue={true}
|
|
unit="%"
|
|
disabled={disabled}
|
|
/>
|
|
|
|
{/* Width */}
|
|
<div>
|
|
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
|
|
<input
|
|
type="number"
|
|
value={options.imageWidth || ''}
|
|
onChange={(e) => handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)}
|
|
placeholder="Original"
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
disabled={disabled}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">Leave empty to keep original</p>
|
|
</div>
|
|
|
|
{/* Height */}
|
|
<div>
|
|
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
|
|
<input
|
|
type="number"
|
|
value={options.imageHeight || ''}
|
|
onChange={(e) => handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)}
|
|
placeholder="Original"
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
disabled={disabled}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">Leave empty to maintain aspect ratio</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Card className="p-4">
|
|
{/* Presets Section */}
|
|
{presets.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Sparkles className="h-4 w-4 text-primary" />
|
|
<h3 className="text-sm font-semibold">Quality Presets</h3>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{presets.map((preset) => (
|
|
<Button
|
|
key={preset.id}
|
|
onClick={() => handlePresetClick(preset)}
|
|
variant={selectedPreset === preset.id ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="flex flex-col h-auto py-3 px-3 text-left items-start"
|
|
disabled={disabled}
|
|
>
|
|
<span className="text-base mb-1">{preset.icon}</span>
|
|
<span className="text-xs font-medium">{preset.name}</span>
|
|
<span className="text-xs text-muted-foreground mt-1">{preset.description}</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Advanced Options Toggle */}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full flex items-center justify-between text-sm font-medium text-foreground hover:text-primary transition-colors mb-3"
|
|
disabled={disabled}
|
|
>
|
|
<span>Advanced Options</span>
|
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
</button>
|
|
|
|
{/* Advanced Options Panel */}
|
|
{isExpanded && (
|
|
<div className="pt-3 border-t border-border">
|
|
{outputFormat.category === 'video' && renderVideoOptions()}
|
|
{outputFormat.category === 'audio' && renderAudioOptions()}
|
|
{outputFormat.category === 'image' && renderImageOptions()}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|