feat(download): refine progress and dialog layout (#88)
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-radio-group':
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -1083,6 +1086,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8':
|
||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
@@ -4586,6 +4602,24 @@ snapshots:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
||||
@@ -20,15 +20,21 @@ export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
const audioFormatIds = (options.audioFormatIds ?? []).filter((id) => id.trim() !== '')
|
||||
|
||||
if (format && audioFormat === '') {
|
||||
return format
|
||||
}
|
||||
|
||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
if (format && (format.includes('/') || format.includes('+') || format.includes('['))) {
|
||||
return format
|
||||
}
|
||||
|
||||
if (audioFormatIds.length > 0) {
|
||||
const baseVideo = format && format !== 'best' ? format : 'bestvideo*'
|
||||
return `${baseVideo}+${audioFormatIds.join('+')}`
|
||||
}
|
||||
|
||||
if (!format || format === 'best') {
|
||||
if (audioFormat === 'none') {
|
||||
return 'bestvideo+none'
|
||||
@@ -77,15 +83,16 @@ export const buildDownloadArgs = (
|
||||
if (options.type === 'video') {
|
||||
const formatSelector = resolveVideoFormatSelector(options)
|
||||
args.push('-f', formatSelector)
|
||||
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
|
||||
args.push('--audio-multistreams')
|
||||
} else if (formatSelector.includes('mergeall')) {
|
||||
args.push('--audio-multistreams')
|
||||
}
|
||||
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
|
||||
// based on codec compatibility. Forcing MP4 can cause failures
|
||||
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
|
||||
} else if (options.type === 'audio') {
|
||||
args.push('-f', resolveAudioFormatSelector(options))
|
||||
} else if (options.type === 'extract') {
|
||||
args.push('-x')
|
||||
args.push('--audio-format', options.extractFormat || 'mp3')
|
||||
args.push('--audio-quality', options.extractQuality || '5')
|
||||
}
|
||||
|
||||
// Time range
|
||||
@@ -100,11 +107,9 @@ export const buildDownloadArgs = (
|
||||
const embedChapters = settings.embedChapters
|
||||
|
||||
// Subtitles
|
||||
if (options.downloadSubs || embedSubs) {
|
||||
if (embedSubs) {
|
||||
args.push('--sub-langs', 'all')
|
||||
}
|
||||
|
||||
if (options.downloadSubs) {
|
||||
} else {
|
||||
args.push('--write-subs')
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ export const resolveSelectedFormat = (
|
||||
return selectVideoFormatForPreset(videoFormats, preset)
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
if (options.type === 'audio') {
|
||||
const audioFormats = formats.filter(
|
||||
(format) =>
|
||||
!!format.acodec &&
|
||||
|
||||
@@ -80,6 +80,59 @@ const isLikelyChannelUrl = (url: string): boolean => {
|
||||
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
|
||||
}
|
||||
|
||||
const clampPercent = (value?: number): number => {
|
||||
const normalized = typeof value === 'number' ? value : 0
|
||||
if (Number.isNaN(normalized)) {
|
||||
return 0
|
||||
}
|
||||
return Math.min(100, Math.max(0, normalized))
|
||||
}
|
||||
|
||||
const estimateProgressParts = (options: DownloadOptions): number => {
|
||||
if (options.type === 'audio') {
|
||||
return 1
|
||||
}
|
||||
|
||||
const audioFormatCount = options.audioFormatIds?.filter((id) => id.trim() !== '').length ?? 0
|
||||
if (audioFormatCount > 0) {
|
||||
return 1 + audioFormatCount
|
||||
}
|
||||
|
||||
const selector = options.format?.trim()
|
||||
if (!selector) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const primary = selector.split('/')[0]?.trim()
|
||||
if (!primary) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const parts = primary
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
if (parts.some((part) => part === 'none')) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return parts.length
|
||||
}
|
||||
|
||||
const isMuxedFormat = (format?: VideoFormat): boolean => {
|
||||
if (!format) {
|
||||
return false
|
||||
}
|
||||
const hasVideo = !!format.vcodec && format.vcodec !== 'none'
|
||||
const hasAudio = !!format.acodec && format.acodec !== 'none'
|
||||
return hasVideo && hasAudio
|
||||
}
|
||||
|
||||
const resolveAutoPlaylistDownloadPath = (
|
||||
basePath: string,
|
||||
info: PlaylistInfo,
|
||||
@@ -591,6 +644,9 @@ class DownloadEngine extends EventEmitter {
|
||||
let actualFormat: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
let totalParts = estimateProgressParts(options)
|
||||
let completedParts = 0
|
||||
let lastPercent = 0
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -604,6 +660,14 @@ class DownloadEngine extends EventEmitter {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
}
|
||||
|
||||
if (
|
||||
options.type === 'video' &&
|
||||
(!options.audioFormatIds || options.audioFormatIds.length === 0) &&
|
||||
isMuxedFormat(selectedFormat)
|
||||
) {
|
||||
totalParts = 1
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
@@ -785,8 +849,23 @@ class DownloadEngine extends EventEmitter {
|
||||
: downloadedBytes
|
||||
}
|
||||
|
||||
const normalizedPercent = clampPercent(progress.percent)
|
||||
if (
|
||||
totalParts > 1 &&
|
||||
lastPercent >= 90 &&
|
||||
normalizedPercent <= 10 &&
|
||||
completedParts < totalParts - 1
|
||||
) {
|
||||
completedParts += 1
|
||||
}
|
||||
lastPercent = normalizedPercent
|
||||
const mergedPercent =
|
||||
totalParts > 1
|
||||
? ((completedParts + normalizedPercent / 100) / totalParts) * 100
|
||||
: normalizedPercent
|
||||
|
||||
const downloadProgress: DownloadProgress = {
|
||||
percent: progress.percent || 0,
|
||||
percent: Math.min(100, mergedPercent),
|
||||
currentSpeed: progress.currentSpeed || '',
|
||||
eta: progress.eta || '',
|
||||
downloaded: progress.downloaded || '',
|
||||
@@ -844,7 +923,8 @@ class DownloadEngine extends EventEmitter {
|
||||
// based on codec compatibility, so we should use actualFormat when available
|
||||
let extension: string
|
||||
if (options.type === 'audio') {
|
||||
extension = options.extractFormat || 'mp3'
|
||||
// Use format extension from yt-dlp output (actualFormat contains the extension)
|
||||
extension = actualFormat || 'm4a'
|
||||
} else if (willMerge) {
|
||||
// For merged files, yt-dlp auto-selects format (mkv/webm/mp4)
|
||||
// Use actualFormat if available, otherwise default to mkv (most compatible)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,7 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||
if (!format) {
|
||||
return undefined
|
||||
}
|
||||
if (download.type === 'audio' || download.type === 'extract') {
|
||||
if (download.type === 'audio') {
|
||||
return sanitizeCodec(format.acodec)
|
||||
}
|
||||
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
|
||||
@@ -678,7 +678,7 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
: {})}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
|
||||
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 h-14 aspect-video pointer-events-none">
|
||||
{selectionEnabled && (
|
||||
<div
|
||||
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
|
||||
@@ -711,6 +711,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
|
||||
{download.title}
|
||||
</p>
|
||||
{download.type === 'audio' && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('download.audio')}
|
||||
</Badge>
|
||||
)}
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
|
||||
42
src/renderer/src/components/ui/radio-group.tsx
Normal file
42
src/renderer/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { CircleIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
89
src/renderer/src/components/ui/table.tsx
Normal file
89
src/renderer/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type * as React from 'react'
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||
return <thead data-slot="table-header" className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({ className, ...props }: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
|
||||
export interface AudioExtractorState {
|
||||
extractFormat: string
|
||||
extractQuality: string
|
||||
}
|
||||
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
state: AudioExtractorState
|
||||
onStateChange: (state: Partial<AudioExtractorState>) => void
|
||||
}
|
||||
|
||||
export function AudioExtractor({
|
||||
videoInfo: _videoInfo,
|
||||
state,
|
||||
onStateChange
|
||||
}: AudioExtractorProps) {
|
||||
const { t } = useTranslation()
|
||||
const { extractFormat, extractQuality } = state
|
||||
|
||||
const audioFormats = [
|
||||
{ value: 'mp3', label: 'MP3' },
|
||||
{ value: 'm4a', label: 'M4A' },
|
||||
{ value: 'opus', label: 'Opus' },
|
||||
{ value: 'wav', label: 'WAV' },
|
||||
{ value: 'flac', label: 'FLAC' },
|
||||
{ value: 'alac', label: 'ALAC' },
|
||||
{ value: 'vorbis', label: 'Vorbis (OGG)' }
|
||||
]
|
||||
|
||||
const qualities = [
|
||||
{ value: '0', label: t('audioExtract.best') },
|
||||
{ value: '2', label: t('audioExtract.good') },
|
||||
{ value: '5', label: t('audioExtract.normal') },
|
||||
{ value: '8', label: t('audioExtract.bad') },
|
||||
{ value: '10', label: t('audioExtract.worst') }
|
||||
]
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
||||
<Select
|
||||
value={extractFormat}
|
||||
onValueChange={(value) => onStateChange({ extractFormat: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.value} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||
<Select
|
||||
value={extractQuality}
|
||||
onValueChange={(value) => onStateChange({ extractQuality: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{qualities.map((quality) => (
|
||||
<SelectItem key={quality.value} value={quality.value}>
|
||||
{quality.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'
|
||||
import { Table, TableBody, TableCell, TableRow } from '@renderer/components/ui/table'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -26,13 +20,15 @@ interface FormatSelectorProps {
|
||||
type: 'video' | 'audio'
|
||||
onVideoFormatChange?: (format: string) => void
|
||||
onAudioFormatChange?: (format: string) => void
|
||||
codec?: string // 'auto' or specific codec name
|
||||
}
|
||||
|
||||
export function FormatSelector({
|
||||
formats,
|
||||
type,
|
||||
onVideoFormatChange,
|
||||
onAudioFormatChange
|
||||
onAudioFormatChange,
|
||||
codec
|
||||
}: FormatSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
@@ -71,44 +67,72 @@ export function FormatSelector({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Filter and sort formats
|
||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||
// Formats are already filtered by VideoInfoCard based on Container, Codec, etc.
|
||||
// We just need to separate video and audio formats, exclude HLS, and sort them.
|
||||
const isHlsFormat = (format: VideoFormat) =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
|
||||
// Filter out HLS formats and separate by type
|
||||
const filteredFormats = formats.filter((f) => !isHlsFormat(f))
|
||||
|
||||
const isVideoFormat = (format: VideoFormat) =>
|
||||
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||
const isAudioFormat = (format: VideoFormat) =>
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' || !format.video_ext)
|
||||
const isHlsFormat = (format: VideoFormat) =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
(format.video_ext === 'none' ||
|
||||
!format.video_ext ||
|
||||
!format.vcodec ||
|
||||
format.vcodec === 'none')
|
||||
|
||||
const videoCandidates = formats.filter(
|
||||
(format) => isVideoFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
const audioCandidates = formats.filter(
|
||||
(format) => isAudioFormat(format) && !isHlsFormat(format)
|
||||
)
|
||||
const videos = filteredFormats.filter(isVideoFormat)
|
||||
const audios = filteredFormats.filter(isAudioFormat)
|
||||
|
||||
const videos =
|
||||
videoCandidates.length > 0
|
||||
? videoCandidates
|
||||
: formats.filter((format) => isVideoFormat(format))
|
||||
const audios =
|
||||
audioCandidates.length > 0
|
||||
? audioCandidates
|
||||
: formats.filter((format) => isAudioFormat(format))
|
||||
// Get file size for comparison (prefer filesize over filesize_approx)
|
||||
const getFileSize = (format: VideoFormat): number => {
|
||||
return format.filesize ?? format.filesize_approx ?? 0
|
||||
}
|
||||
|
||||
// Apply showMoreFormats filter
|
||||
const filteredVideos = settings.showMoreFormats
|
||||
? videos
|
||||
: videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp'))
|
||||
// When codec is 'auto', filter to show only the largest file size per resolution
|
||||
let finalVideos = videos
|
||||
let finalAudios = audios
|
||||
|
||||
const filteredAudios = settings.showMoreFormats
|
||||
? audios
|
||||
: audios.filter((f) => f.ext !== 'webm')
|
||||
if (codec === 'auto') {
|
||||
if (type === 'video') {
|
||||
// Group by height (resolution) and keep only the one with largest file size
|
||||
const groupedByHeight = new Map<number, VideoFormat[]>()
|
||||
videos.forEach((format) => {
|
||||
const height = format.height ?? 0
|
||||
const existing = groupedByHeight.get(height) || []
|
||||
existing.push(format)
|
||||
groupedByHeight.set(height, existing)
|
||||
})
|
||||
|
||||
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
|
||||
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
|
||||
finalVideos = Array.from(groupedByHeight.values()).map((group) => {
|
||||
// Sort by file size descending and take the first (largest)
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
} else {
|
||||
// For audio, group by quality/tbr and keep only the one with largest file size
|
||||
const groupedByQuality = new Map<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) => {
|
||||
@@ -155,8 +179,8 @@ export function FormatSelector({
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
// Auto-select best format based on preferences.
|
||||
// If no separate audio formats exist, prefer muxed video formats (with audio).
|
||||
// Auto-select best format based on preferences
|
||||
if (type === 'video') {
|
||||
const videosWithAudio = finalVideos.filter(
|
||||
(format) => format.acodec && format.acodec !== 'none'
|
||||
)
|
||||
@@ -167,27 +191,32 @@ export function FormatSelector({
|
||||
? videosWithAudio
|
||||
: finalVideos
|
||||
|
||||
if (autoVideos.length > 0 && !selectedVideo) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (finalAudios.length > 0 && !selectedAudio) {
|
||||
} 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,
|
||||
settings.oneClickQuality,
|
||||
type,
|
||||
selectedVideo,
|
||||
selectedAudio,
|
||||
onAudioFormatChange,
|
||||
onVideoFormatChange,
|
||||
pickVideoFormatForPreset
|
||||
pickVideoFormatForPreset,
|
||||
codec
|
||||
])
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
@@ -196,141 +225,130 @@ export function FormatSelector({
|
||||
return `${mb.toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const formatVideoLabel = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Resolution
|
||||
const formatVideoQuality = (format: VideoFormat) => {
|
||||
if (format.height) {
|
||||
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatAudioQuality = (format: VideoFormat) => {
|
||||
if (format.tbr) {
|
||||
return `${Math.round(format.tbr)} kbps`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatVideoDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Format extension
|
||||
parts.push(format.ext.toUpperCase())
|
||||
// Codec (if showMoreFormats is enabled)
|
||||
if (settings.showMoreFormats && format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0])
|
||||
// Codec information
|
||||
if (format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
// Audio indicator
|
||||
if (format.acodec !== 'none') {
|
||||
parts.push('🔊')
|
||||
}
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
if (format.acodec && format.acodec !== 'none') {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioLabel = (format: VideoFormat) => {
|
||||
const formatAudioDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Quality
|
||||
const quality = format.format_note || t('download.unknownQuality')
|
||||
parts.push(quality)
|
||||
// Format extension
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
// File size
|
||||
const size = formatSize(format.filesize || format.filesize_approx)
|
||||
if (size !== t('download.unknownSize')) {
|
||||
parts.push(size)
|
||||
if (format.acodec) {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
if (videoFormats.length === 0 && audioFormats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{videoFormats.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||
<Select
|
||||
value={selectedVideo}
|
||||
onValueChange={(value) => {
|
||||
// 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)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{videoFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>{formatVideoLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioFormats.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
}
|
||||
: (value: string) => {
|
||||
setSelectedAudio(value)
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none" className="cursor-pointer">
|
||||
<span>{t('download.noAudio')}</span>
|
||||
</SelectItem>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem
|
||||
key={format.format_id}
|
||||
value={format.format_id}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Audio only
|
||||
if (audioFormats.length === 0) {
|
||||
if (formats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||
<Select
|
||||
value={selectedAudio}
|
||||
onValueChange={(value) => {
|
||||
setSelectedAudio(value)
|
||||
onAudioFormatChange?.(value)
|
||||
}}
|
||||
<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)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{audioFormats.map((format) => (
|
||||
<SelectItem key={format.format_id} value={format.format_id} className="cursor-pointer">
|
||||
<span>{formatAudioLabel(format)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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,38 +1,57 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
|
||||
import { Clock, Eye, Play } from 'lucide-react'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
|
||||
import { FormatSelector } from './FormatSelector'
|
||||
|
||||
const VideoInfoSkeleton = () => (
|
||||
<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
|
||||
selectedAudioForVideo: string
|
||||
selectedAudioFormat: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
downloadSubs: boolean
|
||||
customDownloadPath: string
|
||||
audioExtractor: AudioExtractorState
|
||||
selectedContainer?: string
|
||||
selectedCodec?: string
|
||||
selectedFps?: string
|
||||
}
|
||||
|
||||
interface VideoInfoCardProps {
|
||||
videoInfo: VideoInfo
|
||||
videoInfo: VideoInfo | null
|
||||
loading?: boolean
|
||||
state: VideoInfoCardState
|
||||
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||
onTabChange: (tab: 'video' | 'audio') => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return 'Unknown'
|
||||
if (!seconds) return '00:00'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
@@ -40,123 +59,327 @@ function formatDuration(seconds?: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatViews(views?: number): string {
|
||||
if (!views) return 'Unknown'
|
||||
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M`
|
||||
if (views >= 1000) return `${(views / 1000).toFixed(1)}K`
|
||||
return views.toString()
|
||||
function getCodecShortName(codec?: string): string {
|
||||
if (!codec || codec === 'none') return 'Unknown'
|
||||
return codec.split('.')[0].toUpperCase()
|
||||
}
|
||||
|
||||
export function VideoInfoCard({
|
||||
videoInfo,
|
||||
loading = false,
|
||||
state,
|
||||
onStateChange,
|
||||
onTabChange
|
||||
}: VideoInfoCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)
|
||||
|
||||
const { title, activeTab } = state
|
||||
const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state
|
||||
|
||||
// Get unique containers based on activeTab
|
||||
const containers = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
const relevantFormats = videoInfo.formats.filter((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// In video mode, we want formats with video codec
|
||||
return f.vcodec && f.vcodec !== 'none'
|
||||
} else {
|
||||
// In audio mode, we only want audio-only formats (no video)
|
||||
return (
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none')
|
||||
)
|
||||
}
|
||||
})
|
||||
const exts = new Set(relevantFormats.map((f) => f.ext))
|
||||
return Array.from(exts).sort()
|
||||
}, [videoInfo?.formats, activeTab])
|
||||
|
||||
// Set default container if not set or if current container is not in the list
|
||||
useEffect(() => {
|
||||
if (containers.length === 0) return undefined
|
||||
|
||||
// Reset container if it's not in the current containers list (e.g., after tab switch)
|
||||
if (selectedContainer && !containers.includes(selectedContainer)) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Set default container if not set
|
||||
if (!selectedContainer) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [containers, selectedContainer, activeTab, onStateChange])
|
||||
|
||||
// Step 1: Filter formats based on activeTab and selected container
|
||||
const formatsByContainer = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
|
||||
// First filter by activeTab type
|
||||
let filteredByType = videoInfo.formats.filter((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// For video, only formats with video codec
|
||||
return f.vcodec && f.vcodec !== 'none'
|
||||
} else {
|
||||
// For audio, only audio-only formats (no video)
|
||||
return (
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Then filter by selected container if one is selected
|
||||
if (selectedContainer) {
|
||||
filteredByType = filteredByType.filter((f) => f.ext === selectedContainer)
|
||||
}
|
||||
|
||||
return filteredByType
|
||||
}, [videoInfo?.formats, selectedContainer, activeTab])
|
||||
|
||||
// Get unique Codecs from formatsByContainer based on activeTab
|
||||
// This should only show codecs that exist in the filtered format list
|
||||
const codecs = useMemo(() => {
|
||||
if (formatsByContainer.length === 0) return []
|
||||
|
||||
const SetVals = new Set<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>
|
||||
<Card className="overflow-hidden border shadow-sm">
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
|
||||
<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"
|
||||
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-[10px] px-1 rounded">
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
|
||||
<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"
|
||||
>
|
||||
{videoInfo.extractor_key || t('download.videoInfo')}
|
||||
</Badge>
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{videoInfo.uploader}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
|
||||
{videoInfo.uploader && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="p-3 pt-3">
|
||||
<Tabs
|
||||
{/* 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' })
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
|
||||
<TabsTrigger value="video" className="text-xs">
|
||||
{t('download.video')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TabsContent value="video" className="space-y-3 mt-0">
|
||||
<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={videoInfo.formats || []}
|
||||
type="video"
|
||||
formats={filteredFormats}
|
||||
type={activeTab}
|
||||
codec={selectedCodec}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audio" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
|
||||
<AudioExtractor
|
||||
videoInfo={videoInfo}
|
||||
state={state.audioExtractor}
|
||||
onStateChange={(updates) =>
|
||||
onStateChange({
|
||||
audioExtractor: { ...state.audioExtractor, ...updates }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,17 +105,6 @@
|
||||
"description": "Download videos and audios from hundreds of sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Bad",
|
||||
"best": "Best",
|
||||
"extract": "Extract",
|
||||
"good": "Good",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Select Format",
|
||||
"selectQuality": "Select Quality",
|
||||
"title": "Extract Audio",
|
||||
"worst": "Worst"
|
||||
},
|
||||
"download": {
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
@@ -160,6 +149,7 @@
|
||||
"paste": "Paste",
|
||||
"pastePlaylistUrl": "Click to paste playlist link from clipboard [Ctrl + V]",
|
||||
"pasteUrl": "Click to paste video URL or ID [Ctrl + V]",
|
||||
"pasteUrlButton": "Paste URL",
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
@@ -234,6 +224,7 @@
|
||||
"emptyUrl": "Please enter a URL",
|
||||
"errorDetails": "Error Details",
|
||||
"fetchInfoFailed": "Failed to fetch video information",
|
||||
"invalidUrl": "The clipboard content is not a valid URL",
|
||||
"networkError": "Some error has occurred. Check your network and use correct URL",
|
||||
"pasteFromClipboard": "Failed to paste from clipboard"
|
||||
},
|
||||
@@ -362,6 +353,8 @@
|
||||
"selectedVideos": "{{count}} selected",
|
||||
"downloadCurrentRange": "Download Selected",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"selectEntry": "Select entry {{index}}",
|
||||
"noEntriesSelected": "No entries selected",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist",
|
||||
"totalVideos": "Total videos: {{count}}",
|
||||
|
||||
@@ -498,27 +498,6 @@ export function Settings() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-2">
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.showMoreFormatsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.showMoreFormats ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('showMoreFormats', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling showMoreFormats:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface VideoFormat {
|
||||
tbr?: number
|
||||
quality?: number
|
||||
protocol?: string // http, https, m3u8, m3u8_native, etc.
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
@@ -54,7 +55,7 @@ export interface DownloadItem {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
status: DownloadStatus
|
||||
progress?: DownloadProgress
|
||||
error?: string
|
||||
@@ -99,7 +100,7 @@ export interface DownloadHistoryItem {
|
||||
url: string
|
||||
title: string
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
status: DownloadStatus
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
@@ -127,11 +128,10 @@ export interface DownloadHistoryItem {
|
||||
|
||||
export interface DownloadOptions {
|
||||
url: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
type: 'video' | 'audio'
|
||||
format?: string
|
||||
audioFormat?: string
|
||||
extractFormat?: string
|
||||
extractQuality?: string
|
||||
audioFormatIds?: string[]
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
@@ -253,7 +253,6 @@ export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
export interface AppSettings {
|
||||
downloadPath: string
|
||||
showMoreFormats: boolean
|
||||
maxConcurrentDownloads: number
|
||||
browserForCookies: string
|
||||
cookiesPath: string
|
||||
@@ -281,7 +280,6 @@ export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
downloadPath: '',
|
||||
showMoreFormats: false,
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
|
||||
Reference in New Issue
Block a user