Compare commits

..

4 Commits

Author SHA1 Message Date
Nexmoe
2bf7af9b25 chore: release v1.1.12 2026-01-15 21:23:02 +08:00
Nexmoe
fe55ac1a79 fix(settings): avoid creating default download folder (#116) 2026-01-15 21:21:09 +08:00
Nexmoe
c1d1a1b912 feat(feedback): attach yt-dlp command (#114) 2026-01-15 21:07:37 +08:00
Nexmoe
9d377dd464 refactor(download): split dialog into single/playlist (#115) 2026-01-15 20:49:24 +08:00
18 changed files with 1600 additions and 1494 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "1.1.11",
"version": "1.1.12",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",

View File

@@ -5,7 +5,8 @@ import type {
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../../shared/types'
import { downloadEngine } from '../../lib/download-engine'
@@ -17,6 +18,14 @@ class DownloadService extends IpcService {
return downloadEngine.getVideoInfo(url)
}
@IpcMethod()
async getVideoInfoWithCommand(
_context: IpcContext,
url: string
): Promise<VideoInfoCommandResult> {
return downloadEngine.getVideoInfoWithCommand(url)
}
@IpcMethod()
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
return downloadEngine.getPlaylistInfo(url)

View File

@@ -15,6 +15,7 @@ export const downloadHistoryTable = sqliteTable('download_history', {
completedAt: integer('completed_at', { mode: 'number' }),
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
error: text('error'),
ytDlpCommand: text('yt_dlp_command'),
description: text('description'),
channel: text('channel'),
uploader: text('uploader'),

View File

@@ -11,7 +11,8 @@ import type {
PlaylistDownloadResult,
PlaylistInfo,
VideoFormat,
VideoInfo
VideoInfo,
VideoInfoCommandResult
} from '../../shared/types'
import {
buildDownloadArgs,
@@ -218,6 +219,44 @@ const appendJsRuntimeArgs = (args: string[]): void => {
}
}
const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsManager.getAll>) => {
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
return args
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue
@@ -236,39 +275,7 @@ class DownloadEngine extends EventEmitter {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = ['-j', '--no-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
}
// Add browser cookies if configured (skip if 'none')
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
const configPath = resolvePathWithHome(settings.configPath)
if (configPath) {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
const args = buildVideoInfoArgs(url, settings)
return new Promise((resolve, reject) => {
const process = ytdlp.exec(args)
@@ -334,6 +341,90 @@ class DownloadEngine extends EventEmitter {
})
}
async getVideoInfoWithCommand(url: string): Promise<VideoInfoCommandResult> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = buildVideoInfoArgs(url, settings)
const ytDlpCommand = formatYtDlpCommand(args)
return new Promise((resolve) => {
let settled = false
const resolveOnce = (payload: VideoInfoCommandResult) => {
if (settled) {
return
}
settled = true
resolve(payload)
}
const process = ytdlp.exec(args)
let stdout = ''
let stderr = ''
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
stdout += data.toString()
})
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
})
process.on('close', (code) => {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
// Calculate estimated file size for formats missing filesize information
// Using tbr (total bitrate in kbps) and duration (in seconds)
// Formula: (tbr * 1000) / 8 * duration = size in bytes
if (info.formats && Array.isArray(info.formats) && info.duration) {
const duration = info.duration
for (const format of info.formats) {
if (
!format.filesize &&
!format.filesize_approx &&
format.tbr &&
typeof format.tbr === 'number' &&
duration > 0
) {
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
format.filesize_approx = estimatedSize
}
}
}
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolveOnce({ info, ytDlpCommand })
} catch (error) {
scopedLoggers.download.error('Failed to parse video info for:', url, error)
resolveOnce({
ytDlpCommand,
error: `Failed to parse video info: ${error instanceof Error ? error.message : error}`
})
}
} else {
scopedLoggers.download.error(
'Failed to fetch video info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' })
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error for:', url, error)
resolveOnce({
ytDlpCommand,
error: error instanceof Error ? error.message : 'Failed to fetch video info'
})
})
})
}
async getPlaylistInfo(url: string): Promise<PlaylistInfo> {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
@@ -809,7 +900,9 @@ class DownloadEngine extends EventEmitter {
args.push('--ffmpeg-location', ffmpegPath)
args.push(urlArg)
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
const ytDlpCommand = formatYtDlpCommand(args)
this.updateDownloadInfo(id, { ytDlpCommand })
scopedLoggers.download.info('yt-dlp command:', ytDlpCommand)
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
@@ -1146,6 +1239,9 @@ class DownloadEngine extends EventEmitter {
if (updates.error !== undefined) {
historyUpdates.error = updates.error
}
if (updates.ytDlpCommand !== undefined) {
historyUpdates.ytDlpCommand = updates.ytDlpCommand
}
if (updates.savedFileName !== undefined) {
historyUpdates.savedFileName = updates.savedFileName
}
@@ -1210,6 +1306,7 @@ class DownloadEngine extends EventEmitter {
downloadedAt: updates.downloadedAt ?? Date.now(),
completedAt: updates.completedAt,
error: updates.error,
ytDlpCommand: updates.ytDlpCommand,
description: updates.description,
channel: updates.channel,
uploader: updates.uploader,

View File

@@ -36,6 +36,7 @@ const createDownloadHistoryTableSql = sql`
completed_at INTEGER,
sort_key INTEGER NOT NULL,
error TEXT,
yt_dlp_command TEXT,
description TEXT,
channel TEXT,
uploader TEXT,
@@ -218,7 +219,12 @@ class HistoryManager {
}
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
const requiredColumns = ['yt_dlp_command']
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
const missingRequired = requiredColumns.some(
(columnName) => !columns.some((column) => column.name === columnName)
)
const needsRebuild = hasDeprecated || missingRequired
if (needsRebuild) {
this.rebuildDownloadHistoryTable()
}
@@ -387,6 +393,7 @@ class HistoryManager {
completedAt: item.completedAt ?? null,
sortKey: item.completedAt ?? item.downloadedAt,
error: item.error ?? null,
ytDlpCommand: item.ytDlpCommand ?? null,
description: item.description ?? null,
channel: item.channel ?? null,
uploader: item.uploader ?? null,
@@ -433,6 +440,7 @@ class HistoryManager {
downloadedAt: row.downloadedAt,
completedAt: row.completedAt ?? undefined,
error: row.error ?? undefined,
ytDlpCommand: row.ytDlpCommand ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,

View File

@@ -20,9 +20,7 @@ const ensureDirectoryExists = (dir: string) => {
}
const resolveDefaultDownloadPath = () => {
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
ensureDirectoryExists(downloadDir)
return downloadDir
return path.join(os.homedir(), 'Downloads', 'VidBee')
}
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
@@ -75,7 +73,6 @@ class SettingsManager {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH
})
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
}
private ensureDownloadDirectory(): void {
@@ -88,7 +85,6 @@ class SettingsManager {
if (normalizedDownloadPath !== currentPath) {
this.store.set('downloadPath', normalizedDownloadPath)
}
ensureDirectoryExists(normalizedDownloadPath)
} catch (error) {
scopedLoggers.system.error('Failed to verify download directory:', error)
}

View File

@@ -3,34 +3,15 @@ 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,
buildVideoFormatPreference
} from '@shared/utils/format-preferences'
import { useAtom, useSetAtom } from 'jotai'
import {
AlertCircle,
FolderOpen,
Github,
List,
Loader2,
MessageCircle,
Plus,
Twitter,
Video
} from 'lucide-react'
import { FolderOpen, List, Loader2, Plus, Video } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -45,10 +26,12 @@ import { loadSettingsAtom, settingsAtom } from '../../store/settings'
import {
currentVideoInfoAtom,
fetchVideoInfoAtom,
videoInfoCommandAtom,
videoInfoErrorAtom,
videoInfoLoadingAtom
} from '../../store/video'
import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard'
import { PlaylistDownload } from './PlaylistDownload'
import { SingleVideoDownload, type SingleVideoState } from './SingleVideoDownload'
const isLikelyUrl = (value: string): boolean => {
try {
@@ -59,35 +42,6 @@ const isLikelyUrl = (value: string): boolean => {
}
}
const normalizeErrorText = (value?: string | null): string =>
value ? value.replace(/\s+/g, ' ').trim() : ''
const clampText = (value: string, maxLength: number): string =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
const FEEDBACK_ISSUE_TITLE = 'Download error report'
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
const FEEDBACK_SOURCE_LABEL = 'Source URL'
const FEEDBACK_ERROR_LABEL = 'Error'
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
const buildIssueLogs = (
errorText: string,
sourceUrl: string | undefined,
urlLabel: string,
errorLabel: string
): string => {
const lines: string[] = []
if (sourceUrl) {
lines.push(`${urlLabel}: ${sourceUrl}`)
}
lines.push(`${errorLabel}: ${errorText}`)
return lines.join('\n')
}
const isAudioOnlyFormat = (format: VideoFormat): boolean =>
!!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none')
@@ -141,6 +95,7 @@ const pickBestAudioFormatsByLanguage = (formats: VideoFormat[]): string[] => {
})
.filter((id): id is string => !!id)
}
interface DownloadDialogProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
@@ -151,10 +106,9 @@ export function DownloadDialog({
onOpenSettings: _onOpenSettings
}: DownloadDialogProps) {
const { t } = useTranslation()
const [appVersion, setAppVersion] = useState('')
const [osVersion, setOsVersion] = useState('')
const [open, setOpen] = useState(false)
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [videoInfoCommand] = useAtom(videoInfoCommandAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
const [error] = useAtom(videoInfoErrorAtom)
const [settings] = useAtom(settingsAtom)
@@ -168,13 +122,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,74 +149,6 @@ export function DownloadDialog({
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set())
const feedbackLinks = useMemo(() => {
const compactError = normalizeErrorText(error)
const tweetError = compactError ? clampText(compactError, 160) : ''
const tweetText = encodeURIComponent(
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
)
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
const issueTitle = FEEDBACK_ISSUE_TITLE
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
const sourceUrl = url.trim() || undefined
const issueLogs = clampText(
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
800
)
const appVersionValue = appVersion
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
: FEEDBACK_UNKNOWN_VALUE
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
return [
{
icon: Github,
label: t('about.resources.githubIssues'),
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
issueTitle
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
issueLogs
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
osVersionValue
)}`
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
href: `https://x.com/intent/tweet?text=${tweetText}`
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
href: 'https://discord.gg/uBqXV6QPdm'
}
]
}, [appVersion, error, osVersion, t, url])
useEffect(() => {
let isActive = true
const loadAppInfo = async () => {
try {
const [version, osRelease] = await Promise.all([
ipcServices.app.getVersion(),
ipcServices.app.getOsVersion()
])
if (!isActive) {
return
}
setAppVersion(version)
setOsVersion(osRelease)
} catch (loadError) {
console.error('Failed to load app info for feedback links:', loadError)
}
}
void loadAppInfo()
return () => {
isActive = false
}
}, [])
const computePlaylistRange = useCallback(
(info: PlaylistInfo) => {
@@ -373,6 +262,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)
}
@@ -489,7 +386,11 @@ export function DownloadDialog({
})
try {
const videoInfo = await ipcServices.download.getVideoInfo(trimmedUrl)
const result = await ipcServices.download.getVideoInfoWithCommand(trimmedUrl)
if (!result.info) {
throw new Error(result.error || 'Failed to fetch video info')
}
const videoInfo = result.info
updateDownload({
id,
@@ -552,6 +453,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 +768,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 +845,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 +867,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 +886,107 @@ 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}
feedbackSourceUrl={url}
ytDlpCommand={videoInfoCommand ?? undefined}
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 +1001,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 +1013,7 @@ export function DownloadDialog({
variant="outline"
size="sm"
disabled={playlistBusy}
className="h-8"
>
{t('settings.selectPath')}
</Button>
@@ -1316,6 +1023,7 @@ export function DownloadDialog({
variant="ghost"
size="sm"
disabled={playlistBusy}
className="h-8 text-xs"
>
{t('download.useAutoFolder')}
</Button>
@@ -1324,7 +1032,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 +1041,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 +1049,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 +1058,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 +1079,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>

View File

@@ -1,3 +1,7 @@
import {
DOWNLOAD_FEEDBACK_ISSUE_TITLE,
FeedbackLinkButtons
} from '@renderer/components/feedback/FeedbackLinks'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
@@ -17,16 +21,13 @@ import {
CheckCircle2,
Copy,
FolderOpen,
Github,
Info,
Loader2,
MessageCircle,
Play,
Trash2,
Twitter,
X
} from 'lucide-react'
import { type ReactNode, useEffect, useMemo, useState } from 'react'
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../../lib/ipc'
@@ -203,72 +204,8 @@ const formatDateShort = (timestamp?: number) => {
})
}
const normalizeErrorText = (value?: string | null): string =>
value ? value.replace(/\s+/g, ' ').trim() : ''
const clampText = (value: string, maxLength: number): string =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
const FEEDBACK_ISSUE_TITLE = 'Download error report'
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
const FEEDBACK_SOURCE_LABEL = 'Source URL'
const FEEDBACK_ERROR_LABEL = 'Error'
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
const buildIssueLogs = (
errorText: string,
sourceUrl: string | undefined,
urlLabel: string,
errorLabel: string
): string => {
const lines: string[] = []
if (sourceUrl) {
lines.push(`${urlLabel}: ${sourceUrl}`)
}
lines.push(`${errorLabel}: ${errorText}`)
return lines.join('\n')
}
type AppInfo = {
appVersion: string
osVersion: string
}
let cachedAppInfo: AppInfo | null = null
let appInfoPromise: Promise<AppInfo> | null = null
const loadAppInfo = async (): Promise<AppInfo> => {
if (cachedAppInfo) {
return cachedAppInfo
}
if (appInfoPromise) {
return appInfoPromise
}
appInfoPromise = (async () => {
try {
const [version, osRelease] = await Promise.all([
ipcServices.app.getVersion(),
ipcServices.app.getOsVersion()
])
cachedAppInfo = { appVersion: version, osVersion: osRelease }
} catch (error) {
console.error('Failed to load app info for feedback links:', error)
cachedAppInfo = { appVersion: '', osVersion: '' }
}
return cachedAppInfo
})()
return appInfoPromise
}
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
const { t } = useTranslation()
const [appVersion, setAppVersion] = useState('')
const [osVersion, setOsVersion] = useState('')
const settings = useAtomValue(settingsAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const removeHistory = useSetAtom(removeHistoryRecordAtom)
@@ -281,66 +218,6 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
const resolvedExtension = resolveDownloadExtension(download)
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
const selectionEnabled = isHistory && Boolean(onToggleSelect)
const feedbackLinks = useMemo(() => {
const compactError = normalizeErrorText(download.error)
const tweetError = compactError ? clampText(compactError, 160) : ''
const tweetText = encodeURIComponent(
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
)
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
const issueTitle = FEEDBACK_ISSUE_TITLE
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
const sourceUrl = download.url?.trim() || undefined
const issueLogs = clampText(
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
800
)
const appVersionValue = appVersion
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
: FEEDBACK_UNKNOWN_VALUE
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
return [
{
icon: Github,
label: t('about.resources.githubIssues'),
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
issueTitle
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
issueLogs
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
osVersionValue
)}`
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
href: `https://x.com/intent/tweet?text=${tweetText}`
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
href: 'https://discord.gg/uBqXV6QPdm'
}
]
}, [appVersion, download.error, download.url, osVersion, t])
useEffect(() => {
let isActive = true
const fetchInfo = async () => {
const info = await loadAppInfo()
if (!isActive) {
return
}
setAppVersion(info.appVersion)
setOsVersion(info.osVersion)
}
void fetchInfo()
return () => {
isActive = false
}
}, [])
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
@@ -1065,24 +942,18 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
{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]"
onClick={(event) => event.stopPropagation()}
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer">
<Icon className="h-3 w-3" />
{resource.label}
</a>
</Button>
)
})}
<FeedbackLinkButtons
error={download.error}
sourceUrl={download.url}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={download.ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-6 gap-1 px-1.5 text-[10px]"
iconClassName="h-3 w-3"
onLinkClick={(event) => event.stopPropagation()}
/>
</div>
</div>
</div>

View 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>
)}
</>
)
}

