diff --git a/components/converter/ConversionOptions.tsx b/components/converter/ConversionOptions.tsx new file mode 100644 index 0000000..8473158 --- /dev/null +++ b/components/converter/ConversionOptions.tsx @@ -0,0 +1,406 @@ +'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(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 = () => ( +
+ {/* Video Codec */} + 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 */} + 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 */} + handleOptionChange('audioBitrate', `${value}k`)} + showValue={true} + unit="k" + disabled={disabled} + /> + + {/* Sample Rate */} + 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} + /> +
+ ); + + const renderImageOptions = () => ( +
+ {/* Quality */} + handleOptionChange('imageQuality', value)} + showValue={true} + unit="%" + disabled={disabled} + /> + + {/* Width */} +
+ + 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} + /> +

Leave empty to keep original

+
+ + {/* Height */} +
+ + 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} + /> +

Leave empty to maintain aspect ratio

+
+
+ ); + + return ( + + {/* Presets Section */} + {presets.length > 0 && ( +
+
+ +

Quality Presets

+
+
+ {presets.map((preset) => ( + + ))} +
+
+ )} + + {/* Advanced Options Toggle */} + + + {/* Advanced Options Panel */} + {isExpanded && ( +
+ {outputFormat.category === 'video' && renderVideoOptions()} + {outputFormat.category === 'audio' && renderAudioOptions()} + {outputFormat.category === 'image' && renderImageOptions()} +
+ )} +
+ ); +} diff --git a/components/converter/ConversionPreview.tsx b/components/converter/ConversionPreview.tsx index c96ef1a..2705580 100644 --- a/components/converter/ConversionPreview.tsx +++ b/components/converter/ConversionPreview.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Download, CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { Button } from '@/components/ui/Button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; @@ -16,6 +16,32 @@ export interface ConversionPreviewProps { export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) { const [previewUrl, setPreviewUrl] = React.useState(null); + const [elapsedTime, setElapsedTime] = React.useState(0); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = React.useState(null); + + // Timer for elapsed time and estimation + React.useEffect(() => { + if (job.status === 'processing' || job.status === 'loading') { + const interval = setInterval(() => { + if (job.startTime) { + const elapsed = Date.now() - job.startTime; + setElapsedTime(elapsed); + + // Estimate time remaining based on progress + if (job.progress > 5 && job.progress < 100) { + const progressRate = job.progress / elapsed; + const remainingProgress = 100 - job.progress; + const estimated = remainingProgress / progressRate; + setEstimatedTimeRemaining(estimated); + } + } + }, 100); + + return () => clearInterval(interval); + } else { + setEstimatedTimeRemaining(null); + } + }, [job.status, job.startTime, job.progress]); // Create preview URL for result React.useEffect(() => { @@ -77,44 +103,109 @@ export function ConversionPreview({ job, onDownload }: ConversionPreviewProps) { } }; + const formatTime = (ms: number) => { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; + }; + const renderStatus = () => { switch (job.status) { case 'loading': return ( -
- - Loading converter... +
+
+ + Loading WASM converter... +
+
+ + Elapsed: {formatTime(elapsedTime)} +
); case 'processing': return ( -
-
- - Converting... +
+
+
+ + Converting... +
+ {job.progress}% +
+ +
+
+ + Elapsed: {formatTime(elapsedTime)} +
+ {estimatedTimeRemaining && ( +
+ + ~{formatTime(estimatedTimeRemaining)} remaining +
+ )}
-
); case 'completed': + const inputSize = job.inputFile.size; + const outputSize = job.result?.size || 0; + const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0; + return ( -
- - Conversion complete! - {job.result && ( - - {formatFileSize(job.result.size)} - - )} +
+
+ + Conversion complete! +
+ + {/* File size comparison */} +
+
+
+ + Input: +
+ {formatFileSize(inputSize)} +
+ +
+ +
+ +
+
+ + Output: +
+
+ {formatFileSize(outputSize)} + {Math.abs(sizeReduction) > 1 && ( + 0 + ? "bg-success/10 text-success" + : "bg-info/10 text-info" + )}> + {sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}% + + )} +
+
+
); case 'error': return (
- + Conversion failed
); diff --git a/components/converter/FileConverter.tsx b/components/converter/FileConverter.tsx index c212ed6..b09dad7 100644 --- a/components/converter/FileConverter.tsx +++ b/components/converter/FileConverter.tsx @@ -7,6 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { FileUpload } from './FileUpload'; import { FormatSelector } from './FormatSelector'; import { ConversionPreview } from './ConversionPreview'; +import { ConversionOptionsPanel } from './ConversionOptions'; +import { FileInfo } from './FileInfo'; import { useToast } from '@/components/ui/Toast'; import { SUPPORTED_FORMATS, @@ -17,7 +19,7 @@ import { import { convertWithFFmpeg } from '@/lib/converters/ffmpegService'; import { convertWithImageMagick } from '@/lib/converters/imagemagickService'; import { addToHistory } from '@/lib/storage/history'; -import type { ConversionJob, ConversionFormat } from '@/types/conversion'; +import type { ConversionJob, ConversionFormat, ConversionOptions } from '@/types/conversion'; export function FileConverter() { const { addToast } = useToast(); @@ -27,6 +29,7 @@ export function FileConverter() { const [outputFormat, setOutputFormat] = React.useState(); const [compatibleFormats, setCompatibleFormats] = React.useState([]); const [conversionJob, setConversionJob] = React.useState(); + const [conversionOptions, setConversionOptions] = React.useState({}); // Detect input format when file is selected React.useEffect(() => { @@ -77,7 +80,7 @@ export function FileConverter() { inputFile: selectedFile, inputFormat, outputFormat, - options: {}, + options: conversionOptions, status: 'loading', progress: 0, startTime: Date.now(), @@ -94,7 +97,7 @@ export function FileConverter() { switch (outputFormat.converter) { case 'ffmpeg': - result = await convertWithFFmpeg(selectedFile, outputFormat.extension, {}, (progress) => { + result = await convertWithFFmpeg(selectedFile, outputFormat.extension, conversionOptions, (progress) => { setConversionJob((prev) => prev && { ...prev, progress }); }); break; @@ -103,7 +106,7 @@ export function FileConverter() { result = await convertWithImageMagick( selectedFile, outputFormat.extension, - {}, + conversionOptions, (progress) => { setConversionJob((prev) => prev && { ...prev, progress }); } @@ -165,6 +168,7 @@ export function FileConverter() { setOutputFormat(undefined); setCompatibleFormats([]); setConversionJob(undefined); + setConversionOptions({}); }; const isConvertDisabled = @@ -189,6 +193,11 @@ export function FileConverter() { disabled={conversionJob?.status === 'processing' || conversionJob?.status === 'loading'} /> + {/* File Info */} + {selectedFile && inputFormat && ( + + )} + {/* Format selection */} {inputFormat && compatibleFormats.length > 0 && (
@@ -217,6 +226,17 @@ export function FileConverter() {
)} + {/* Conversion Options */} + {inputFormat && outputFormat && ( + + )} + {/* Convert button */} {inputFormat && outputFormat && (
diff --git a/components/converter/FileInfo.tsx b/components/converter/FileInfo.tsx new file mode 100644 index 0000000..7c5a089 --- /dev/null +++ b/components/converter/FileInfo.tsx @@ -0,0 +1,210 @@ +'use client'; + +import * as React from 'react'; +import { File, FileVideo, FileAudio, FileImage, Clock, HardDrive, Film, Music } from 'lucide-react'; +import { Card } from '@/components/ui/Card'; +import type { ConversionFormat } from '@/types/conversion'; + +interface FileInfoProps { + file: File; + format: ConversionFormat; +} + +interface FileMetadata { + name: string; + size: string; + type: string; + category: string; + duration?: string; + dimensions?: string; +} + +export function FileInfo({ file, format }: FileInfoProps) { + const [metadata, setMetadata] = React.useState(null); + + React.useEffect(() => { + extractMetadata(file, format); + }, [file, format]); + + const extractMetadata = async (file: File, format: ConversionFormat) => { + const sizeInMB = (file.size / (1024 * 1024)).toFixed(2); + const baseMetadata: FileMetadata = { + name: file.name, + size: file.size < 1024 * 1024 ? `${(file.size / 1024).toFixed(2)} KB` : `${sizeInMB} MB`, + type: format.name, + category: format.category, + }; + + // Try to extract media-specific metadata + if (format.category === 'video' && file.type.startsWith('video/')) { + try { + const video = document.createElement('video'); + video.preload = 'metadata'; + + const promise = new Promise((resolve) => { + video.onloadedmetadata = () => { + const duration = video.duration; + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + resolve({ + ...baseMetadata, + duration: durationStr, + dimensions: `${video.videoWidth} × ${video.videoHeight}`, + }); + + URL.revokeObjectURL(video.src); + }; + + video.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(video.src); + }; + }); + + video.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract video metadata:', error); + } + } else if (format.category === 'audio' && file.type.startsWith('audio/')) { + try { + const audio = document.createElement('audio'); + audio.preload = 'metadata'; + + const promise = new Promise((resolve) => { + audio.onloadedmetadata = () => { + const duration = audio.duration; + const minutes = Math.floor(duration / 60); + const seconds = Math.floor(duration % 60); + const durationStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + resolve({ + ...baseMetadata, + duration: durationStr, + }); + + URL.revokeObjectURL(audio.src); + }; + + audio.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(audio.src); + }; + }); + + audio.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract audio metadata:', error); + } + } else if (format.category === 'image' && file.type.startsWith('image/')) { + try { + const img = new Image(); + + const promise = new Promise((resolve) => { + img.onload = () => { + resolve({ + ...baseMetadata, + dimensions: `${img.width} × ${img.height}`, + }); + + URL.revokeObjectURL(img.src); + }; + + img.onerror = () => { + resolve(baseMetadata); + URL.revokeObjectURL(img.src); + }; + }); + + img.src = URL.createObjectURL(file); + const result = await promise; + setMetadata(result); + return; + } catch (error) { + console.error('Failed to extract image metadata:', error); + } + } + + setMetadata(baseMetadata); + }; + + const getCategoryIcon = () => { + switch (format.category) { + case 'video': + return ; + case 'audio': + return ; + case 'image': + return ; + default: + return ; + } + }; + + if (!metadata) { + return ( + +
+
+
+
+
+
+
+
+ ); + } + + return ( + +
+
{getCategoryIcon()}
+
+

+ {metadata.name} +

+
+ {/* File Size */} +
+ + {metadata.size} +
+ + {/* Type */} +
+ + {metadata.type} +
+ + {/* Duration (for video/audio) */} + {metadata.duration && ( +
+ + {metadata.duration} +
+ )} + + {/* Dimensions */} + {metadata.dimensions && ( +
+ {format.category === 'video' ? ( + + ) : ( + + )} + {metadata.dimensions} +
+ )} +
+
+
+
+ ); +} diff --git a/components/ui/Select.tsx b/components/ui/Select.tsx new file mode 100644 index 0000000..1b23a5f --- /dev/null +++ b/components/ui/Select.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface SelectOption { + value: string; + label: string; +} + +export interface SelectProps extends Omit, 'onChange'> { + options: SelectOption[]; + onValueChange?: (value: string) => void; + label?: string; +} + +const Select = React.forwardRef( + ({ className, options, onValueChange, label, value, ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + onValueChange?.(e.target.value); + }; + + return ( +
+ {label && } + +
+ ); + } +); + +Select.displayName = 'Select'; + +export { Select }; diff --git a/components/ui/Slider.tsx b/components/ui/Slider.tsx new file mode 100644 index 0000000..3d1521b --- /dev/null +++ b/components/ui/Slider.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface SliderProps extends Omit, 'type'> { + min?: number; + max?: number; + step?: number; + value?: number; + onValueChange?: (value: number) => void; + showValue?: boolean; + label?: string; + unit?: string; +} + +const Slider = React.forwardRef( + ({ className, min = 0, max = 100, step = 1, value = 50, onValueChange, showValue = true, label, unit = '', ...props }, ref) => { + const handleChange = (e: React.ChangeEvent) => { + const newValue = parseFloat(e.target.value); + onValueChange?.(newValue); + }; + + return ( +
+ {label && ( +
+ + {showValue && ( + + {value}{unit} + + )} +
+ )} + +
+ ); + } +); + +Slider.displayName = 'Slider'; + +export { Slider };