From 9d377dd464d458ab4d2306aed30480b74edd84c3 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Thu, 15 Jan 2026 20:49:24 +0800 Subject: [PATCH] refactor(download): split dialog into single/playlist (#115) --- .../components/download/DownloadDialog.tsx | 550 ++++--------- .../components/download/PlaylistDownload.tsx | 248 ++++++ .../download/PlaylistDownloadGroup.tsx | 2 +- .../download/SingleVideoDownload.tsx | 762 ++++++++++++++++++ .../src/components/video/FormatSelector.tsx | 354 -------- .../src/components/video/VideoInfoCard.tsx | 385 --------- 6 files changed, 1188 insertions(+), 1113 deletions(-) create mode 100644 src/renderer/src/components/download/PlaylistDownload.tsx create mode 100644 src/renderer/src/components/download/SingleVideoDownload.tsx delete mode 100644 src/renderer/src/components/video/FormatSelector.tsx delete mode 100644 src/renderer/src/components/video/VideoInfoCard.tsx diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx index b9e4e77..d7658e5 100644 --- a/src/renderer/src/components/download/DownloadDialog.tsx +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -3,17 +3,8 @@ import { Checkbox } from '@renderer/components/ui/checkbox' 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' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@renderer/components/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' -import { cn } from '@renderer/lib/utils' import type { PlaylistInfo, VideoFormat } from '@shared/types' import { buildAudioFormatPreference, @@ -21,7 +12,6 @@ import { } from '@shared/utils/format-preferences' import { useAtom, useSetAtom } from 'jotai' import { - AlertCircle, FolderOpen, Github, List, @@ -48,7 +38,12 @@ import { videoInfoErrorAtom, videoInfoLoadingAtom } from '../../store/video' -import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard' +import { PlaylistDownload } from './PlaylistDownload' +import { + type FeedbackLink, + SingleVideoDownload, + type SingleVideoState +} from './SingleVideoDownload' const isLikelyUrl = (value: string): boolean => { try { @@ -141,6 +136,7 @@ const pickBestAudioFormatsByLanguage = (formats: VideoFormat[]): string[] => { }) .filter((id): id is string => !!id) } + interface DownloadDialogProps { onOpenSupportedSites?: () => void onOpenSettings?: () => void @@ -168,13 +164,16 @@ export function DownloadDialog({ const [url, setUrl] = useState('') const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single') - // VideoInfoCard state management - const [videoInfoCardState, setVideoInfoCardState] = useState({ + // Single video state + const [singleVideoState, setSingleVideoState] = useState({ title: '', activeTab: 'video', selectedVideoFormat: '', selectedAudioFormat: '', - customDownloadPath: '' + customDownloadPath: '', + selectedContainer: undefined, + selectedCodec: undefined, + selectedFps: undefined }) // Playlist states @@ -192,7 +191,7 @@ export function DownloadDialog({ const playlistBusy = playlistPreviewLoading || playlistDownloadLoading const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false) const [selectedEntryIds, setSelectedEntryIds] = useState>(new Set()) - const feedbackLinks = useMemo(() => { + const feedbackLinks: FeedbackLink[] = useMemo(() => { const compactError = normalizeErrorText(error) const tweetError = compactError ? clampText(compactError, 160) : '' const tweetText = encodeURIComponent( @@ -373,6 +372,14 @@ export function DownloadDialog({ // Wait for dialog to open and settings to load, then fetch video info setTimeout(async () => { + setSingleVideoState((prev) => ({ + ...prev, + selectedVideoFormat: '', + selectedAudioFormat: '', + selectedContainer: undefined, + selectedCodec: undefined, + selectedFps: undefined + })) await fetchVideoInfo(url) }, 100) } @@ -552,6 +559,14 @@ export function DownloadDialog({ toast.error(t('errors.emptyUrl')) return } + setSingleVideoState((prev) => ({ + ...prev, + selectedVideoFormat: '', + selectedAudioFormat: '', + selectedContainer: undefined, + selectedCodec: undefined, + selectedFps: undefined + })) await fetchVideoInfo(url.trim()) }, [url, fetchVideoInfo, t]) @@ -859,77 +874,76 @@ export function DownloadDialog({ selectedEntryIds ]) - // Update videoInfoCardState when videoInfo changes + // Update single video title when videoInfo changes useEffect(() => { if (videoInfo) { - setVideoInfoCardState((prev) => ({ + setSingleVideoState((prev) => ({ ...prev, title: videoInfo.title || prev.title })) } }, [videoInfo]) - // Handle video download from VideoInfoCard - const handleVideoDownload = useCallback( - async (type: 'video' | 'audio') => { - if (!videoInfo) return + const handleSingleVideoDownload = useCallback(async () => { + if (!videoInfo) return - const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` + const type = singleVideoState.activeTab + const selectedFormat = + type === 'video' ? singleVideoState.selectedVideoFormat : singleVideoState.selectedAudioFormat + if (!selectedFormat) { + return + } + const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` - const downloadItem = { - id, - url: videoInfo.webpage_url || '', - title: videoInfoCardState.title, + const downloadItem = { + id, + url: videoInfo.webpage_url || '', + title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'), + thumbnail: videoInfo.thumbnail, + type, + status: 'pending' as const, + progress: { percent: 0 }, + duration: videoInfo.duration, + description: videoInfo.description, + channel: videoInfo.extractor_key, + uploader: videoInfo.extractor_key, + createdAt: Date.now() + } + + const audioFormatIds = + type === 'video' ? pickBestAudioFormatsByLanguage(videoInfo.formats || []) : undefined + + const options = { + url: videoInfo.webpage_url || '', + type, + format: selectedFormat || undefined, + audioFormat: type === 'video' ? 'best' : undefined, + audioFormatIds: audioFormatIds && audioFormatIds.length > 0 ? audioFormatIds : undefined, + customDownloadPath: singleVideoState.customDownloadPath.trim() || undefined + } + + addDownload(downloadItem) + + try { + await ipcServices.download.startDownload(id, options) + + await ipcServices.download.updateDownloadInfo(id, { + title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'), thumbnail: videoInfo.thumbnail, - type, - status: 'pending' as const, - progress: { percent: 0 }, duration: videoInfo.duration, description: videoInfo.description, channel: videoInfo.extractor_key, uploader: videoInfo.extractor_key, createdAt: Date.now() - } + }) - const audioFormatIds = - type === 'video' ? pickBestAudioFormatsByLanguage(videoInfo.formats || []) : undefined - - const options = { - url: videoInfo.webpage_url || '', - type, - format: - type === 'video' - ? videoInfoCardState.selectedVideoFormat || undefined - : videoInfoCardState.selectedAudioFormat || undefined, - audioFormat: type === 'video' ? 'best' : undefined, - audioFormatIds: audioFormatIds && audioFormatIds.length > 0 ? audioFormatIds : undefined, - customDownloadPath: videoInfoCardState.customDownloadPath.trim() || undefined - } - - addDownload(downloadItem) - - try { - await ipcServices.download.startDownload(id, options) - - await ipcServices.download.updateDownloadInfo(id, { - title: videoInfoCardState.title, - thumbnail: videoInfo.thumbnail, - duration: videoInfo.duration, - description: videoInfo.description, - channel: videoInfo.extractor_key, - uploader: videoInfo.extractor_key, - createdAt: Date.now() - }) - - toast.success(t('notifications.downloadStarted')) - setOpen(false) // Close dialog after download starts - } catch (error) { - console.error('Failed to start download:', error) - toast.error(t('notifications.downloadFailed')) - } - }, - [videoInfo, videoInfoCardState, addDownload, t] - ) + toast.success(t('notifications.downloadStarted')) + setOpen(false) // Close dialog after download starts + } catch (error) { + console.error('Failed to start download:', error) + toast.error(t('notifications.downloadFailed')) + } + }, [videoInfo, singleVideoState, addDownload, t]) // Reset form when dialog closes useEffect(() => { @@ -937,12 +951,15 @@ export function DownloadDialog({ // Reset single video states setUrl('') setActiveTab('single') - setVideoInfoCardState({ + setSingleVideoState({ title: '', activeTab: 'video', selectedVideoFormat: '', selectedAudioFormat: '', - customDownloadPath: '' + customDownloadPath: '', + selectedContainer: undefined, + selectedCodec: undefined, + selectedFps: undefined }) // Reset playlist states @@ -956,6 +973,14 @@ export function DownloadDialog({ } }, [open]) + const handleSingleVideoStateChange = useCallback((updates: Partial) => { + setSingleVideoState((prev) => ({ ...prev, ...updates })) + }, []) + const selectedSingleFormat = + singleVideoState.activeTab === 'video' + ? singleVideoState.selectedVideoFormat + : singleVideoState.selectedAudioFormat + return ( - + setActiveTab(value as 'single' | 'playlist')} - className="w-full flex flex-col flex-1 min-h-0" + className="w-full flex flex-col flex-1 min-h-0 gap-0" > - + setActiveTab('single')}> - setActiveTab('playlist')}> - + {t('download.metadata.playlist')} {/* Single Video Download Tab */} - - {/* Error Display */} - {error && ( -
-
- -
-

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

-

{error}

-
-
-
- - {t('download.feedback.title')} - -
- {feedbackLinks.map((resource) => { - const Icon = resource.icon - return ( - - ) - })} -
-
-
- )} - - {/* Video Info and Download Options */} - {(loading || videoInfo) && ( - - setVideoInfoCardState((prev) => ({ ...prev, ...updates })) - } - onTabChange={(tab) => { - setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab })) - }} - /> - )} + + {/* Playlist Download Tab */} - - -
- {/* Preview State */} - {playlistInfo && !playlistPreviewLoading && ( -
-
-

{playlistInfo.title}

-
- - {t('playlist.foundVideos', { count: playlistInfo.entryCount })} - {selectedPlaylistEntries.length !== playlistInfo.entryCount && ( - <> - - - {t('playlist.selectedVideos', { - count: selectedPlaylistEntries.length - })} - - - )} -
-
- - -
- {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 ( - - ) - })} -
-
-
- )} - - {playlistPreviewError && ( -
-
- -
-

- {t('playlist.previewFailed')} -

-

{playlistPreviewError}

-
-
-
- )} - - {/* Advanced Options Content - Playlist */} -
-
-
-
-
-
- - -
- -
- -
- { - setStartIndex(e.target.value) - // Clear manual selection when using range - if (selectedEntryIds.size > 0) { - setSelectedEntryIds(new Set()) - } - }} - className="text-center" - disabled={playlistBusy} - /> - - - { - setEndIndex(e.target.value) - // Clear manual selection when using range - if (selectedEntryIds.size > 0) { - setSelectedEntryIds(new Set()) - } - }} - className="text-center" - disabled={playlistBusy} - /> -
-
-
-
-
-
-
-
-
+ +
- -
-
+ +
+
{/* Download Location - Single Video */} {activeTab === 'single' && videoInfo && !loading && (
-
+
-
- +
+
- - {videoInfoCardState.customDownloadPath && ( + + {singleVideoState.customDownloadPath && ( @@ -1295,11 +1106,11 @@ export function DownloadDialog({ -
- +
+
@@ -1316,6 +1128,7 @@ export function DownloadDialog({ variant="ghost" size="sm" disabled={playlistBusy} + className="h-8 text-xs" > {t('download.useAutoFolder')} @@ -1324,7 +1137,7 @@ export function DownloadDialog({ )} {/* Advanced Options - Playlist (when no playlist info) */} - {activeTab === 'playlist' && !playlistInfo && ( + {activeTab === 'playlist' && !playlistInfo && !playlistPreviewLoading && (
-
@@ -1341,7 +1154,7 @@ export function DownloadDialog({
{activeTab === 'single' ? ( - !videoInfo ? ( + !videoInfo && !loading ? ( - ) : ( - - ) + ) : null ) : playlistInfo && !playlistPreviewLoading ? ( - ) : ( + ) : !playlistPreviewLoading ? ( - )} + ) : null}
diff --git a/src/renderer/src/components/download/PlaylistDownload.tsx b/src/renderer/src/components/download/PlaylistDownload.tsx new file mode 100644 index 0000000..152ce08 --- /dev/null +++ b/src/renderer/src/components/download/PlaylistDownload.tsx @@ -0,0 +1,248 @@ +import { Checkbox } from '@renderer/components/ui/checkbox' +import { Input } from '@renderer/components/ui/input' +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 { cn } from '@renderer/lib/utils' +import type { PlaylistInfo } from '@shared/types' +import { AlertCircle, List, Loader2 } from 'lucide-react' +import type { Dispatch, SetStateAction } from 'react' +import { useTranslation } from 'react-i18next' + +interface PlaylistDownloadProps { + playlistPreviewLoading: boolean + playlistPreviewError: string | null + playlistInfo: PlaylistInfo | null + playlistBusy: boolean + selectedPlaylistEntries: PlaylistInfo['entries'] + selectedEntryIds: Set + downloadType: 'video' | 'audio' + downloadTypeId: string + startIndex: string + endIndex: string + advancedOptionsOpen: boolean + setSelectedEntryIds: Dispatch>> + setStartIndex: Dispatch> + setEndIndex: Dispatch> + setDownloadType: Dispatch> +} + +export function PlaylistDownload({ + playlistPreviewLoading, + playlistPreviewError, + playlistInfo, + playlistBusy, + selectedPlaylistEntries, + selectedEntryIds, + downloadType, + downloadTypeId, + startIndex, + endIndex, + advancedOptionsOpen, + setSelectedEntryIds, + setStartIndex, + setEndIndex, + setDownloadType +}: PlaylistDownloadProps) { + const { t } = useTranslation() + + return ( + <> + {playlistPreviewLoading && !playlistPreviewError && ( +
+ +

{t('playlist.fetchingInfo')}

+
+ )} + + {playlistPreviewError && ( +
+
+ +
+

{t('playlist.previewFailed')}

+

{playlistPreviewError}

+
+
+
+ )} + + {playlistInfo && !playlistPreviewLoading && ( +
+
+

{playlistInfo.title}

+
+ + {t('playlist.foundVideos', { count: playlistInfo.entryCount })} + {selectedPlaylistEntries.length !== playlistInfo.entryCount && ( + <> + + + {t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })} + + + )} +
+
+ + +
+ {playlistInfo.entries.map((entry) => { + const isSelected = selectedEntryIds.has(entry.id) + const isInRange = + selectedEntryIds.size === 0 && + selectedPlaylistEntries.some((playlistEntry) => playlistEntry.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 + }) + if (selectedEntryIds.size === 0) { + setStartIndex('1') + setEndIndex('') + } + } + + return ( + + ) + })} +
+
+ +
+
+
+
+
+
+ + +
+ +
+ +
+ { + setStartIndex(event.target.value) + if (selectedEntryIds.size > 0) { + setSelectedEntryIds(new Set()) + } + }} + className="text-center h-8 text-xs" + disabled={playlistBusy} + /> + - + { + setEndIndex(event.target.value) + if (selectedEntryIds.size > 0) { + setSelectedEntryIds(new Set()) + } + }} + className="text-center h-8 text-xs" + disabled={playlistBusy} + /> +
+
+
+
+
+
+
+
+ )} + + ) +} diff --git a/src/renderer/src/components/download/PlaylistDownloadGroup.tsx b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx index b83fb65..e3681f4 100644 --- a/src/renderer/src/components/download/PlaylistDownloadGroup.tsx +++ b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx @@ -78,7 +78,7 @@ export function PlaylistDownloadGroup({ const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0 return ( -
+
+ ) + })} +
+
+
+ )} + + {!loading && videoInfo && ( +
+
+
+ +
+ {formatDuration(videoInfo.duration)} +
+
+ +
+
+

{displayTitle}

+
+ {videoInfo.uploader && ( + + {videoInfo.uploader} + + )} + {videoInfo.webpage_url && ( + + + + )} +
+
+ +
+
+ + +
+ + +
+
+
+ + + +
+
+
+
+
+ + +
+ +
+ + +
+ + {activeTab === 'video' && ( +
+ + +
+ )} +
+
+
+ + + + onStateChange( + activeTab === 'video' + ? { selectedVideoFormat: formatId } + : { selectedAudioFormat: formatId } + ) + } + /> + +
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/video/FormatSelector.tsx b/src/renderer/src/components/video/FormatSelector.tsx deleted file mode 100644 index e532407..0000000 --- a/src/renderer/src/components/video/FormatSelector.tsx +++ /dev/null @@ -1,354 +0,0 @@ -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' -import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types' - -const qualityPresetToVideoHeight: Record = { - best: null, - good: 1080, - normal: 720, - bad: 480, - worst: 360 -} - -import { settingsAtom } from '../../store/settings' - -interface FormatSelectorProps { - formats: VideoFormat[] - 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, - codec -}: FormatSelectorProps) { - const { t } = useTranslation() - const [settings] = useAtom(settingsAtom) - const [videoFormats, setVideoFormats] = useState([]) - const [audioFormats, setAudioFormats] = useState([]) - const [selectedVideo, setSelectedVideo] = useState('') - const [selectedAudio, setSelectedAudio] = useState('') - - const pickVideoFormatForPreset = useCallback( - (formats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => { - if (formats.length === 0) { - return null - } - - const heightLimit = qualityPresetToVideoHeight[preset] - const byHeightDescending = (a: VideoFormat, b: VideoFormat) => - (b.height ?? 0) - (a.height ?? 0) - const sorted = [...formats].sort(byHeightDescending) - - if (preset === 'worst') { - return sorted[sorted.length - 1] ?? sorted[0] - } - - if (!heightLimit) { - return sorted[0] - } - - const matchingLimit = sorted.find((format) => { - if (!format.height) return false - return format.height <= heightLimit - }) - - return matchingLimit ?? sorted[0] - }, - [] - ) - - useEffect(() => { - // 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 || - !format.vcodec || - format.vcodec === 'none') - - const videos = filteredFormats.filter(isVideoFormat) - const audios = filteredFormats.filter(isAudioFormat) - - // Get file size for comparison (prefer filesize over filesize_approx) - const getFileSize = (format: VideoFormat): number => { - return format.filesize ?? format.filesize_approx ?? 0 - } - - // When codec is 'auto', filter to show only the largest file size per resolution - let finalVideos = videos - let finalAudios = audios - - 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) - }) - - 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) => { - // Sort by height (higher is better) - const aHeight = a.height ?? 0 - const bHeight = b.height ?? 0 - if (aHeight !== bHeight) { - return bHeight - aHeight - } - // If same height, sort by fps (higher is better) - const aFps = a.fps ?? 0 - const bFps = b.fps ?? 0 - if (aFps !== bFps) { - return bFps - aFps - } - // If same quality, prefer formats with file size information - const aHasSize = !!(a.filesize || a.filesize_approx) - const bHasSize = !!(b.filesize || b.filesize_approx) - if (aHasSize !== bHasSize) { - return bHasSize ? 1 : -1 - } - return 0 - } - - const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => { - // Sort by bitrate/quality if available - const aQuality = a.tbr ?? a.quality ?? 0 - const bQuality = b.tbr ?? b.quality ?? 0 - if (aQuality !== bQuality) { - return bQuality - aQuality - } - // If same quality, prefer formats with file size information - const aHasSize = !!(a.filesize || a.filesize_approx) - const bHasSize = !!(b.filesize || b.filesize_approx) - if (aHasSize !== bHasSize) { - return bHasSize ? 1 : -1 - } - return 0 - } - - finalVideos.sort(sortVideoFormatsByQuality) - finalAudios.sort(sortAudioFormatsByQuality) - - setVideoFormats(finalVideos) - setAudioFormats(finalAudios) - - // 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 - - 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) - } - } - }, [ - formats, - settings.oneClickQuality, - type, - selectedVideo, - selectedAudio, - onAudioFormatChange, - onVideoFormatChange, - pickVideoFormatForPreset, - codec - ]) - - const formatSize = (bytes?: number) => { - if (!bytes) return t('download.unknownSize') - const mb = bytes / 1000000 - return `${mb.toFixed(2)} MB` - } - - const formatVideoQuality = (format: VideoFormat) => { - if (format.height) { - 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 information - if (format.vcodec) { - parts.push(format.vcodec.split('.')[0].toUpperCase()) - } - if (format.acodec && format.acodec !== 'none') { - parts.push(format.acodec.split('.')[0].toUpperCase()) - } - return parts.join(' • ') - } - - const formatAudioDetail = (format: VideoFormat) => { - const parts: string[] = [] - // Format extension - const ext = format.ext === 'webm' ? 'opus' : format.ext - parts.push(ext.toUpperCase()) - if (format.acodec) { - parts.push(format.acodec.split('.')[0].toUpperCase()) - } - return parts.join(' • ') - } - - // 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 ( - - - - {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) - - return ( - onFormatChange(format.format_id)} - > - - - - -
{qualityLabel}
-
- -
{detailLabel}
-
- -
- {thirdColumnLabel} -
-
- -
{sizeLabel}
-
-
- ) - })} -
-
-
- ) - } - - return renderFormatTable() -} diff --git a/src/renderer/src/components/video/VideoInfoCard.tsx b/src/renderer/src/components/video/VideoInfoCard.tsx deleted file mode 100644 index 061520e..0000000 --- a/src/renderer/src/components/video/VideoInfoCard.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder' -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 { FormatSelector } from './FormatSelector' - -const VideoInfoSkeleton = () => ( -
- {/* Header Info Skeleton */} -
-
-
-
-
-
-
-
-
-
-
-) - -export interface VideoInfoCardState { - title: string - activeTab: 'video' | 'audio' - selectedVideoFormat: string - selectedAudioFormat: string - customDownloadPath: string - selectedContainer?: string - selectedCodec?: string - selectedFps?: string -} - -interface VideoInfoCardProps { - videoInfo: VideoInfo | null - loading?: boolean - state: VideoInfoCardState - onStateChange: (state: Partial) => void - onTabChange: (tab: 'video' | 'audio') => void -} - -function formatDuration(seconds?: number): string { - if (!seconds) return '00:00' - const h = Math.floor(seconds / 3600) - const m = Math.floor((seconds % 3600) / 60) - const s = Math.floor(seconds % 60) - if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` - return `${m}:${s.toString().padStart(2, '0')}` -} - -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 { 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 ( -
- {/* Header Info */} -
-
- -
- {formatDuration(videoInfo.duration)} -
-
-
-
-

{title}

- - - -
-
{videoInfo.uploader}
-
-
- - {/* Controls Area */} - -
-
-
- - -
- -
- - -
-
- - {/* Advanced Filters */} -
-
- Codec - -
- - {activeTab === 'video' && ( -
- Frame Rate - -
- )} -
- - {/* List */} -
- onStateChange({ selectedVideoFormat: format })} - onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })} - /> -
-
-
-
- ) -}