From a5022fbf8ab2ed04e43aebee6c87d9d05ac24924 Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 12 Jun 2026 20:33:17 +0330 Subject: [PATCH] feat(desktop): modernize download management --- apps/desktop/src-tauri/src/lib.rs | 84 +++--- apps/desktop/src/App.tsx | 12 +- .../src/components/AddDownloadsModal.tsx | 249 +++++++++++------- apps/desktop/src/components/DownloadTable.tsx | 199 +++++++------- .../src/components/PropertiesModal.tsx | 9 +- .../{SettingsModal.tsx => SettingsView.tsx} | 180 ++++++------- apps/desktop/src/components/Sidebar.tsx | 113 ++++---- apps/desktop/src/store/useDownloadStore.ts | 3 +- apps/desktop/src/store/useSettingsStore.ts | 26 +- 9 files changed, 492 insertions(+), 383 deletions(-) rename apps/desktop/src/components/{SettingsModal.tsx => SettingsView.tsx} (91%) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index fe32c12..eb42e70 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -26,7 +26,7 @@ async fn fetch_metadata(url: String, user_agent: Option) -> Result) -> Result Result { let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ytdlp_path = resource_dir.join("binaries").join("yt-dlp"); println!("Resolved yt-dlp path: {:?}", ytdlp_path); - + let output = Command::new(&ytdlp_path) .arg("--version") .output() @@ -139,7 +139,7 @@ async fn test_ytdlp(app_handle: tauri::AppHandle) -> Result { println!("Failed to execute: {}", e); format!("Failed to execute yt-dlp: {}", e) })?; - + println!("yt-dlp execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -158,7 +158,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle) -> Result { let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let aria2c_path = resource_dir.join("binaries").join("aria2c"); println!("Resolved aria2c path: {:?}", aria2c_path); - + let output = Command::new(&aria2c_path) .arg("--version") .output() @@ -166,7 +166,7 @@ async fn test_aria2c(app_handle: tauri::AppHandle) -> Result { println!("Failed to execute: {}", e); format!("Failed to execute aria2c: {}", e) })?; - + println!("aria2c execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -187,7 +187,7 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?; let ffmpeg_path = resource_dir.join("binaries").join("ffmpeg"); println!("Resolved ffmpeg path: {:?}", ffmpeg_path); - + let output = Command::new(&ffmpeg_path) .arg("-version") .output() @@ -195,7 +195,7 @@ async fn test_ffmpeg(app_handle: tauri::AppHandle) -> Result { println!("Failed to execute: {}", e); format!("Failed to execute ffmpeg: {}", e) })?; - + println!("ffmpeg execution finished with status: {}", output.status); if output.status.success() { let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -350,7 +350,7 @@ async fn start_download( .arg("--check-certificate=false") .arg(format!("--dir={}", resolved_dest.to_string_lossy())) .arg(format!("--out={}", filename)); - + if let Some(conn) = connections { cmd.arg(format!("--split={}", conn)); cmd.arg(format!("--max-connection-per-server={}", conn)); @@ -388,7 +388,7 @@ async fn start_download( cmd.arg(format!("--all-proxy={}", p)); } } - + cmd.arg(&url); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::null()); @@ -396,14 +396,14 @@ async fn start_download( // cmd.kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn aria2c: {}", e))?; - + let pid = child.id().unwrap_or(0); state.tasks.lock().unwrap().insert(id.clone(), pid); let stdout = child.stdout.take().unwrap(); let app_handle_clone = app_handle.clone(); let id_clone = id.clone(); - + tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); let percentage_re = Regex::new(r"\((\d+)%\)").unwrap(); @@ -420,17 +420,17 @@ async fn start_download( .and_then(|cap| cap.get(1)) .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0.0) / 100.0; - + let speed = speed_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); - + let eta = eta_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); - + let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { id: id_clone.clone(), fraction, @@ -490,9 +490,15 @@ async fn start_media_download( if !resolved_dest.exists() { let _ = std::fs::create_dir_all(&resolved_dest); } - + let out_path = resolved_dest.join(&filename); + let total_tracks: f64 = if let Some(ref format) = format_selector { + if format.contains('+') { 2.0 } else { 1.0 } + } else { + 1.0 + }; + let mut cmd = AsyncCommand::new(&ytdlp_path); cmd.arg("--newline") .arg("--ffmpeg-location") @@ -502,7 +508,7 @@ async fn start_media_download( .arg("--retries").arg("3") .arg("--extractor-retries").arg("3") .arg("-o").arg(out_path.to_string_lossy().to_string()); - + if let Some(format) = format_selector { cmd.arg("-f").arg(format); // If the filename implies an audio format, use it as audio output @@ -523,7 +529,7 @@ async fn start_media_download( } } } - + cmd.arg(&url); cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); // Also pipe stderr for better error reporting @@ -535,14 +541,17 @@ async fn start_media_download( let stdout = child.stdout.take().unwrap(); let app_handle_clone = app_handle.clone(); let id_clone = id.clone(); - + // yt-dlp parsing regex let pct_re = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%").unwrap(); let spd_re = Regex::new(r"at\s+([^\s]+)").unwrap(); let eta_re = Regex::new(r"ETA\s+([^\s]+)").unwrap(); - + tokio::spawn(async move { let mut reader = BufReader::new(stdout).lines(); + let mut current_track: f64 = 0.0; + let mut last_fraction: f64 = 0.0; + loop { tokio::select! { line_result = reader.next_line() => { @@ -553,20 +562,27 @@ async fn start_media_download( .and_then(|cap| cap.get(1)) .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0.0) / 100.0; - + + if fraction < last_fraction && (last_fraction - fraction) > 0.5 { + current_track += 1.0; + } + last_fraction = fraction; + + let overall_fraction = ((current_track + fraction) / total_tracks).min(1.0); + let speed = spd_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); - + let eta = eta_re.captures(&line) .and_then(|cap| cap.get(1)) .map(|m| m.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); - + let _ = app_handle_clone.emit("download-progress", DownloadProgressEvent { id: id_clone.clone(), - fraction, + fraction: overall_fraction, speed, eta, }); @@ -619,7 +635,7 @@ async fn pause_download(state: tauri::State<'_, AppState>, id: String) -> Result } #[tauri::command] -fn update_dock_badge(app_handle: tauri::AppHandle, count: i32) { +fn update_dock_badge(_app_handle: tauri::AppHandle, count: i32) { #[cfg(target_os = "macos")] { let label = if count > 0 { count.to_string() } else { "".to_string() }; @@ -661,7 +677,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result Result = None; let mut max_match_len = 0; - + for disk in disks.list() { let mount_point = disk.mount_point(); if resolved_dest.starts_with(mount_point) { @@ -687,7 +703,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result state.updateDownload); const theme = useSettingsStore(state => state.theme); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); + const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); useEffect(() => { @@ -95,10 +96,13 @@ function App() { return (
- {isSidebarVisible && } - + {isSidebarVisible && { setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />} + {activeView === 'downloads' ? ( + + ) : ( + + )} -
); diff --git a/apps/desktop/src/components/AddDownloadsModal.tsx b/apps/desktop/src/components/AddDownloadsModal.tsx index 91f1b0c..1ba111b 100644 --- a/apps/desktop/src/components/AddDownloadsModal.tsx +++ b/apps/desktop/src/components/AddDownloadsModal.tsx @@ -1,10 +1,22 @@ import { useState, useEffect } from 'react'; import { useDownloadStore } from '../store/useDownloadStore'; import { useSettingsStore } from '../store/useSettingsStore'; -import { X, FolderPlus, Settings, Shield, Globe, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, CheckCircle2, Play, ChevronDown, ChevronRight, Video } from 'lucide-react'; +import { DownloadCategory } from '../store/useDownloadStore'; +import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { invoke } from '@tauri-apps/api/core'; +function determineCategory(fileName: string): DownloadCategory { + const ext = fileName.split('.').pop()?.toLowerCase() || ''; + if (['mp4', 'mkv', 'webm', 'avi', 'mov', 'flv'].includes(ext)) return 'Video'; + if (['mp3', 'm4a', 'wav', 'flac', 'ogg', 'aac'].includes(ext)) return 'Audio'; + if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents'; + if (['exe', 'dmg', 'pkg', 'app', 'apk', 'deb', 'rpm'].includes(ext)) return 'Apps'; + if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'tiff'].includes(ext)) return 'Images'; + if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return 'Archives'; + return 'Other'; +} + interface RawMediaFormat { format_id?: string; ext?: string; @@ -17,6 +29,17 @@ interface RawMediaFormat { filesize_approx?: number; } +interface ParsedDownloadItem { + url: string; + file: string; + size?: string; + sizeBytes?: number; + status?: string; + isMedia?: boolean; + formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[]; + selectedFormat?: number; +} + const isVideo = (f: RawMediaFormat) => { const vcodec = f.vcodec?.toLowerCase(); return vcodec && vcodec !== 'none'; @@ -32,7 +55,7 @@ const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0; const matchesHeight = (f: RawMediaFormat, height: number | null) => { if (height === null) return true; - + const note = f.format_note || ""; if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true; if (height === 1440 && note.includes("1440p")) return true; @@ -137,7 +160,7 @@ const parseMediaFormats = (jsonStr: string) => { { h: 360, name: "360p" } ]; - const availableResolutions = standardResolutions.filter(res => + const availableResolutions = standardResolutions.filter(res => rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h)) ); @@ -153,7 +176,7 @@ const parseMediaFormats = (jsonStr: string) => { if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue; const est = estimatedVideoBytes(rawFormats, q.h, c.ext); const filter = q.h ? `[height<=${q.h}]` : ''; - + let selector = `bestvideo${filter}+bestaudio/best${filter}`; if (c.ext === 'mp4') { selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`; @@ -214,35 +237,36 @@ const parseMediaFormats = (jsonStr: string) => { } }; +const MEDIA_DOMAINS = ['youtube.com', 'youtu.be', 'twitter.com', 'x.com', 'twitch.tv', 'vimeo.com', 'instagram.com', 'tiktok.com', 'reddit.com', 'soundcloud.com', 'facebook.com']; +const isMediaUrl = (url: string) => { + try { + const u = new URL(url); + return MEDIA_DOMAINS.some(d => u.hostname.includes(d)); + } catch { + return false; + } +}; + export const AddDownloadsModal = () => { const { isAddModalOpen, toggleAddModal, addDownload } = useDownloadStore(); const { defaultDownloadPath } = useSettingsStore(); - + const [urls, setUrls] = useState(''); - const [extractMedia, setExtractMedia] = useState(false); - const [parsedItems, setParsedItems] = useState<{ - url: string, - file: string, - size?: string, - sizeBytes?: number, - status?: string, - isMedia?: boolean, - formats?: { name: string, selector: string, ext: string, detail: string, type: string, bytes: number }[], - selectedFormat?: number - }[]>([]); - + const [selectedItemIndex, setSelectedItemIndex] = useState(null); + const [parsedItems, setParsedItems] = useState([]); + // Right Form const [saveLocation, setSaveLocation] = useState(defaultDownloadPath); const [connections, setConnections] = useState(16); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimit, setSpeedLimit] = useState('1024'); const [freeSpace, setFreeSpace] = useState('Unknown'); - + const [useAuth, setUseAuth] = useState(false); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - + const [advancedExpanded, setAdvancedExpanded] = useState(false); const [checksumEnabled, setChecksumEnabled] = useState(false); const [checksumAlgo, setChecksumAlgo] = useState('SHA-256'); @@ -256,7 +280,7 @@ export const AddDownloadsModal = () => { setSaveLocation(defaultDownloadPath); setUrls(''); setParsedItems([]); - setExtractMedia(false); + setSelectedItemIndex(null); } }, [isAddModalOpen, defaultDownloadPath]); @@ -270,35 +294,42 @@ export const AddDownloadsModal = () => { // Metadata parser useEffect(() => { const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0); - + // Immediately display items in loading state - const initialItems = lines.map(url => { + const initialItems: ParsedDownloadItem[] = lines.map(url => { let fallbackFile = 'URL'; try { fallbackFile = new URL(url).pathname.split('/').pop() || 'download'; } catch {} - return { url, file: fallbackFile, size: '-', status: 'Loading' }; + return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) }; }); setParsedItems(initialItems); - if (lines.length === 0) return; + if (lines.length === 0) { + setSelectedItemIndex(null); + return; + } else if (selectedItemIndex === null || selectedItemIndex >= lines.length) { + setSelectedItemIndex(0); + } const timer = setTimeout(async () => { const updatedItems = [...initialItems]; + let firstReadyIndex: number | null = null; + for (let i = 0; i < lines.length; i++) { const url = lines[i]; try { new URL(url); - if (extractMedia) { + if (isMediaUrl(url)) { const { mediaCookieSource } = useSettingsStore.getState(); const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null; const jsonStr = await invoke('fetch_media_metadata', { url, cookieBrowser: browserArg }); const mediaData = parseMediaFormats(jsonStr); if (mediaData && mediaData.formats.length > 0) { - updatedItems[i] = { - url, - file: `${mediaData.title}.${mediaData.formats[0].ext}`, - size: mediaData.formats[0].detail || 'Unknown (Media)', - sizeBytes: mediaData.formats[0].bytes, + updatedItems[i] = { + url, + file: `${mediaData.title}.${mediaData.formats[0].ext}`, + size: mediaData.formats[0].detail || 'Unknown (Media)', + sizeBytes: mediaData.formats[0].bytes, status: 'Ready', isMedia: true, formats: mediaData.formats, @@ -311,17 +342,22 @@ export const AddDownloadsModal = () => { const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', { url }); updatedItems[i] = { url, file: meta.filename, size: meta.size, sizeBytes: meta.size_bytes, status: 'Ready' }; } + if (firstReadyIndex === null) firstReadyIndex = i; } catch (e) { console.error("Meta fetch failed", e); updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' }; } setParsedItems([...updatedItems]); } + + if (firstReadyIndex !== null) { + setSelectedItemIndex(firstReadyIndex); + } }, 400); return () => clearTimeout(timer); - }, [urls, extractMedia]); // Re-fetch if extractMedia toggles - + }, [urls]); // Re-fetch only on urls change + if (!isAddModalOpen) return null; const handleBrowse = async () => { @@ -364,7 +400,7 @@ export const AddDownloadsModal = () => { const id = crypto.randomUUID(); let finalFile = item.file; let formatSelector = undefined; - + if (item.isMedia && item.formats && item.selectedFormat !== undefined) { const selectedFormat = item.formats[item.selectedFormat]; formatSelector = selectedFormat.selector; @@ -378,7 +414,7 @@ export const AddDownloadsModal = () => { url: item.url, fileName: finalFile, status: startImmediately ? 'queued' : 'paused', - category: item.isMedia ? 'Video' : 'Other', + category: determineCategory(finalFile), dateAdded: new Date().toISOString(), connections: Number(connections), speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined, @@ -407,8 +443,8 @@ export const AddDownloadsModal = () => { ); const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0); - const requiredStr = requiredBytes > 0 - ? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB` + const requiredStr = requiredBytes > 0 + ? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB` : requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB` : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`) : 'Unknown'; @@ -416,32 +452,22 @@ export const AddDownloadsModal = () => { return (
- + {/* Main Content Split */}
- + {/* Left Column: URLs and Preview */}
- +
Download Links
-
-