diff --git a/package.json b/package.json index 3d38ad5..22a0446 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0697d9..c9430e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-progress': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1083,6 +1086,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -4586,6 +4602,24 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 355ff0c..6569cb3 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -20,15 +20,21 @@ export const sanitizeFilenameTemplate = (template: string): string => { export const resolveVideoFormatSelector = (options: DownloadOptions): string => { const format = options.format const audioFormat = options.audioFormat + const audioFormatIds = (options.audioFormatIds ?? []).filter((id) => id.trim() !== '') if (format && audioFormat === '') { return format } - if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) { + if (format && (format.includes('/') || format.includes('+') || format.includes('['))) { return format } + if (audioFormatIds.length > 0) { + const baseVideo = format && format !== 'best' ? format : 'bestvideo*' + return `${baseVideo}+${audioFormatIds.join('+')}` + } + if (!format || format === 'best') { if (audioFormat === 'none') { return 'bestvideo+none' @@ -77,15 +83,16 @@ export const buildDownloadArgs = ( if (options.type === 'video') { const formatSelector = resolveVideoFormatSelector(options) args.push('-f', formatSelector) + if (options.audioFormatIds && options.audioFormatIds.length > 0) { + args.push('--audio-multistreams') + } else if (formatSelector.includes('mergeall')) { + args.push('--audio-multistreams') + } // Let yt-dlp automatically choose the best merge format (mkv/webm/mp4) // based on codec compatibility. Forcing MP4 can cause failures // when codecs are incompatible (e.g., VP9+Opus requires mkv/webm) } else if (options.type === 'audio') { args.push('-f', resolveAudioFormatSelector(options)) - } else if (options.type === 'extract') { - args.push('-x') - args.push('--audio-format', options.extractFormat || 'mp3') - args.push('--audio-quality', options.extractQuality || '5') } // Time range @@ -100,11 +107,9 @@ export const buildDownloadArgs = ( const embedChapters = settings.embedChapters // Subtitles - if (options.downloadSubs || embedSubs) { + if (embedSubs) { args.push('--sub-langs', 'all') - } - - if (options.downloadSubs) { + } else { args.push('--write-subs') } diff --git a/src/main/download-engine/format-utils.ts b/src/main/download-engine/format-utils.ts index c9e9b6e..335c98e 100644 --- a/src/main/download-engine/format-utils.ts +++ b/src/main/download-engine/format-utils.ts @@ -194,7 +194,7 @@ export const resolveSelectedFormat = ( return selectVideoFormatForPreset(videoFormats, preset) } - if (options.type === 'audio' || options.type === 'extract') { + if (options.type === 'audio') { const audioFormats = formats.filter( (format) => !!format.acodec && diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 1e4a0c6..72893a9 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -80,6 +80,59 @@ const isLikelyChannelUrl = (url: string): boolean => { return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized) } +const clampPercent = (value?: number): number => { + const normalized = typeof value === 'number' ? value : 0 + if (Number.isNaN(normalized)) { + return 0 + } + return Math.min(100, Math.max(0, normalized)) +} + +const estimateProgressParts = (options: DownloadOptions): number => { + if (options.type === 'audio') { + return 1 + } + + const audioFormatCount = options.audioFormatIds?.filter((id) => id.trim() !== '').length ?? 0 + if (audioFormatCount > 0) { + return 1 + audioFormatCount + } + + const selector = options.format?.trim() + if (!selector) { + return 2 + } + + const primary = selector.split('/')[0]?.trim() + if (!primary) { + return 2 + } + + const parts = primary + .split('+') + .map((part) => part.trim()) + .filter((part) => part !== '') + + if (parts.length <= 1) { + return 1 + } + + if (parts.some((part) => part === 'none')) { + return 1 + } + + return parts.length +} + +const isMuxedFormat = (format?: VideoFormat): boolean => { + if (!format) { + return false + } + const hasVideo = !!format.vcodec && format.vcodec !== 'none' + const hasAudio = !!format.acodec && format.acodec !== 'none' + return hasVideo && hasAudio +} + const resolveAutoPlaylistDownloadPath = ( basePath: string, info: PlaylistInfo, @@ -591,6 +644,9 @@ class DownloadEngine extends EventEmitter { let actualFormat: string | null = null let videoInfo: VideoInfo | undefined let lastKnownOutputPath: string | undefined + let totalParts = estimateProgressParts(options) + let completedParts = 0 + let lastPercent = 0 // First, get detailed video info to capture basic metadata and formats try { @@ -604,6 +660,14 @@ class DownloadEngine extends EventEmitter { actualFormat = selectedFormat.ext || actualFormat } + if ( + options.type === 'video' && + (!options.audioFormatIds || options.audioFormatIds.length === 0) && + isMuxedFormat(selectedFormat) + ) { + totalParts = 1 + } + this.updateDownloadInfo(id, { title: info.title, thumbnail: info.thumbnail, @@ -785,8 +849,23 @@ class DownloadEngine extends EventEmitter { : downloadedBytes } + const normalizedPercent = clampPercent(progress.percent) + if ( + totalParts > 1 && + lastPercent >= 90 && + normalizedPercent <= 10 && + completedParts < totalParts - 1 + ) { + completedParts += 1 + } + lastPercent = normalizedPercent + const mergedPercent = + totalParts > 1 + ? ((completedParts + normalizedPercent / 100) / totalParts) * 100 + : normalizedPercent + const downloadProgress: DownloadProgress = { - percent: progress.percent || 0, + percent: Math.min(100, mergedPercent), currentSpeed: progress.currentSpeed || '', eta: progress.eta || '', downloaded: progress.downloaded || '', @@ -844,7 +923,8 @@ class DownloadEngine extends EventEmitter { // based on codec compatibility, so we should use actualFormat when available let extension: string if (options.type === 'audio') { - extension = options.extractFormat || 'mp3' + // Use format extension from yt-dlp output (actualFormat contains the extension) + extension = actualFormat || 'm4a' } else if (willMerge) { // For merged files, yt-dlp auto-selects format (mkv/webm/mp4) // Use actualFormat if available, otherwise default to mkv (most compatible) diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index cc47e2b..268e5ae 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -1,12 +1,6 @@ import { Button } from '@renderer/components/ui/button' import { Checkbox } from '@renderer/components/ui/checkbox' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTrigger -} from '@renderer/components/ui/dialog' +import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog' import { Input } from '@renderer/components/ui/input' import { Label } from '@renderer/components/ui/label' import { ScrollArea } from '@renderer/components/ui/scroll-area' @@ -19,13 +13,12 @@ import { } from '@renderer/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' -import { popularSites } from '@renderer/data/popularSites' import { cn } from '@renderer/lib/utils' -import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types' +import type { AppSettings, OneClickQualityPreset, PlaylistInfo, VideoFormat } from '@shared/types' import { useAtom, useSetAtom } from 'jotai' import { AlertCircle, FolderOpen, List, Loader2, Plus, Video } from 'lucide-react' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ipcEvents, ipcServices } from '../../lib/ipc' @@ -42,7 +35,6 @@ import { videoInfoErrorAtom, videoInfoLoadingAtom } from '../../store/video' -import { AdvancedOptions } from '../video/AdvancedOptions' import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard' const qualityPresetToVideoHeight: Record = { @@ -73,6 +65,15 @@ const dedupe = (candidates: Array): string[] => { return result } +const isLikelyUrl = (value: string): boolean => { + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + const getQualityPreset = (settings: AppSettings): OneClickQualityPreset => settings.oneClickQuality ?? 'best' @@ -86,6 +87,60 @@ const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => { return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio']) } +const isAudioOnlyFormat = (format: VideoFormat): boolean => + !!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none') + +const isHlsFormat = (format: VideoFormat): boolean => + format.protocol === 'm3u8' || format.protocol === 'm3u8_native' + +const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat): number => { + const aQuality = a.tbr ?? a.quality ?? 0 + const bQuality = b.tbr ?? b.quality ?? 0 + if (aQuality !== bQuality) { + return bQuality - aQuality + } + const aHasSize = !!(a.filesize || a.filesize_approx) + const bHasSize = !!(b.filesize || b.filesize_approx) + if (aHasSize !== bHasSize) { + return bHasSize ? 1 : -1 + } + return 0 +} + +const pickBestAudioFormatsByLanguage = (formats: VideoFormat[]): string[] => { + const audioFormats = formats.filter(isAudioOnlyFormat) + if (audioFormats.length === 0) { + return [] + } + + const nonHls = audioFormats.filter((format) => !isHlsFormat(format)) + const candidates = nonHls.length > 0 ? nonHls : audioFormats + + const grouped = new Map() + for (const format of candidates) { + const language = format.language?.trim() || 'und' + const existing = grouped.get(language) + if (existing) { + existing.push(format) + } else { + grouped.set(language, [format]) + } + } + + const sortedLanguages = Array.from(grouped.entries()).sort(([a], [b]) => { + if (a === 'und') return 1 + if (b === 'und') return -1 + return a.localeCompare(b) + }) + + return sortedLanguages + .map(([, languageFormats]) => { + const sorted = [...languageFormats].sort(sortAudioFormatsByQuality) + return sorted[0]?.format_id + }) + .filter((id): id is string => !!id) +} + const buildVideoFormatPreference = (settings: AppSettings): string => { const preset = getQualityPreset(settings) @@ -133,7 +188,10 @@ interface DownloadDialogProps { onOpenSettings?: () => void } -export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: DownloadDialogProps) { +export function DownloadDialog({ + onOpenSupportedSites: _onOpenSupportedSites, + onOpenSettings: _onOpenSettings +}: DownloadDialogProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom) @@ -148,35 +206,20 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa const removeDownload = useSetAtom(removeDownloadAtom) const [url, setUrl] = useState('') - const inputRef = useRef(null) const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single') - const inlinePreviewSites = popularSites - .slice(0, 3) - .map((site) => t(`sites.popular.${site.id}.label`)) - .join(', ') // VideoInfoCard state management const [videoInfoCardState, setVideoInfoCardState] = useState({ title: '', activeTab: 'video', selectedVideoFormat: '', - selectedAudioForVideo: '', selectedAudioFormat: '', - startTime: '', - endTime: '', - downloadSubs: false, - customDownloadPath: '', - audioExtractor: { - extractFormat: 'mp3', - extractQuality: '5' - } + customDownloadPath: '' }) // Playlist states - const playlistUrlId = useId() const downloadTypeId = useId() const advancedOptionsId = useId() - const singleVideoAdvancedOptionsId = useId() const [playlistUrl, setPlaylistUrl] = useState('') const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video') const [startIndex, setStartIndex] = useState('1') @@ -188,7 +231,7 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa const [playlistPreviewError, setPlaylistPreviewError] = useState(null) const playlistBusy = playlistPreviewLoading || playlistDownloadLoading const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false) - const [singleVideoAdvancedOptionsOpen, setSingleVideoAdvancedOptionsOpen] = useState(false) + const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set()) const computePlaylistRange = useCallback( (info: PlaylistInfo) => { @@ -210,12 +253,17 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa if (!playlistInfo) { return [] } + // If manual selection is active (has selected entries), use that + if (selectedEntryIds.size > 0) { + return playlistInfo.entries.filter((entry) => selectedEntryIds.has(entry.id)) + } + // Otherwise, use range-based selection const range = computePlaylistRange(playlistInfo) const previewEnd = range.end ?? playlistInfo.entryCount return playlistInfo.entries.filter( (entry) => entry.index >= range.start && entry.index <= previewEnd ) - }, [playlistInfo, computePlaylistRange]) + }, [playlistInfo, computePlaylistRange, selectedEntryIds]) const syncHistoryItem = useCallback( async (id: string) => { @@ -266,6 +314,7 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa setPlaylistInfo(null) setPlaylistPreviewError(null) setPlaylistCustomDownloadPath('') + setSelectedEntryIds(new Set()) // Wait for dialog to open, then fetch playlist info setTimeout(async () => { @@ -478,103 +527,149 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa await fetchVideoInfo(url.trim()) }, [url, fetchVideoInfo, t]) - const handlePasteUrl = useCallback(async () => { + const handleAutoDetectClipboard = useCallback(async () => { + if (!navigator.clipboard?.readText) { + return + } + + let text = '' try { - const text = await navigator.clipboard.readText() - if (!text.trim()) { + text = await navigator.clipboard.readText() + } catch { + return + } + + const trimmedUrl = text.trim() + if (!trimmedUrl) { + return + } + if (!isLikelyUrl(trimmedUrl)) { + toast.error(t('errors.invalidUrl')) + return + } + + if (activeTab === 'playlist') { + if (playlistBusy || playlistUrl.trim()) { + return + } + + setPlaylistUrl(trimmedUrl) + setPlaylistInfo(null) + setPlaylistPreviewError(null) + setPlaylistCustomDownloadPath('') + setSelectedEntryIds(new Set()) + + setPlaylistPreviewError(null) + setPlaylistPreviewLoading(true) + try { + const info = await ipcServices.download.getPlaylistInfo(trimmedUrl) + setPlaylistInfo(info) + if (info.entryCount === 0) { + toast.error(t('playlist.noEntries')) + return + } + toast.success(t('playlist.foundVideos', { count: info.entryCount })) + } catch (error) { + console.error('Failed to fetch playlist info:', error) + const message = + error instanceof Error && error.message ? error.message : t('playlist.previewFailed') + setPlaylistPreviewError(message) + setPlaylistInfo(null) + toast.error(t('playlist.previewFailed')) + } finally { + setPlaylistPreviewLoading(false) + } + return + } + + if (loading || url.trim()) { + return + } + + setUrl(trimmedUrl) + + if (settings.oneClickDownload) { + await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) + setOpen(false) + return + } + + await fetchVideoInfo(trimmedUrl) + }, [ + activeTab, + fetchVideoInfo, + loading, + playlistBusy, + playlistUrl, + settings.oneClickDownload, + startOneClickDownload, + t, + url + ]) + + const handleOpenDialog = useCallback(async () => { + if (settings.oneClickDownload) { + if (!navigator.clipboard?.readText) { + toast.error(t('errors.pasteFromClipboard')) + return + } + + let text = '' + try { + text = await navigator.clipboard.readText() + } catch { + toast.error(t('errors.pasteFromClipboard')) + return + } + + const trimmedUrl = text.trim() + if (!trimmedUrl) { toast.error(t('errors.clipboardEmpty')) return } - const trimmedUrl = text.trim() - setUrl(trimmedUrl) - inputRef.current?.focus() - // Auto-fetch video info after pasting - if (settings.oneClickDownload) { - await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) - setOpen(false) // Close dialog after download starts - } else { - await fetchVideoInfo(trimmedUrl) + if (!isLikelyUrl(trimmedUrl)) { + toast.error(t('errors.invalidUrl')) + return } - } catch (error) { - console.error('Failed to paste URL:', error) - toast.error(t('errors.pasteFromClipboard')) + + await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) + return } - }, [t, settings, startOneClickDownload, fetchVideoInfo]) - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleFetchVideo() - } - }, - [handleFetchVideo] - ) + // Check clipboard before opening dialog + if (!navigator.clipboard?.readText) { + setOpen(true) + return + } - const handlePaste = useCallback( - async (e: React.ClipboardEvent) => { - // Let the default paste behavior happen first - setTimeout(async () => { - const pastedText = e.clipboardData.getData('text') - if (!pastedText.trim()) return + let text = '' + try { + text = await navigator.clipboard.readText() + } catch { + setOpen(true) + return + } - const trimmedUrl = pastedText.trim() - // Only auto-fetch if the URL actually changed - if (trimmedUrl !== url) { - if (settings.oneClickDownload) { - await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) - setOpen(false) // Close dialog after download starts - } else { - await fetchVideoInfo(trimmedUrl) - } - } - }, 0) - }, - [url, settings, startOneClickDownload, fetchVideoInfo] - ) + const trimmedUrl = text.trim() + if (!trimmedUrl) { + setOpen(true) + return + } - const handlePlaylistPaste = useCallback( - async (e: React.ClipboardEvent) => { - if (playlistBusy) return - // Let the default paste behavior happen first - setTimeout(async () => { - const pastedText = e.clipboardData.getData('text') - if (!pastedText.trim()) return + if (!isLikelyUrl(trimmedUrl)) { + toast.error(t('errors.invalidUrl')) + return + } - const trimmed = pastedText.trim() - // Only auto-preview if the URL actually changed - if (trimmed !== playlistUrl && trimmed) { - setPlaylistUrl(trimmed) - setPlaylistInfo(null) - setPlaylistPreviewError(null) - setPlaylistCustomDownloadPath('') + // If it's a valid URL, open dialog and let handleAutoDetectClipboard process it + setOpen(true) + }, [settings.oneClickDownload, startOneClickDownload, t]) - // Auto-preview playlist after pasting - setPlaylistPreviewError(null) - setPlaylistPreviewLoading(true) - try { - const info = await ipcServices.download.getPlaylistInfo(trimmed) - setPlaylistInfo(info) - if (info.entryCount === 0) { - toast.error(t('playlist.noEntries')) - return - } - toast.success(t('playlist.foundVideos', { count: info.entryCount })) - } catch (error) { - console.error('Failed to fetch playlist info:', error) - const message = - error instanceof Error && error.message ? error.message : t('playlist.previewFailed') - setPlaylistPreviewError(message) - setPlaylistInfo(null) - toast.error(t('playlist.previewFailed')) - } finally { - setPlaylistPreviewLoading(false) - } - } - }, 0) - }, - [playlistUrl, playlistBusy, t] - ) + useEffect(() => { + if (!open) return + void handleAutoDetectClipboard() + }, [open, handleAutoDetectClipboard]) const handleOneClickDownload = useCallback(async () => { await startOneClickDownload(url, { clearInput: true }) @@ -582,49 +677,6 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa }, [startOneClickDownload, url]) // Playlist handlers - const handlePastePlaylistUrl = useCallback(async () => { - if (playlistBusy) return - try { - const text = await navigator.clipboard.readText() - if (!text.trim()) { - toast.error(t('errors.clipboardEmpty')) - return - } - const trimmed = text.trim() - setPlaylistUrl(trimmed) - setPlaylistInfo(null) - setPlaylistPreviewError(null) - setPlaylistCustomDownloadPath('') - - // Auto-preview playlist after pasting - if (trimmed) { - setPlaylistPreviewError(null) - setPlaylistPreviewLoading(true) - try { - const info = await ipcServices.download.getPlaylistInfo(trimmed) - setPlaylistInfo(info) - if (info.entryCount === 0) { - toast.error(t('playlist.noEntries')) - return - } - toast.success(t('playlist.foundVideos', { count: info.entryCount })) - } catch (error) { - console.error('Failed to fetch playlist info:', error) - const message = - error instanceof Error && error.message ? error.message : t('playlist.previewFailed') - setPlaylistPreviewError(message) - setPlaylistInfo(null) - toast.error(t('playlist.previewFailed')) - } finally { - setPlaylistPreviewLoading(false) - } - } - } catch (error) { - console.error('Failed to paste URL:', error) - toast.error(t('errors.pasteFromClipboard')) - } - }, [playlistBusy, t]) - const handleSelectPlaylistDirectory = useCallback(async () => { if (playlistBusy) return try { @@ -649,6 +701,7 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa const trimmedUrl = playlistUrl.trim() const info = await ipcServices.download.getPlaylistInfo(trimmedUrl) setPlaylistInfo(info) + setSelectedEntryIds(new Set()) if (info.entryCount === 0) { toast.error(t('playlist.noEntries')) return @@ -689,12 +742,36 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa return } - const range = computePlaylistRange(info) - const previewEnd = range.end ?? info.entryCount + // Use manual selection if available, otherwise use range + let startIndex: number | undefined + let endIndex: number | undefined - if (previewEnd < range.start || previewEnd === 0) { - toast.error(t('playlist.noEntriesInRange')) - return + if (selectedEntryIds.size > 0) { + // Manual selection mode: find min and max indices + const selectedIndices = Array.from(selectedEntryIds) + .map((id) => info.entries.find((e) => e.id === id)?.index) + .filter((idx): idx is number => idx !== undefined) + .sort((a, b) => a - b) + + if (selectedIndices.length === 0) { + toast.error(t('playlist.noEntriesSelected')) + return + } + + startIndex = selectedIndices[0] + endIndex = selectedIndices[selectedIndices.length - 1] + } else { + // Range-based selection + const range = computePlaylistRange(info) + const previewEnd = range.end ?? info.entryCount + + if (previewEnd < range.start || previewEnd === 0) { + toast.error(t('playlist.noEntriesInRange')) + return + } + + startIndex = range.start + endIndex = range.end } const format = @@ -706,8 +783,8 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa url: trimmedUrl, type: downloadType, format, - startIndex: range.start, - endIndex: range.end, + startIndex, + endIndex, customDownloadPath: playlistCustomDownloadPath.trim() || undefined }) @@ -750,7 +827,8 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa settings, addDownload, t, - playlistCustomDownloadPath + playlistCustomDownloadPath, + selectedEntryIds ]) // Update videoInfoCardState when videoInfo changes @@ -765,7 +843,7 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa // Handle video download from VideoInfoCard const handleVideoDownload = useCallback( - async (type: 'video' | 'audio' | 'extract') => { + async (type: 'video' | 'audio') => { if (!videoInfo) return const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` @@ -775,7 +853,7 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa url: videoInfo.webpage_url || '', title: videoInfoCardState.title, thumbnail: videoInfo.thumbnail, - type: type === 'extract' ? 'audio' : type, + type, status: 'pending' as const, progress: { percent: 0 }, duration: videoInfo.duration, @@ -785,24 +863,18 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa createdAt: Date.now() } + const audioFormatIds = + type === 'video' ? pickBestAudioFormatsByLanguage(videoInfo.formats || []) : undefined + const options = { url: videoInfo.webpage_url || '', type, format: type === 'video' ? videoInfoCardState.selectedVideoFormat || undefined - : type === 'extract' - ? undefined - : videoInfoCardState.selectedAudioFormat || undefined, - audioFormat: - type === 'video' ? videoInfoCardState.selectedAudioForVideo || undefined : undefined, - extractFormat: - type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined, - extractQuality: - type === 'extract' ? videoInfoCardState.audioExtractor.extractQuality : undefined, - startTime: videoInfoCardState.startTime || undefined, - endTime: videoInfoCardState.endTime || undefined, - downloadSubs: videoInfoCardState.downloadSubs, + : videoInfoCardState.selectedAudioFormat || undefined, + audioFormat: type === 'video' ? 'best' : undefined, + audioFormatIds: audioFormatIds && audioFormatIds.length > 0 ? audioFormatIds : undefined, customDownloadPath: videoInfoCardState.customDownloadPath.trim() || undefined } @@ -837,21 +909,12 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa // Reset single video states setUrl('') setActiveTab('single') - setSingleVideoAdvancedOptionsOpen(false) setVideoInfoCardState({ title: '', activeTab: 'video', selectedVideoFormat: '', - selectedAudioForVideo: '', selectedAudioFormat: '', - startTime: '', - endTime: '', - downloadSubs: false, - customDownloadPath: '', - audioExtractor: { - extractFormat: 'mp3', - extractQuality: '5' - } + customDownloadPath: '' }) // Reset playlist states @@ -861,16 +924,21 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa setPlaylistCustomDownloadPath('') setStartIndex('1') setEndIndex('') + setSelectedEntryIds(new Set()) } }, [open]) return ( - - - + - - {/* Single Video Download Tab */} - -
-
-
- setUrl(e.target.value)} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - disabled={loading} - className="h-10" - /> - {loading && ( -
- -
- )} -
- -
- - {/* Sub-info section */} -
-
- {t('sites.homeInlineDescription', { sites: inlinePreviewSites })} - + {/* Single Video Download Tab */} + + {/* Error Display */} + {error && ( +
+
+ +
+

+ {t('errors.fetchInfoFailed')} +

+

{error}

- - {/* One-click download indicator */} - {settings.oneClickDownload && ( -
-
-
- {t('download.oneClickDownloadEnabled')} - {onOpenSettings && ( - - )} -
-
-
- )} - - {/* Error Display */} - {error && ( -
-
- -
-

- {t('errors.fetchInfoFailed')} -

-

{error}

-
-
-
- )} - - {/* Video Info and Download Options */} - {videoInfo && !loading && ( - <> - - setVideoInfoCardState((prev) => ({ ...prev, ...updates })) - } - onTabChange={(tab) => { - setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab })) - }} - /> - - {/* Download Location - Single Video */} -
- -
-
- -
- -
-
-
- - {videoInfoCardState.customDownloadPath && ( - - )} -
-
-
- - )} - - {/* Advanced Options Content - Single Video */} - {videoInfo && !loading && ( -
-
-
- - setVideoInfoCardState((prev) => ({ ...prev, startTime: value })) - } - onEndTimeChange={(value) => - setVideoInfoCardState((prev) => ({ ...prev, endTime: value })) - } - onDownloadSubsChange={(value) => - setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value })) - } - showAccordion={false} - /> -
-
-
- )}
-
+ )} - {/* Playlist Download Tab */} - + {/* Video Info and Download Options */} + {(loading || videoInfo) && ( + + setVideoInfoCardState((prev) => ({ ...prev, ...updates })) + } + onTabChange={(tab) => { + setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab })) + }} + /> + )} + + + {/* Playlist Download Tab */} + +
-
-
- { - setPlaylistUrl(e.target.value) - setPlaylistInfo(null) - setPlaylistPreviewError(null) - setPlaylistCustomDownloadPath('') - }} - onPaste={handlePlaylistPaste} - disabled={playlistBusy} - /> - {playlistPreviewLoading && ( -
- -
- )} -
- -
- {/* Preview State */} {playlistInfo && !playlistPreviewLoading && (
@@ -1146,59 +1018,81 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
- {selectedPlaylistEntries.map((entry) => ( -
-
- #{entry.index} -
-
-

- {entry.title || t('download.fetchingVideoInfo')} -

-
-
- ))} + {playlistInfo.entries.map((entry) => { + const isSelected = selectedEntryIds.has(entry.id) + const isInRange = + selectedEntryIds.size === 0 && + selectedPlaylistEntries.some((e) => e.id === entry.id) + + const handleToggle = () => { + setSelectedEntryIds((prev) => { + const next = new Set(prev) + if (next.has(entry.id)) { + next.delete(entry.id) + } else { + next.add(entry.id) + } + return next + }) + // Clear range inputs when manual selection is used + if (selectedEntryIds.size === 0) { + setStartIndex('1') + setEndIndex('') + } + } + + return ( + + ) + })}
- - {/* Download Location - Playlist */} -
- -
-
- -
- -
-
-
- - {playlistCustomDownloadPath && ( - - )} -
-
-
)} @@ -1254,7 +1148,13 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa setStartIndex(e.target.value)} + onChange={(e) => { + setStartIndex(e.target.value) + // Clear manual selection when using range + if (selectedEntryIds.size > 0) { + setSelectedEntryIds(new Set()) + } + }} className="text-center" disabled={playlistBusy} /> @@ -1262,7 +1162,13 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa setEndIndex(e.target.value)} + onChange={(e) => { + setEndIndex(e.target.value) + // Clear manual selection when using range + if (selectedEntryIds.size > 0) { + setSelectedEntryIds(new Set()) + } + }} className="text-center" disabled={playlistBusy} /> @@ -1274,83 +1180,140 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
- - + + - +
- {(activeTab === 'playlist' || (activeTab === 'single' && videoInfo && !loading)) && ( -
- { - if (activeTab === 'playlist') { +
+ {/* Download Location - Single Video */} + {activeTab === 'single' && videoInfo && !loading && ( +
+
+ +
+ +
+
+ + {videoInfoCardState.customDownloadPath && ( + + )} +
+ )} + + {/* Download Location - Playlist */} + {activeTab === 'playlist' && playlistInfo && !playlistPreviewLoading && ( +
+
+ +
+ +
+
+ + {playlistCustomDownloadPath && ( + + )} +
+ )} + + {/* Advanced Options - Playlist (when no playlist info) */} + {activeTab === 'playlist' && !playlistInfo && ( +
+ { setAdvancedOptionsOpen(checked === true) - } else { - setSingleVideoAdvancedOptionsOpen(checked === true) - } - }} - /> - -
- )} + }} + /> + +
+ )} +
{activeTab === 'single' ? ( !videoInfo ? ( - settings.oneClickDownload ? ( - - ) : ( - - ) + ) : videoInfoCardState.activeTab === 'video' ? ( ) : ( - <> - - - + ) ) : playlistInfo && !playlistPreviewLoading ? ( )} diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 0011474..395a935 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -149,7 +149,7 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => { if (!format) { return undefined } - if (download.type === 'audio' || download.type === 'extract') { + if (download.type === 'audio') { return sanitizeCodec(format.acodec) } return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec) @@ -678,7 +678,7 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D : {})} > {/* Thumbnail */} -
+
{selectionEnabled && (
{download.title}

+ {download.type === 'audio' && ( + + {t('download.audio')} + + )} {isSubscriptionDownload && ( {t('subscriptions.labels.subscription')} diff --git a/src/renderer/src/components/ui/radio-group.tsx b/src/renderer/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..8d9ba34 --- /dev/null +++ b/src/renderer/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' +import { cn } from '@renderer/lib/utils' +import { CircleIcon } from 'lucide-react' +import type * as React from 'react' + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/src/renderer/src/components/ui/table.tsx b/src/renderer/src/components/ui/table.tsx new file mode 100644 index 0000000..6aafbe6 --- /dev/null +++ b/src/renderer/src/components/ui/table.tsx @@ -0,0 +1,89 @@ +import { cn } from '@renderer/lib/utils' +import type * as React from 'react' + +function Table({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return +} + +function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { + return ( + tr]:last:border-b-0', className)} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
[role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + [role=checkbox]]:translate-y-[2px]', + className + )} + {...props} + /> + ) +} + +function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) { + return ( +
+ ) +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption } diff --git a/src/renderer/src/components/video/AudioExtractor.tsx b/src/renderer/src/components/video/AudioExtractor.tsx deleted file mode 100644 index 4756bc5..0000000 --- a/src/renderer/src/components/video/AudioExtractor.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card' -import { Label } from '@renderer/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@renderer/components/ui/select' -import { useTranslation } from 'react-i18next' -import type { VideoInfo } from '../../../../shared/types' - -export interface AudioExtractorState { - extractFormat: string - extractQuality: string -} - -interface AudioExtractorProps { - videoInfo: VideoInfo - state: AudioExtractorState - onStateChange: (state: Partial) => void -} - -export function AudioExtractor({ - videoInfo: _videoInfo, - state, - onStateChange -}: AudioExtractorProps) { - const { t } = useTranslation() - const { extractFormat, extractQuality } = state - - const audioFormats = [ - { value: 'mp3', label: 'MP3' }, - { value: 'm4a', label: 'M4A' }, - { value: 'opus', label: 'Opus' }, - { value: 'wav', label: 'WAV' }, - { value: 'flac', label: 'FLAC' }, - { value: 'alac', label: 'ALAC' }, - { value: 'vorbis', label: 'Vorbis (OGG)' } - ] - - const qualities = [ - { value: '0', label: t('audioExtract.best') }, - { value: '2', label: t('audioExtract.good') }, - { value: '5', label: t('audioExtract.normal') }, - { value: '8', label: t('audioExtract.bad') }, - { value: '10', label: t('audioExtract.worst') } - ] - - return ( - - - {t('audioExtract.title')} - - -
-
- - -
- -
- - -
-
-
-
- ) -} diff --git a/src/renderer/src/components/video/FormatSelector.tsx b/src/renderer/src/components/video/FormatSelector.tsx index 39bc518..e532407 100644 --- a/src/renderer/src/components/video/FormatSelector.tsx +++ b/src/renderer/src/components/video/FormatSelector.tsx @@ -1,11 +1,5 @@ -import { Label } from '@renderer/components/ui/label' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@renderer/components/ui/select' +import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group' +import { Table, TableBody, TableCell, TableRow } from '@renderer/components/ui/table' import { useAtom } from 'jotai' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -26,13 +20,15 @@ interface FormatSelectorProps { type: 'video' | 'audio' onVideoFormatChange?: (format: string) => void onAudioFormatChange?: (format: string) => void + codec?: string // 'auto' or specific codec name } export function FormatSelector({ formats, type, onVideoFormatChange, - onAudioFormatChange + onAudioFormatChange, + codec }: FormatSelectorProps) { const { t } = useTranslation() const [settings] = useAtom(settingsAtom) @@ -71,44 +67,72 @@ export function FormatSelector({ ) useEffect(() => { - // Filter and sort formats - // Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download + // Formats are already filtered by VideoInfoCard based on Container, Codec, etc. + // We just need to separate video and audio formats, exclude HLS, and sort them. + const isHlsFormat = (format: VideoFormat) => + format.protocol === 'm3u8' || format.protocol === 'm3u8_native' + + // Filter out HLS formats and separate by type + const filteredFormats = formats.filter((f) => !isHlsFormat(f)) + const isVideoFormat = (format: VideoFormat) => format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none' const isAudioFormat = (format: VideoFormat) => format.acodec && format.acodec !== 'none' && - (format.video_ext === 'none' || !format.video_ext) - const isHlsFormat = (format: VideoFormat) => - format.protocol === 'm3u8' || format.protocol === 'm3u8_native' + (format.video_ext === 'none' || + !format.video_ext || + !format.vcodec || + format.vcodec === 'none') - const videoCandidates = formats.filter( - (format) => isVideoFormat(format) && !isHlsFormat(format) - ) - const audioCandidates = formats.filter( - (format) => isAudioFormat(format) && !isHlsFormat(format) - ) + const videos = filteredFormats.filter(isVideoFormat) + const audios = filteredFormats.filter(isAudioFormat) - const videos = - videoCandidates.length > 0 - ? videoCandidates - : formats.filter((format) => isVideoFormat(format)) - const audios = - audioCandidates.length > 0 - ? audioCandidates - : formats.filter((format) => isAudioFormat(format)) + // Get file size for comparison (prefer filesize over filesize_approx) + const getFileSize = (format: VideoFormat): number => { + return format.filesize ?? format.filesize_approx ?? 0 + } - // Apply showMoreFormats filter - const filteredVideos = settings.showMoreFormats - ? videos - : videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp')) + // When codec is 'auto', filter to show only the largest file size per resolution + let finalVideos = videos + let finalAudios = audios - const filteredAudios = settings.showMoreFormats - ? audios - : audios.filter((f) => f.ext !== 'webm') + if (codec === 'auto') { + if (type === 'video') { + // Group by height (resolution) and keep only the one with largest file size + const groupedByHeight = new Map() + videos.forEach((format) => { + const height = format.height ?? 0 + const existing = groupedByHeight.get(height) || [] + existing.push(format) + groupedByHeight.set(height, existing) + }) - const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos - const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios + finalVideos = Array.from(groupedByHeight.values()).map((group) => { + // Sort by file size descending and take the first (largest) + return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0] + }) + } else { + // For audio, group by quality/tbr and keep only the one with largest file size + const groupedByQuality = new Map() + audios.forEach((format) => { + // Use tbr or quality as grouping key + const qualityKey = format.tbr + ? `tbr_${format.tbr}` + : format.quality + ? `quality_${format.quality}` + : 'unknown' + const existing = groupedByQuality.get(qualityKey) || [] + existing.push(format) + groupedByQuality.set(qualityKey, existing) + }) + + finalAudios = Array.from(groupedByQuality.values()).map((group) => { + // Sort by file size descending and take the first (largest) + return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0] + }) + } + } // Sort formats by quality (best first) const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => { @@ -155,39 +179,44 @@ export function FormatSelector({ setVideoFormats(finalVideos) setAudioFormats(finalAudios) - // Auto-select best format based on preferences. - // If no separate audio formats exist, prefer muxed video formats (with audio). - const videosWithAudio = finalVideos.filter( - (format) => format.acodec && format.acodec !== 'none' - ) - const autoVideos = - finalAudios.length > 0 - ? finalVideos - : videosWithAudio.length > 0 - ? videosWithAudio - : finalVideos + // Auto-select best format based on preferences + if (type === 'video') { + const videosWithAudio = finalVideos.filter( + (format) => format.acodec && format.acodec !== 'none' + ) + const autoVideos = + finalAudios.length > 0 + ? finalVideos + : videosWithAudio.length > 0 + ? videosWithAudio + : finalVideos - if (autoVideos.length > 0 && !selectedVideo) { - const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality) - if (preferred) { - setSelectedVideo(preferred.format_id) - onVideoFormatChange?.(preferred.format_id) + const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedVideo) + if (autoVideos.length > 0 && (!selectedVideo || !hasSelectedVideo)) { + const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality) + if (preferred) { + setSelectedVideo(preferred.format_id) + onVideoFormatChange?.(preferred.format_id) + } + } + } else { + const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedAudio) + if (finalAudios.length > 0 && (!selectedAudio || !hasSelectedAudio)) { + const best = finalAudios[0] + setSelectedAudio(best.format_id) + onAudioFormatChange?.(best.format_id) } - } - - if (finalAudios.length > 0 && !selectedAudio) { - const best = finalAudios[0] - setSelectedAudio(best.format_id) - onAudioFormatChange?.(best.format_id) } }, [ formats, - settings, + settings.oneClickQuality, + type, selectedVideo, selectedAudio, onAudioFormatChange, onVideoFormatChange, - pickVideoFormatForPreset + pickVideoFormatForPreset, + codec ]) const formatSize = (bytes?: number) => { @@ -196,141 +225,130 @@ export function FormatSelector({ return `${mb.toFixed(2)} MB` } - const formatVideoLabel = (format: VideoFormat) => { - const parts: string[] = [] - // Resolution + const formatVideoQuality = (format: VideoFormat) => { if (format.height) { - parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`) + return `${format.height}p${format.fps === 60 ? '60' : ''}` } + if (format.format_note) { + return format.format_note + } + if (typeof format.quality === 'number') { + return format.quality.toString() + } + return t('download.unknownQuality') + } + + const formatAudioQuality = (format: VideoFormat) => { + if (format.tbr) { + return `${Math.round(format.tbr)} kbps` + } + if (format.format_note) { + return format.format_note + } + if (typeof format.quality === 'number') { + return format.quality.toString() + } + return t('download.unknownQuality') + } + + const formatVideoDetail = (format: VideoFormat) => { + const parts: string[] = [] // Format extension parts.push(format.ext.toUpperCase()) - // Codec (if showMoreFormats is enabled) - if (settings.showMoreFormats && format.vcodec) { - parts.push(format.vcodec.split('.')[0]) + // Codec information + if (format.vcodec) { + parts.push(format.vcodec.split('.')[0].toUpperCase()) } - // Audio indicator - if (format.acodec !== 'none') { - parts.push('🔊') - } - // File size - const size = formatSize(format.filesize || format.filesize_approx) - if (size !== t('download.unknownSize')) { - parts.push(size) + if (format.acodec && format.acodec !== 'none') { + parts.push(format.acodec.split('.')[0].toUpperCase()) } return parts.join(' • ') } - const formatAudioLabel = (format: VideoFormat) => { + const formatAudioDetail = (format: VideoFormat) => { const parts: string[] = [] - // Quality - const quality = format.format_note || t('download.unknownQuality') - parts.push(quality) // Format extension const ext = format.ext === 'webm' ? 'opus' : format.ext parts.push(ext.toUpperCase()) - // File size - const size = formatSize(format.filesize || format.filesize_approx) - if (size !== t('download.unknownSize')) { - parts.push(size) + if (format.acodec) { + parts.push(format.acodec.split('.')[0].toUpperCase()) } return parts.join(' • ') } - if (type === 'video') { - if (videoFormats.length === 0 && audioFormats.length === 0) { + // Unified table rendering for both video and audio + const renderFormatTable = () => { + const formats = type === 'video' ? videoFormats : audioFormats + const selected = type === 'video' ? selectedVideo : selectedAudio + const onFormatChange = + type === 'video' + ? (value: string) => { + setSelectedVideo(value) + onVideoFormatChange?.(value) + } + : (value: string) => { + setSelectedAudio(value) + onAudioFormatChange?.(value) + } + + if (formats.length === 0) { return null } return ( -
- {videoFormats.length > 0 && ( -
- - -
- )} + + + + {formats.map((format) => { + const qualityLabel = + type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format) + const detailLabel = + type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format) + const thirdColumnLabel = + type === 'video' + ? format.fps + ? `${format.fps}fps` + : '-' + : format.acodec + ? format.acodec.split('.')[0].toUpperCase() + : '-' + const sizeLabel = formatSize(format.filesize || format.filesize_approx) - {audioFormats.length > 0 && ( -
- - -
- )} - + return ( + onFormatChange(format.format_id)} + > + + + + +
{qualityLabel}
+
+ +
{detailLabel}
+
+ +
+ {thirdColumnLabel} +
+
+ +
{sizeLabel}
+
+
+ ) + })} +
+
+
) } - // Audio only - if (audioFormats.length === 0) { - return null - } - - return ( -
- - -
- ) + return renderFormatTable() } diff --git a/src/renderer/src/components/video/VideoInfoCard.tsx b/src/renderer/src/components/video/VideoInfoCard.tsx index 8d2fe80..061520e 100644 --- a/src/renderer/src/components/video/VideoInfoCard.tsx +++ b/src/renderer/src/components/video/VideoInfoCard.tsx @@ -1,38 +1,57 @@ -import { Badge } from '@renderer/components/ui/badge' -import { Card, CardContent, CardHeader } from '@renderer/components/ui/card' import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder' -import { Separator } from '@renderer/components/ui/separator' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' - -import { Clock, Eye, Play } from 'lucide-react' +import { Label } from '@renderer/components/ui/label' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@renderer/components/ui/select' +import { ExternalLink } from 'lucide-react' +import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import type { VideoInfo } from '../../../../shared/types' import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail' -import { AudioExtractor, type AudioExtractorState } from './AudioExtractor' import { FormatSelector } from './FormatSelector' +const VideoInfoSkeleton = () => ( +
+ {/* Header Info Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+) + export interface VideoInfoCardState { title: string activeTab: 'video' | 'audio' selectedVideoFormat: string - selectedAudioForVideo: string selectedAudioFormat: string - startTime: string - endTime: string - downloadSubs: boolean customDownloadPath: string - audioExtractor: AudioExtractorState + selectedContainer?: string + selectedCodec?: string + selectedFps?: string } interface VideoInfoCardProps { - videoInfo: VideoInfo + videoInfo: VideoInfo | null + loading?: boolean state: VideoInfoCardState onStateChange: (state: Partial) => void onTabChange: (tab: 'video' | 'audio') => void } function formatDuration(seconds?: number): string { - if (!seconds) return 'Unknown' + if (!seconds) return '00:00' const h = Math.floor(seconds / 3600) const m = Math.floor((seconds % 3600) / 60) const s = Math.floor(seconds % 60) @@ -40,123 +59,327 @@ function formatDuration(seconds?: number): string { return `${m}:${s.toString().padStart(2, '0')}` } -function formatViews(views?: number): string { - if (!views) return 'Unknown' - if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M` - if (views >= 1000) return `${(views / 1000).toFixed(1)}K` - return views.toString() +function getCodecShortName(codec?: string): string { + if (!codec || codec === 'none') return 'Unknown' + return codec.split('.')[0].toUpperCase() } export function VideoInfoCard({ videoInfo, + loading = false, state, onStateChange, onTabChange }: VideoInfoCardProps) { const { t } = useTranslation() - const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail) + const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail) - const { title, activeTab } = state + const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state + + // Get unique containers based on activeTab + const containers = useMemo(() => { + if (!videoInfo?.formats) return [] + const relevantFormats = videoInfo.formats.filter((f) => { + if (activeTab === 'video') { + // In video mode, we want formats with video codec + return f.vcodec && f.vcodec !== 'none' + } else { + // In audio mode, we only want audio-only formats (no video) + return ( + f.acodec && + f.acodec !== 'none' && + (f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none') + ) + } + }) + const exts = new Set(relevantFormats.map((f) => f.ext)) + return Array.from(exts).sort() + }, [videoInfo?.formats, activeTab]) + + // Set default container if not set or if current container is not in the list + useEffect(() => { + if (containers.length === 0) return undefined + + // Reset container if it's not in the current containers list (e.g., after tab switch) + if (selectedContainer && !containers.includes(selectedContainer)) { + let defaultContainer: string + if (activeTab === 'video') { + defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0] + } else { + defaultContainer = containers.includes('m4a') + ? 'm4a' + : containers.includes('mp3') + ? 'mp3' + : containers[0] + } + const timer = setTimeout(() => { + onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' }) + }, 0) + return () => clearTimeout(timer) + } + + // Set default container if not set + if (!selectedContainer) { + let defaultContainer: string + if (activeTab === 'video') { + defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0] + } else { + defaultContainer = containers.includes('m4a') + ? 'm4a' + : containers.includes('mp3') + ? 'mp3' + : containers[0] + } + const timer = setTimeout(() => { + onStateChange({ selectedContainer: defaultContainer }) + }, 0) + return () => clearTimeout(timer) + } + + return undefined + }, [containers, selectedContainer, activeTab, onStateChange]) + + // Step 1: Filter formats based on activeTab and selected container + const formatsByContainer = useMemo(() => { + if (!videoInfo?.formats) return [] + + // First filter by activeTab type + let filteredByType = videoInfo.formats.filter((f) => { + if (activeTab === 'video') { + // For video, only formats with video codec + return f.vcodec && f.vcodec !== 'none' + } else { + // For audio, only audio-only formats (no video) + return ( + f.acodec && + f.acodec !== 'none' && + (f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none') + ) + } + }) + + // Then filter by selected container if one is selected + if (selectedContainer) { + filteredByType = filteredByType.filter((f) => f.ext === selectedContainer) + } + + return filteredByType + }, [videoInfo?.formats, selectedContainer, activeTab]) + + // Get unique Codecs from formatsByContainer based on activeTab + // This should only show codecs that exist in the filtered format list + const codecs = useMemo(() => { + if (formatsByContainer.length === 0) return [] + + const SetVals = new Set() + formatsByContainer.forEach((f) => { + if (activeTab === 'video') { + // For video, only get video codecs from formats that have video + const c = f.vcodec + if (c && c !== 'none') { + SetVals.add(getCodecShortName(c)) + } + } else { + // For audio, only get audio codecs from formats that have audio + const c = f.acodec + if (c && c !== 'none') { + SetVals.add(getCodecShortName(c)) + } + } + }) + return Array.from(SetVals).sort() + }, [formatsByContainer, activeTab]) + + // Reset codec if it's not in the current codecs list (e.g., after tab or container switch) + useEffect(() => { + if (codecs.length === 0) return undefined + if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) { + const timer = setTimeout(() => { + onStateChange({ selectedCodec: 'auto' }) + }, 0) + return () => clearTimeout(timer) + } + return undefined + }, [codecs, selectedCodec, onStateChange]) + + // Step 2: Filter formats by selected Codec based on activeTab + const formatsByCodec = useMemo(() => { + if (!selectedCodec || selectedCodec === 'auto') return formatsByContainer + return formatsByContainer.filter((f) => { + if (activeTab === 'video') { + // For video, filter by video codec + const c = f.vcodec + return c && c !== 'none' && getCodecShortName(c) === selectedCodec + } else { + // For audio, filter by audio codec + const c = f.acodec + return c && c !== 'none' && getCodecShortName(c) === selectedCodec + } + }) + }, [formatsByContainer, selectedCodec, activeTab]) + + // Get unique Framerates from formatsByCodec (only for video) + const framerates = useMemo(() => { + if (activeTab !== 'video') return [] + const SetVals = new Set() + formatsByCodec.forEach((f) => { + if (f.fps) SetVals.add(f.fps) + }) + return Array.from(SetVals).sort((a, b) => b - a) + }, [formatsByCodec, activeTab]) + + // Step 3: Filter formats by selected FPS + const filteredFormats = useMemo(() => { + let res = formatsByCodec + if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') { + res = res.filter((f) => f.fps === Number(selectedFps)) + } + return res + }, [formatsByCodec, selectedFps, activeTab]) + + if (loading || !videoInfo) { + return + } return ( -
- - -
- {/* Thumbnail */} -
-
- } - /> -
+
+ {/* Header Info */} +
+
+ +
+ {formatDuration(videoInfo.duration)} +
+
+
+
+

