refactor(download): split dialog into single/playlist (#115)
This commit is contained in:
@@ -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<VideoInfoCardState>({
|
||||
// Single video state
|
||||
const [singleVideoState, setSingleVideoState] = useState<SingleVideoState>({
|
||||
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<Set<string>>(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<SingleVideoState>) => {
|
||||
setSingleVideoState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
const selectedSingleFormat =
|
||||
singleVideoState.activeTab === 'video'
|
||||
? singleVideoState.selectedVideoFormat
|
||||
: singleVideoState.selectedAudioFormat
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
@@ -967,320 +992,106 @@ export function DownloadDialog({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('download.pasteUrlButton')}
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] flex flex-col p-5 gap-0">
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => 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"
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogHeader>
|
||||
<TabsList>
|
||||
<TabsTrigger value="single" onClick={() => setActiveTab('single')}>
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
<Video className="h-3.5 w-3.5" />
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" onClick={() => setActiveTab('playlist')}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
<List className="h-3.5 w-3.5" />
|
||||
{t('download.metadata.playlist')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</DialogHeader>
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="flex flex-col flex-1 min-h-0 mt-3">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="shrink-0 mb-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-0.5 min-w-0">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground wrap-break-word">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-[10px]"
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-3 w-3" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Info and Download Options */}
|
||||
{(loading || videoInfo) && (
|
||||
<VideoInfoCard
|
||||
videoInfo={videoInfo}
|
||||
loading={loading}
|
||||
state={videoInfoCardState}
|
||||
onStateChange={(updates) =>
|
||||
setVideoInfoCardState((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
onTabChange={(tab) => {
|
||||
setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TabsContent value="single" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<SingleVideoDownload
|
||||
loading={loading}
|
||||
error={error}
|
||||
videoInfo={videoInfo}
|
||||
state={singleVideoState}
|
||||
feedbackLinks={feedbackLinks}
|
||||
onStateChange={handleSingleVideoStateChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="px-6 space-y-6 mt-3">
|
||||
<ScrollArea className="flex-1 -mx-6 overflow-y-auto min-h-0">
|
||||
<div className="space-y-6">
|
||||
{/* Preview State */}
|
||||
{playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1 shrink-0">
|
||||
<h3 className="font-semibold leading-none">{playlistInfo.title}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<List className="h-3.5 w-3.5" />
|
||||
<span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>
|
||||
{selectedPlaylistEntries.length !== playlistInfo.entryCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('playlist.selectedVideos', {
|
||||
count: selectedPlaylistEntries.length
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[320px] w-full rounded-lg border">
|
||||
<div className="p-1">
|
||||
{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 (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors cursor-pointer w-full text-left',
|
||||
isSelected || isInRange
|
||||
? 'bg-primary/10 hover:bg-primary/20'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
aria-label={t('playlist.selectEntry', { index: entry.index })}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isInRange}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(entry.id)
|
||||
} else {
|
||||
next.delete(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="shrink-0 w-8 text-[10px] font-medium text-muted-foreground tabular-nums">
|
||||
#{entry.index}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium line-clamp-1 leading-tight">
|
||||
{entry.title || t('download.fetchingVideoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{t('playlist.previewFailed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{playlistPreviewError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Content - Playlist */}
|
||||
<div
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||
advancedOptionsOpen
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="w-full pt-4 mt-4 border-t">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('playlist.range')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1"
|
||||
value={startIndex}
|
||||
onChange={(e) => {
|
||||
setStartIndex(e.target.value)
|
||||
// Clear manual selection when using range
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<Input
|
||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||
value={endIndex}
|
||||
onChange={(e) => {
|
||||
setEndIndex(e.target.value)
|
||||
// Clear manual selection when using range
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<TabsContent value="playlist" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PlaylistDownload
|
||||
playlistPreviewLoading={playlistPreviewLoading}
|
||||
playlistPreviewError={playlistPreviewError}
|
||||
playlistInfo={playlistInfo}
|
||||
playlistBusy={playlistBusy}
|
||||
selectedPlaylistEntries={selectedPlaylistEntries}
|
||||
selectedEntryIds={selectedEntryIds}
|
||||
downloadType={downloadType}
|
||||
downloadTypeId={downloadTypeId}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
advancedOptionsOpen={advancedOptionsOpen}
|
||||
setSelectedEntryIds={setSelectedEntryIds}
|
||||
setStartIndex={setStartIndex}
|
||||
setEndIndex={setEndIndex}
|
||||
setDownloadType={setDownloadType}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter className="shrink-0">
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<DialogFooter className="shrink-0 pt-3 border-t">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download Location - Single Video */}
|
||||
{activeTab === 'single' && videoInfo && !loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-[280px]">
|
||||
<div className="relative w-[240px]">
|
||||
<Input
|
||||
value={videoInfoCardState.customDownloadPath || settings.downloadPath}
|
||||
value={singleVideoState.customDownloadPath || settings.downloadPath}
|
||||
readOnly
|
||||
className="pr-8 text-xs"
|
||||
className="pr-7"
|
||||
placeholder={t('download.autoFolderPlaceholder')}
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: path
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setVideoInfoCardState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: path
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
{videoInfoCardState.customDownloadPath && (
|
||||
|
||||
{singleVideoState.customDownloadPath && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
setVideoInfoCardState((prev) => ({
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: ''
|
||||
}))
|
||||
}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t('download.useAutoFolder')}
|
||||
</Button>
|
||||
@@ -1295,11 +1106,11 @@ export function DownloadDialog({
|
||||
<Input
|
||||
value={playlistCustomDownloadPath || settings.downloadPath}
|
||||
readOnly
|
||||
className="pr-8 text-xs"
|
||||
className="pr-7 text-xs h-8 bg-muted/30"
|
||||
placeholder={t('download.autoFolderPlaceholder')}
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1307,6 +1118,7 @@ export function DownloadDialog({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={playlistBusy}
|
||||
className="h-8"
|
||||
>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
@@ -1316,6 +1128,7 @@ export function DownloadDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={playlistBusy}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t('download.useAutoFolder')}
|
||||
</Button>
|
||||
@@ -1324,7 +1137,7 @@ export function DownloadDialog({
|
||||
)}
|
||||
|
||||
{/* Advanced Options - Playlist (when no playlist info) */}
|
||||
{activeTab === 'playlist' && !playlistInfo && (
|
||||
{activeTab === 'playlist' && !playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={advancedOptionsId}
|
||||
@@ -1333,7 +1146,7 @@ export function DownloadDialog({
|
||||
setAdvancedOptionsOpen(checked === true)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer text-xs">
|
||||
{t('advancedOptions.title')}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -1341,7 +1154,7 @@ export function DownloadDialog({
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{activeTab === 'single' ? (
|
||||
!videoInfo ? (
|
||||
!videoInfo && !loading ? (
|
||||
<Button
|
||||
onClick={settings.oneClickDownload ? handleOneClickDownload : handleFetchVideo}
|
||||
disabled={loading || !url.trim()}
|
||||
@@ -1350,28 +1163,20 @@ export function DownloadDialog({
|
||||
? t('download.oneClickDownloadNow')
|
||||
: t('download.startDownload')}
|
||||
</Button>
|
||||
) : videoInfoCardState.activeTab === 'video' ? (
|
||||
) : !loading && videoInfo ? (
|
||||
<Button
|
||||
onClick={() => handleVideoDownload('video')}
|
||||
disabled={loading || !videoInfoCardState.selectedVideoFormat}
|
||||
size="lg"
|
||||
onClick={handleSingleVideoDownload}
|
||||
disabled={loading || !selectedSingleFormat}
|
||||
>
|
||||
{t('download.downloadVideo')}
|
||||
{singleVideoState.activeTab === 'video'
|
||||
? t('download.downloadVideo')
|
||||
: t('download.downloadAudio')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleVideoDownload('audio')}
|
||||
disabled={loading || !videoInfoCardState.selectedAudioFormat}
|
||||
size="lg"
|
||||
>
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
) : playlistInfo && !playlistPreviewLoading ? (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
disabled={playlistDownloadLoading || selectedPlaylistEntries.length === 0}
|
||||
size="lg"
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -1379,19 +1184,18 @@ export function DownloadDialog({
|
||||
t('playlist.downloadCurrentRange')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
) : !playlistPreviewLoading ? (
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
size="lg"
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
t('download.startDownload')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
248
src/renderer/src/components/download/PlaylistDownload.tsx
Normal file
248
src/renderer/src/components/download/PlaylistDownload.tsx
Normal file
@@ -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<string>
|
||||
downloadType: 'video' | 'audio'
|
||||
downloadTypeId: string
|
||||
startIndex: string
|
||||
endIndex: string
|
||||
advancedOptionsOpen: boolean
|
||||
setSelectedEntryIds: Dispatch<SetStateAction<Set<string>>>
|
||||
setStartIndex: Dispatch<SetStateAction<string>>
|
||||
setEndIndex: Dispatch<SetStateAction<string>>
|
||||
setDownloadType: Dispatch<SetStateAction<'video' | 'audio'>>
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t('playlist.fetchingInfo')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{t('playlist.previewFailed')}</p>
|
||||
<p className="text-xs text-muted-foreground/80">{playlistPreviewError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||
<div className="space-y-0.5 shrink-0">
|
||||
<h3 className="font-bold text-sm leading-tight line-clamp-1">{playlistInfo.title}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<List className="h-3 w-3" />
|
||||
<span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>
|
||||
{selectedPlaylistEntries.length !== playlistInfo.entryCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 w-full rounded-md border">
|
||||
<div className="p-1">
|
||||
{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 (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-1.5 rounded transition-colors cursor-pointer w-full text-left',
|
||||
isSelected || isInRange ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
aria-label={t('playlist.selectEntry', { index: entry.index })}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isInRange}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(entry.id)
|
||||
} else {
|
||||
next.delete(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="shrink-0 w-8 text-xs font-medium text-muted-foreground/70 tabular-nums">
|
||||
#{entry.index}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium line-clamp-1 leading-tight">
|
||||
{entry.title || t('download.fetchingVideoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-all duration-300 ease-out shrink-0',
|
||||
advancedOptionsOpen ? 'grid-rows-[1fr] py-3 opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="w-full pt-3 border-t">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={downloadTypeId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('playlist.downloadType')}
|
||||
</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(value) => setDownloadType(value as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId} className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video" className="text-xs">
|
||||
{t('download.video')}
|
||||
</SelectItem>
|
||||
<SelectItem value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t('playlist.range')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1"
|
||||
value={startIndex}
|
||||
onChange={(event) => {
|
||||
setStartIndex(event.target.value)
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center h-8 text-xs"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<Input
|
||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||
value={endIndex}
|
||||
onChange={(event) => {
|
||||
setEndIndex(event.target.value)
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center h-8 text-xs"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function PlaylistDownloadGroup({
|
||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2 mx-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
762
src/renderer/src/components/download/SingleVideoDownload.tsx
Normal file
762
src/renderer/src/components/download/SingleVideoDownload.tsx
Normal file
@@ -0,0 +1,762 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { AlertCircle, ExternalLink, Loader2, type LucideIcon, Settings2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
export interface SingleVideoState {
|
||||
title: string
|
||||
activeTab: 'video' | 'audio'
|
||||
selectedVideoFormat: string
|
||||
selectedAudioFormat: string
|
||||
customDownloadPath: string
|
||||
selectedContainer?: string
|
||||
selectedCodec?: string
|
||||
selectedFps?: string
|
||||
}
|
||||
|
||||
export interface FeedbackLink {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface SingleVideoDownloadProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
videoInfo: VideoInfo | null
|
||||
state: SingleVideoState
|
||||
feedbackLinks: FeedbackLink[]
|
||||
onStateChange: (state: Partial<SingleVideoState>) => void
|
||||
}
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return '00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getCodecShortName = (codec?: string): string => {
|
||||
if (!codec || codec === 'none') return 'Unknown'
|
||||
return codec.split('.')[0].toUpperCase()
|
||||
}
|
||||
|
||||
const filterFormatsByType = (
|
||||
formats: VideoInfo['formats'],
|
||||
activeTab: 'video' | 'audio'
|
||||
): VideoInfo['formats'] => {
|
||||
if (!formats) return []
|
||||
|
||||
return formats.filter((format) => {
|
||||
if (activeTab === 'video') {
|
||||
return format.vcodec && format.vcodec !== 'none'
|
||||
}
|
||||
|
||||
return (
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' ||
|
||||
!format.video_ext ||
|
||||
!format.vcodec ||
|
||||
format.vcodec === 'none')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface FormatListProps {
|
||||
formats: VideoFormat[]
|
||||
type: 'video' | 'audio'
|
||||
codec?: string
|
||||
selectedFormat: string
|
||||
onFormatChange: (formatId: string) => void
|
||||
}
|
||||
|
||||
const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: FormatListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
|
||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
|
||||
|
||||
const getFileSize = useCallback((format: VideoFormat): number => {
|
||||
return format.filesize ?? format.filesize_approx ?? 0
|
||||
}, [])
|
||||
|
||||
const sortVideoFormatsByQuality = useCallback(
|
||||
(a: VideoFormat, b: VideoFormat) => {
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return getFileSize(b) - getFileSize(a)
|
||||
},
|
||||
[getFileSize]
|
||||
)
|
||||
|
||||
const sortAudioFormatsByQuality = useCallback(
|
||||
(a: VideoFormat, b: VideoFormat) => {
|
||||
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 getFileSize(b) - getFileSize(a)
|
||||
},
|
||||
[getFileSize]
|
||||
)
|
||||
|
||||
const pickVideoFormatForPreset = useCallback(
|
||||
(presetFormats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
|
||||
if (presetFormats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const heightLimit = qualityPresetToVideoHeight[preset]
|
||||
const sorted = [...presetFormats].sort(sortVideoFormatsByQuality)
|
||||
|
||||
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]
|
||||
},
|
||||
[sortVideoFormatsByQuality]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
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 = formats.filter(isVideoFormat)
|
||||
const audios = formats.filter(isAudioFormat)
|
||||
|
||||
const groupedByHeight = new Map<number, VideoFormat[]>()
|
||||
videos.forEach((format) => {
|
||||
const height = format.height ?? 0
|
||||
const existing = groupedByHeight.get(height) || []
|
||||
existing.push(format)
|
||||
groupedByHeight.set(height, existing)
|
||||
})
|
||||
|
||||
const finalVideos = Array.from(groupedByHeight.values()).map((group) => {
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
|
||||
let finalAudios = audios
|
||||
|
||||
if (codec === 'auto' && type === 'audio') {
|
||||
const groupedByQuality = new Map<string, VideoFormat[]>()
|
||||
audios.forEach((format) => {
|
||||
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) => {
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
}
|
||||
|
||||
finalVideos.sort(sortVideoFormatsByQuality)
|
||||
finalAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
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 === selectedFormat)
|
||||
if (autoVideos.length > 0 && (!selectedFormat || !hasSelectedVideo)) {
|
||||
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
|
||||
if (preferred) {
|
||||
onFormatChange(preferred.format_id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedFormat)
|
||||
if (finalAudios.length > 0 && (!selectedFormat || !hasSelectedAudio)) {
|
||||
const best = finalAudios[0]
|
||||
onFormatChange(best.format_id)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
formats,
|
||||
settings.oneClickQuality,
|
||||
type,
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
pickVideoFormatForPreset,
|
||||
codec,
|
||||
getFileSize,
|
||||
sortVideoFormatsByQuality,
|
||||
sortAudioFormatsByQuality
|
||||
])
|
||||
|
||||
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[] = []
|
||||
parts.push(format.ext.toUpperCase())
|
||||
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[] = []
|
||||
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(' • ')
|
||||
}
|
||||
|
||||
const list = type === 'video' ? videoFormats : audioFormats
|
||||
|
||||
if (list.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroup value={selectedFormat} onValueChange={onFormatChange} className="w-full gap-1">
|
||||
{list.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)
|
||||
const isSelected = selectedFormat === format.format_id
|
||||
|
||||
return (
|
||||
<label
|
||||
key={format.format_id}
|
||||
htmlFor={`${type}-${format.format_id}`}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors rounded-md',
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={format.format_id}
|
||||
id={`${type}-${format.format_id}`}
|
||||
className="shrink-0 hidden"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-4">
|
||||
<span
|
||||
className={cn('text-sm font-medium w-16 shrink-0', isSelected && 'text-primary')}
|
||||
>
|
||||
{qualityLabel}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{thirdColumnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0 w-20 text-right">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function SingleVideoDownload({
|
||||
loading,
|
||||
error,
|
||||
videoInfo,
|
||||
state,
|
||||
feedbackLinks,
|
||||
onStateChange
|
||||
}: SingleVideoDownloadProps) {
|
||||
const { t } = useTranslation()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state
|
||||
const displayTitle = title || videoInfo?.title || t('download.fetchingVideoInfo')
|
||||
|
||||
const relevantFormats = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
return filterFormatsByType(videoInfo.formats, activeTab)
|
||||
}, [videoInfo?.formats, activeTab])
|
||||
|
||||
const containers = useMemo(() => {
|
||||
if (relevantFormats.length === 0) return []
|
||||
const exts = new Set(relevantFormats.map((format) => format.ext))
|
||||
return Array.from(exts).sort()
|
||||
}, [relevantFormats])
|
||||
|
||||
useEffect(() => {
|
||||
if (containers.length === 0) return undefined
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const formatsByContainer = useMemo(() => {
|
||||
if (relevantFormats.length === 0) return []
|
||||
|
||||
if (!selectedContainer) {
|
||||
return relevantFormats
|
||||
}
|
||||
|
||||
return relevantFormats.filter((format) => format.ext === selectedContainer)
|
||||
}, [relevantFormats, selectedContainer])
|
||||
|
||||
const codecs = useMemo(() => {
|
||||
if (formatsByContainer.length === 0) return []
|
||||
|
||||
const SetVals = new Set<string>()
|
||||
formatsByContainer.forEach((format) => {
|
||||
if (activeTab === 'video') {
|
||||
const c = format.vcodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
} else {
|
||||
const c = format.acodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(SetVals).sort()
|
||||
}, [formatsByContainer, activeTab])
|
||||
|
||||
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])
|
||||
|
||||
const formatsByCodec = useMemo(() => {
|
||||
if (!selectedCodec || selectedCodec === 'auto') return formatsByContainer
|
||||
return formatsByContainer.filter((format) => {
|
||||
if (activeTab === 'video') {
|
||||
const c = format.vcodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
}
|
||||
const c = format.acodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
})
|
||||
}, [formatsByContainer, selectedCodec, activeTab])
|
||||
|
||||
const framerates = useMemo(() => {
|
||||
if (activeTab !== 'video') return []
|
||||
const SetVals = new Set<number>()
|
||||
formatsByCodec.forEach((format) => {
|
||||
if (format.fps) SetVals.add(format.fps)
|
||||
})
|
||||
return Array.from(SetVals).sort((a, b) => b - a)
|
||||
}, [formatsByCodec, activeTab])
|
||||
|
||||
const filteredFormats = useMemo(() => {
|
||||
let res = formatsByCodec
|
||||
if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') {
|
||||
res = res.filter((format) => format.fps === Number(selectedFps))
|
||||
}
|
||||
return res
|
||||
}, [formatsByCodec, selectedFps, activeTab])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{loading && !error && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t('download.fetchingVideoInfo')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="shrink-0 mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<p className="text-sm font-medium text-destructive">{t('errors.fetchInfoFailed')}</p>
|
||||
<p className="text-xs text-muted-foreground/80 break-words">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground/70">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-5 gap-1 px-1.5 text-[10px]"
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-2.5 w-2.5" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && videoInfo && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex gap-4 py-4 shrink-0">
|
||||
<div className="shrink-0 w-32 relative rounded-md overflow-hidden bg-muted">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={displayTitle}
|
||||
className="w-full h-full object-cover aspect-video"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] px-1 rounded">
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-bold text-[13px] leading-tight line-clamp-2">{displayTitle}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{videoInfo.uploader && (
|
||||
<span className="truncate max-w-[140px] uppercase tracking-wider font-semibold opacity-70">
|
||||
{videoInfo.uploader}
|
||||
</span>
|
||||
)}
|
||||
{videoInfo.webpage_url && (
|
||||
<a
|
||||
href={videoInfo.webpage_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex p-0.5 bg-muted rounded-md gap-0.5">
|
||||
<Button
|
||||
variant={activeTab === 'video' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onStateChange({ activeTab: 'video' })}
|
||||
className={cn(
|
||||
'h-5 px-2 text-[11px] rounded-sm',
|
||||
activeTab === 'video'
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t('download.video')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'audio' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onStateChange({ activeTab: 'audio' })}
|
||||
className={cn(
|
||||
'h-5 px-2 text-[11px] rounded-sm',
|
||||
activeTab === 'audio'
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t('download.audio')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 rounded-full hover:bg-muted font-normal text-muted-foreground transition-colors',
|
||||
showAdvanced && 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-in-out',
|
||||
showAdvanced ? 'grid-rows-[1fr] py-3 border-b' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden min-h-0">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
{t('download.container') || 'Format'}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedContainer || ''}
|
||||
onValueChange={(value) => onStateChange({ selectedContainer: value })}
|
||||
disabled={containers.length <= 1}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((ext) => (
|
||||
<SelectItem key={ext} value={ext} className="text-xs">
|
||||
{ext.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
Codec
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCodec || 'auto'}
|
||||
onValueChange={(value) => onStateChange({ selectedCodec: value })}
|
||||
disabled={codecs.length <= 1}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">
|
||||
Auto
|
||||
</SelectItem>
|
||||
{codecs.map((codecName) => (
|
||||
<SelectItem key={codecName} value={codecName} className="text-xs">
|
||||
{codecName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
Frame Rate
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedFps || 'highest'}
|
||||
onValueChange={(value) => onStateChange({ selectedFps: value })}
|
||||
disabled={framerates.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Highest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="highest" className="text-xs">
|
||||
Highest
|
||||
</SelectItem>
|
||||
{framerates.map((fps) => (
|
||||
<SelectItem key={fps} value={String(fps)} className="text-xs">
|
||||
{fps} fps
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto my-3 max-h-72">
|
||||
<FormatList
|
||||
formats={filteredFormats}
|
||||
type={activeTab}
|
||||
codec={selectedCodec}
|
||||
selectedFormat={
|
||||
activeTab === 'video' ? state.selectedVideoFormat : state.selectedAudioFormat
|
||||
}
|
||||
onFormatChange={(formatId) =>
|
||||
onStateChange(
|
||||
activeTab === 'video'
|
||||
? { selectedVideoFormat: formatId }
|
||||
: { selectedAudioFormat: formatId }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<OneClickQualityPreset, number | null> = {
|
||||
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<VideoFormat[]>([])
|
||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
|
||||
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<number, VideoFormat[]>()
|
||||
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<string, VideoFormat[]>()
|
||||
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 (
|
||||
<RadioGroup value={selected} onValueChange={onFormatChange} className="w-full">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow
|
||||
key={format.format_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onFormatChange(format.format_id)}
|
||||
>
|
||||
<TableCell className="w-[24px] pl-0">
|
||||
<RadioGroupItem
|
||||
className="bg-background mt-1 border-border"
|
||||
value={format.format_id}
|
||||
id={`${type}-${format.format_id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px]">
|
||||
<div className="text-sm font-medium">{qualityLabel}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground truncate">{detailLabel}</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[70px]">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{thirdColumnLabel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px] text-right">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{sizeLabel}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return renderFormatTable()
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<div className="flex flex-col w-full flex-1 h-full min-h-0">
|
||||
{/* Header Info Skeleton */}
|
||||
<div className="flex gap-4 shrink-0 -mx-6 px-6 pb-4 shadow-sm animate-pulse">
|
||||
<div className="shrink-0 w-[96px] aspect-video rounded-md bg-muted" />
|
||||
<div className="flex-1 min-w-0 py-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="h-4 w-1/2 rounded bg-muted/70" />
|
||||
</div>
|
||||
<div className="h-3 w-1/3 rounded bg-muted/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
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<VideoInfoCardState>) => 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<string>()
|
||||
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<number>()
|
||||
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 <VideoInfoSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full flex-1 h-full min-h-0">
|
||||
{/* Header Info */}
|
||||
<div className="flex gap-4 shrink-0 -mx-6 px-6 pb-4 shadow-sm">
|
||||
<div className="shrink-0 w-[96px] aspect-video rounded-md overflow-hidden bg-muted relative">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-[10px] px-1 rounded">
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-medium text-sm leading-snug line-clamp-2">{title}</h3>
|
||||
<a
|
||||
href={videoInfo.webpage_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-primary relative top-0.5"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{videoInfo.uploader}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Area */}
|
||||
<ScrollArea className="bg-muted/30 overflow-y-auto max-h-68 -mx-6 flex-1 min-h-0">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-medium">
|
||||
{t('download.download') || 'Download'}
|
||||
</Label>
|
||||
<Select
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
onTabChange(v as 'video' | 'audio')
|
||||
onStateChange({ activeTab: v as 'video' | 'audio' })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-medium">
|
||||
{t('download.container') || 'Container'}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedContainer || ''}
|
||||
onValueChange={(v) => {
|
||||
onStateChange({ selectedContainer: v })
|
||||
}}
|
||||
disabled={containers.length === 0}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue placeholder="Select container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((ext) => (
|
||||
<SelectItem key={ext} value={ext}>
|
||||
{ext.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Codec</span>
|
||||
<Select
|
||||
value={selectedCodec || 'auto'}
|
||||
onValueChange={(v) => onStateChange({ selectedCodec: v })}
|
||||
disabled={codecs.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[70px] text-xs bg-transparent border-none shadow-none focus:ring-0 px-0 gap-1">
|
||||
<SelectValue placeholder="Auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
{codecs.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Frame Rate</span>
|
||||
<Select
|
||||
value={selectedFps || 'highest'}
|
||||
onValueChange={(v) => onStateChange({ selectedFps: v })}
|
||||
disabled={framerates.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[80px] text-xs bg-transparent border-none shadow-none focus:ring-0 px-0 gap-1">
|
||||
<SelectValue placeholder="Highest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="highest">Highest</SelectItem>
|
||||
{framerates.map((fps) => (
|
||||
<SelectItem key={fps} value={String(fps)}>
|
||||
{fps}fps
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="mt-4 min-h-[200px]">
|
||||
<FormatSelector
|
||||
formats={filteredFormats}
|
||||
type={activeTab}
|
||||
codec={selectedCodec}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user