View File

@@ -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"

View File

@@ -0,0 +1,753 @@
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, 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'
import { DOWNLOAD_FEEDBACK_ISSUE_TITLE, FeedbackLinkButtons } from '../feedback/FeedbackLinks'
export interface SingleVideoState {
title: string
activeTab: 'video' | 'audio'
selectedVideoFormat: string
selectedAudioFormat: string
customDownloadPath: string
selectedContainer?: string
selectedCodec?: string
selectedFps?: string
}
interface SingleVideoDownloadProps {
loading: boolean
error: string | null
videoInfo: VideoInfo | null
state: SingleVideoState
feedbackSourceUrl?: string | null
ytDlpCommand?: string
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,
feedbackSourceUrl,
ytDlpCommand,
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">
<FeedbackLinkButtons
error={error}
sourceUrl={feedbackSourceUrl}
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
includeAppInfo
ytDlpCommand={ytDlpCommand}
buttonVariant="outline"
buttonSize="sm"
buttonClassName="h-5 gap-1 px-1.5 text-[10px]"
iconClassName="h-2.5 w-2.5"
/>
</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>
)
}

View File

@@ -0,0 +1,213 @@
import { Button, type ButtonProps } from '@renderer/components/ui/button'
import { ipcServices } from '@renderer/lib/ipc'
import { Github, MessageCircle, Twitter } from 'lucide-react'
import { type MouseEvent, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
type AppInfo = {
appVersion: string
osVersion: string
}
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[bug]: Download error report'
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
const FEEDBACK_SOURCE_LABEL = 'Source URL'
const FEEDBACK_ERROR_LABEL = 'Error'
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
let cachedAppInfo: AppInfo | null = null
let appInfoPromise: Promise<AppInfo> | null = null
const normalizeErrorText = (value?: string | null): string =>
value ? value.replace(/\s+/g, ' ').trim() : ''
const clampText = (value: string, maxLength: number): string =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
const buildIssueLogs = (
errorText: string,
sourceUrl: string | undefined,
ytDlpCommand: string | undefined,
urlLabel: string,
errorLabel: string,
commandLabel: string
): string => {
const lines: string[] = []
if (sourceUrl) {
lines.push(`${urlLabel}: ${sourceUrl}`)
}
if (ytDlpCommand) {
lines.push(`${commandLabel}: ${ytDlpCommand}`)
}
lines.push(`${errorLabel}: ${errorText}`)
return lines.join('\n')
}
const loadAppInfo = async (): Promise<AppInfo> => {
if (cachedAppInfo) {
return cachedAppInfo
}
if (appInfoPromise) {
return appInfoPromise
}
appInfoPromise = (async () => {
try {
const [version, osRelease] = await Promise.all([
ipcServices.app.getVersion(),
ipcServices.app.getOsVersion()
])
cachedAppInfo = { appVersion: version, osVersion: osRelease }
} catch (error) {
console.error('Failed to load app info for feedback links:', error)
cachedAppInfo = DEFAULT_APP_INFO
}
return cachedAppInfo
})()
return appInfoPromise
}
export const useAppInfo = (): AppInfo => {
const [appInfo, setAppInfo] = useState<AppInfo>(DEFAULT_APP_INFO)
useEffect(() => {
let isActive = true
const loadInfo = async () => {
const info = await loadAppInfo()
if (isActive) {
setAppInfo(info)
}
}
void loadInfo()
return () => {
isActive = false
}
}, [])
return appInfo
}
type FeedbackLinkButtonsProps = {
error?: string | null
sourceUrl?: string | null
issueTitle?: string
includeAppInfo?: boolean
appInfo?: AppInfo
buttonVariant?: ButtonProps['variant']
buttonSize?: ButtonProps['size']
buttonClassName?: string
iconClassName?: string
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
ytDlpCommand?: string
}
export const FeedbackLinkButtons = ({
error,
sourceUrl,
issueTitle = '[bug]: ',
includeAppInfo = false,
appInfo,
buttonVariant = 'outline',
buttonSize = 'sm',
buttonClassName,
iconClassName,
onLinkClick,
ytDlpCommand
}: FeedbackLinkButtonsProps) => {
const { t } = useTranslation()
const fallbackAppInfo = useAppInfo()
const { appVersion, osVersion } = appInfo ?? fallbackAppInfo
const links = useMemo(() => {
const compactError = normalizeErrorText(error)
const tweetError = compactError ? clampText(compactError, 160) : ''
const versionLabels = [
appVersion ? `v${appVersion}` : null,
osVersion ? osVersion : null
].filter(Boolean)
const tweetPrefix = versionLabels.length
? `${FEEDBACK_TWEET_PREFIX} ${versionLabels.join(' ')}`
: FEEDBACK_TWEET_PREFIX
const tweetText = encodeURIComponent(
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
)
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
const resolvedSourceUrl = sourceUrl?.trim() || undefined
const normalizedCommand = ytDlpCommand?.trim() || undefined
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
const issueLogs = shouldIncludeLogs
? clampText(
buildIssueLogs(
issueError,
resolvedSourceUrl,
normalizedCommand,
FEEDBACK_SOURCE_LABEL,
FEEDBACK_ERROR_LABEL,
FEEDBACK_COMMAND_LABEL
),
800
)
: null
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
const issueParams = new URLSearchParams({
template: 'bug_report.yml',
title: issueTitle
})
if (issueLogs) {
issueParams.set('logs', issueLogs)
}
if (includeAppInfo) {
issueParams.set('app_version', appVersionValue)
issueParams.set('os_version', osVersionValue)
}
return [
{
icon: Github,
label: t('about.resources.githubIssues'),
href: `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
href: `https://x.com/intent/tweet?text=${tweetText}`
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
href: 'https://discord.gg/uBqXV6QPdm'
}
]
}, [appVersion, error, includeAppInfo, issueTitle, osVersion, sourceUrl, t, ytDlpCommand])
return (
<>
{links.map((resource) => {
const Icon = resource.icon
return (
<Button
key={resource.label}
variant={buttonVariant}
size={buttonSize}
className={buttonClassName}
asChild
>
<a href={resource.href} target="_blank" rel="noreferrer" onClick={onLinkClick}>
<Icon className={iconClassName} />
{resource.label}
</a>
</Button>
)
})}
</>
)
}

View File

@@ -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()
}