{title}

+ + + +
+
{videoInfo.uploader}
+
+
+ + {/* Controls Area */} + +
+
+
+ +
- {/* Video Metadata */} -
-
- - {videoInfo.extractor_key || t('download.videoInfo')} - - {videoInfo.duration && ( - - - {formatDuration(videoInfo.duration)} - - )} - {videoInfo.view_count && ( - - - {formatViews(videoInfo.view_count)} - - )} -
- -
-

{title}

- {videoInfo.uploader && ( -

{videoInfo.uploader}

- )} -
+
+ +
- - + {/* Advanced Filters */} +
+
+ Codec + +
- - { - onTabChange(v as 'video' | 'audio') - onStateChange({ activeTab: v as 'video' | 'audio' }) - }} - className="w-full" - > - - - {t('download.video')} - - - {t('download.audio')} - - + {activeTab === 'video' && ( +
+ Frame Rate + +
+ )} +
- - onStateChange({ selectedVideoFormat: format })} - onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })} - /> - - - - onStateChange({ selectedAudioFormat: format })} - /> - - - onStateChange({ - audioExtractor: { ...state.audioExtractor, ...updates } - }) - } - /> - - - - + {/* List */} +
+ onStateChange({ selectedVideoFormat: format })} + onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })} + /> +
+
+
) } diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 5b407a2..c564547 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -105,17 +105,6 @@ "description": "Download videos and audios from hundreds of sites", "title": "VidBee" }, - "audioExtract": { - "bad": "Bad", - "best": "Best", - "extract": "Extract", - "good": "Good", - "normal": "Normal", - "selectFormat": "Select Format", - "selectQuality": "Select Quality", - "title": "Extract Audio", - "worst": "Worst" - }, "download": { "active": "Active", "all": "All", @@ -160,6 +149,7 @@ "paste": "Paste", "pastePlaylistUrl": "Click to paste playlist link from clipboard [Ctrl + V]", "pasteUrl": "Click to paste video URL or ID [Ctrl + V]", + "pasteUrlButton": "Paste URL", "preparing": "Preparing...", "processing": "Processing", "progress": "Progress", @@ -234,6 +224,7 @@ "emptyUrl": "Please enter a URL", "errorDetails": "Error Details", "fetchInfoFailed": "Failed to fetch video information", + "invalidUrl": "The clipboard content is not a valid URL", "networkError": "Some error has occurred. Check your network and use correct URL", "pasteFromClipboard": "Failed to paste from clipboard" }, @@ -362,6 +353,8 @@ "selectedVideos": "{{count}} selected", "downloadCurrentRange": "Download Selected", "showingCount": "Showing {{count}} videos", + "selectEntry": "Select entry {{index}}", + "noEntriesSelected": "No entries selected", "startIndex": "Start (1)", "title": "Download Playlist", "totalVideos": "Total videos: {{count}}", diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 80cd06f..ec20af6 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -498,27 +498,6 @@ export function Settings() { - - - - {t('settings.showMoreFormats')} - {t('settings.showMoreFormatsDescription')} - - - { - try { - handleSettingChange('showMoreFormats', value) - } catch (error) { - logger.error('[Settings] Error toggling showMoreFormats:', error) - } - }} - /> - - - - diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d943718..4db054c 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -18,6 +18,7 @@ export interface VideoFormat { tbr?: number quality?: number protocol?: string // http, https, m3u8, m3u8_native, etc. + language?: string } export interface VideoInfo { @@ -54,7 +55,7 @@ export interface DownloadItem { url: string title: string thumbnail?: string - type: 'video' | 'audio' | 'extract' + type: 'video' | 'audio' status: DownloadStatus progress?: DownloadProgress error?: string @@ -99,7 +100,7 @@ export interface DownloadHistoryItem { url: string title: string thumbnail?: string - type: 'video' | 'audio' | 'extract' + type: 'video' | 'audio' status: DownloadStatus downloadPath?: string savedFileName?: string @@ -127,11 +128,10 @@ export interface DownloadHistoryItem { export interface DownloadOptions { url: string - type: 'video' | 'audio' | 'extract' + type: 'video' | 'audio' format?: string audioFormat?: string - extractFormat?: string - extractQuality?: string + audioFormatIds?: string[] startTime?: string endTime?: string downloadSubs?: boolean @@ -253,7 +253,6 @@ export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst' export interface AppSettings { downloadPath: string - showMoreFormats: boolean maxConcurrentDownloads: number browserForCookies: string cookiesPath: string @@ -281,7 +280,6 @@ export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%( export const defaultSettings: AppSettings = { downloadPath: '', - showMoreFormats: false, maxConcurrentDownloads: 5, browserForCookies: 'none', cookiesPath: '',