View File

@@ -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>
)
}

View File

@@ -1,3 +1,4 @@
import { FeedbackLinkButtons, useAppInfo } from '@renderer/components/feedback/FeedbackLinks'
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
@@ -11,7 +12,6 @@ import {
FileText,
Github,
Link as LinkIcon,
MessageCircle,
MessageSquare,
RefreshCw,
Twitter
@@ -44,33 +44,13 @@ export function About() {
const [updateReady] = useAtom(updateReadyAtom)
const [updateAvailableState] = useAtom(updateAvailableAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const { appVersion, osVersion } = useAppInfo()
const appVersionLabel = appVersion || '—'
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://vidbee.org'
useEffect(() => {
let isActive = true
const fetchAppVersion = async () => {
try {
const version = await ipcServices.app.getVersion()
if (isActive) {
setAppVersion(version)
}
} catch (error) {
console.error('Failed to get app version:', error)
}
}
void fetchAppVersion()
return () => {
isActive = false
}
}, [])
useEffect(() => {
if (!updateAvailableState.available) {
return
@@ -163,7 +143,7 @@ export function About() {
toast.success(t('about.notifications.noUpdatesAvailable'))
setLatestVersionState({
status: 'uptodate',
version: result.version ?? appVersion
version: result.version ?? appVersionLabel
})
setUpdateAvailable({
available: false,
@@ -210,7 +190,7 @@ export function About() {
toast.success(t('about.notifications.noUpdatesAvailable'))
setLatestVersionState({
status: 'uptodate',
version: result.version ?? appVersion
version: result.version ?? appVersionLabel
})
setUpdateAvailable({
available: false,
@@ -279,39 +259,6 @@ export function About() {
const shouldShowCheckUpdates =
!updateAvailableState.available && latestVersionState?.status !== 'available'
const handleXFeedback = useCallback(() => {
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
}, [appVersion, openShareUrl])
const feedbackResources = useMemo<AboutResource[]>(
() => [
{
icon: Github,
label: t('about.resources.githubIssues'),
description: t('about.resources.githubIssuesDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
description: t('about.resources.xFeedbackDescription'),
actionLabel: t('about.actions.feedback'),
onClick: handleXFeedback
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
description: t('about.resources.discordDescription'),
actionLabel: t('about.actions.visit'),
href: 'https://discord.gg/uBqXV6QPdm'
}
],
[t, handleXFeedback]
)
const aboutResources = useMemo<AboutResource[]>(
() => [
{
@@ -345,7 +292,7 @@ export function About() {
<div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
{t('about.versionLabel', { version: appVersionLabel })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
@@ -496,28 +443,12 @@ export function About() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{feedbackResources.map((resource) => {
const Icon = resource.icon
return resource.href ? (
<Button key={resource.label} variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
<Icon className="h-4 w-4" />
{resource.label}
</a>
</Button>
) : (
<Button
key={resource.label}
variant="outline"
size="sm"
onClick={resource.onClick}
className="gap-2"
>
<Icon className="h-4 w-4" />
{resource.label}
</Button>
)
})}
<FeedbackLinkButtons
appInfo={{ appVersion, osVersion }}
issueTitle="[bug]: "
buttonClassName="gap-2"
iconClassName="h-4 w-4"
/>
</div>
</div>
{/* Other resources */}

View File

@@ -24,6 +24,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
status: item.status,
progress: undefined,
error: item.error,
ytDlpCommand: item.ytDlpCommand,
downloadPath: item.downloadPath,
speed: undefined,
duration: item.duration,

View File

@@ -11,15 +11,24 @@ export const videoInfoLoadingAtom = atom<boolean>(false)
// Error state for video info
export const videoInfoErrorAtom = atom<string | null>(null)
// Last yt-dlp command used for video info
export const videoInfoCommandAtom = atom<string | null>(null)
// Fetch video info
export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
set(videoInfoLoadingAtom, true)
set(videoInfoErrorAtom, null)
set(videoInfoCommandAtom, null)
set(currentVideoInfoAtom, null)
try {
const info = await ipcServices.download.getVideoInfo(url)
set(currentVideoInfoAtom, info)
const result = await ipcServices.download.getVideoInfoWithCommand(url)
set(videoInfoCommandAtom, result.ytDlpCommand)
if (result.info) {
set(currentVideoInfoAtom, result.info)
return
}
set(videoInfoErrorAtom, result.error || 'Failed to fetch video info')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'
set(videoInfoErrorAtom, errorMessage)
@@ -32,4 +41,5 @@ export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
export const clearVideoInfoAtom = atom(null, (_get, set) => {
set(currentVideoInfoAtom, null)
set(videoInfoErrorAtom, null)
set(videoInfoCommandAtom, null)
})

View File

@@ -34,6 +34,12 @@ export interface VideoInfo {
uploader?: string
}
export interface VideoInfoCommandResult {
info?: VideoInfo
ytDlpCommand: string
error?: string
}
export interface DownloadProgress {
percent: number
currentSpeed?: string
@@ -60,6 +66,7 @@ export interface DownloadItem {
progress?: DownloadProgress
error?: string
speed?: string
ytDlpCommand?: string
// Enhanced video information
duration?: number
fileSize?: number
@@ -109,6 +116,7 @@ export interface DownloadHistoryItem {
downloadedAt: number
completedAt?: number
error?: string
ytDlpCommand?: string
// Additional metadata
description?: string
channel?: string