chore: add initial project configuration and structure

This commit is contained in:
Nexmoe
2025-10-23 21:31:54 +08:00
parent a014349de3
commit ac4b4a8e6f
102 changed files with 15832 additions and 2 deletions

16
src/renderer/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>VidBee</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

65
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
import { About } from './pages/About'
import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
import { SupportedSites } from './pages/SupportedSites'
type Page = 'home' | 'settings' | 'about' | 'sites'
function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const renderPage = () => {
switch (currentPage) {
case 'home':
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
case 'settings':
return <Settings />
case 'about':
return <About />
case 'sites':
return <SupportedSites />
default:
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
}
}
return (
<div className="flex flex-row h-screen">
{/* Sidebar Navigation */}
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
{/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
{/* Custom Title Bar */}
<TitleBar />
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
style={{ maxWidth: '100%' }}
>
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
{renderPage()}
</div>
</ScrollArea>
</main>
<Toaster />
</div>
)
}
function App() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AppContent />
</ThemeProvider>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,12 @@
@import 'tailwindcss';
@import './theme.css';
@layer base {
* {
@apply border-border;
}
body {
@apply text-foreground;
}
}

View File

@@ -0,0 +1,23 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
* {
box-sizing: border-box;
}
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
}

View File

@@ -0,0 +1,160 @@
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1884 0.0128 248.5103);
--card: oklch(0.9881 0 0);
--card-foreground: oklch(0.1884 0.0128 248.5103);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1884 0.0128 248.5103);
--primary: oklch(0.8223 0.1704 79.8747);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.1884 0.0128 248.5103);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9227 0.0011 17.1793);
--muted-foreground: oklch(0.1884 0.0128 248.5103);
--accent: oklch(0.9485 0.0162 64.6689);
--accent-foreground: oklch(0.8223 0.1704 79.8747);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8929 0.0133 82.4013);
--input: oklch(0.9823 0.0029 84.5589);
--ring: oklch(0.8223 0.1704 79.8747);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.9784 0.0011 197.1387);
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9485 0.0162 64.6689);
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
--sidebar-border: oklch(0.9330 0.0108 76.5962);
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: #1da1f2;
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.9328 0.0025 228.7857);
--card: oklch(0.2097 0.0080 274.5332);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: #1da1f2;
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}

View File

@@ -0,0 +1,10 @@
/* Title bar drag region styles */
.drag-region {
-webkit-app-region: drag;
app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
app-region: no-drag;
}

View File

@@ -0,0 +1,418 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Progress } from '@renderer/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
ExternalLink,
FolderOpen,
Loader2,
Play,
Trash2,
X
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ipcServices } from '../../lib/ipc'
import {
type DownloadRecord,
removeDownloadAtom,
removeHistoryRecordAtom
} from '../../store/downloads'
interface DownloadItemProps {
download: DownloadRecord
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1)
return `${(bytes / 1024 ** order).toFixed(1)} ${sizes[order]}`
}
const formatDuration = (seconds?: number) => {
if (!seconds) return ''
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')}`
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return ''
return new Date(timestamp).toLocaleString()
}
export function DownloadItem({ download }: DownloadItemProps) {
const { t } = useTranslation()
const removeDownload = useSetAtom(removeDownloadAtom)
const removeHistory = useSetAtom(removeHistoryRecordAtom)
const isHistory = download.entryType === 'history'
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
const actionsContainerClass = showActionsWithoutHover
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
const handleCancel = async () => {
if (isHistory) return
try {
await ipcServices.download.cancelDownload(download.id)
removeDownload(download.id)
} catch (error) {
console.error('Failed to cancel download:', error)
}
}
const handleOpenFileLocation = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFolderFailed'))
return
}
try {
const success = await ipcServices.fs.openFileLocation(download.outputPath)
if (!success) {
toast.error(t('notifications.openFolderFailed'))
}
} catch (error) {
console.error('Failed to open file location:', error)
toast.error(t('notifications.openFolderFailed'))
}
}
const handleOpenFile = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFileFailed'))
return
}
try {
await ipcServices.fs.openFileLocation(download.outputPath)
} catch (error) {
console.error('Failed to open file:', error)
toast.error(t('notifications.openFileFailed'))
}
}
const handleOpenFolder = async () => {
await handleOpenFileLocation()
}
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
removeHistory(download.id)
toast.success(t('notifications.itemRemoved'))
} catch (error) {
console.error('Failed to remove item:', error)
toast.error(t('notifications.removeFailed'))
}
}
const getStatusIcon = () => {
switch (download.status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'error':
return <AlertCircle className="h-4 w-4 text-destructive" />
case 'downloading':
case 'processing':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'pending':
return <Loader2 className="h-4 w-4 text-muted-foreground" />
case 'cancelled':
return <X className="h-4 w-4 text-muted-foreground" />
default:
return null
}
}
const getStatusText = () => {
switch (download.status) {
case 'completed':
return t('download.completed')
case 'error':
return t('download.error')
case 'downloading':
return t('download.downloading')
case 'processing':
return t('download.processing')
case 'pending':
return t('download.downloadPending')
case 'cancelled':
return t('download.cancelled')
default:
return ''
}
}
const statusIcon = getStatusIcon()
const statusText = getStatusText()
return (
<div className="group relative w-full max-w-full overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
{/* Thumbnail */}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60">
<ImageWithPlaceholder
src={thumbnailSrc}
alt={download.title}
className="w-32 h-18 object-cover aspect-video"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs wrap-break-word">{download.title}</p>
</TooltipContent>
</Tooltip>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
{download.type}
</Badge>
{(statusIcon || statusText) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcon}
<span>{statusText}</span>
</div>
)}
</div>
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
) : null}
<span className="truncate max-w-[120px] text-left">
<Tooltip>
<TooltipTrigger asChild>
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors cursor-pointer"
>
{download.uploader &&
download.channel &&
download.uploader !== download.channel
? `${download.uploader}${download.channel}`
: download.uploader
? `${download.uploader}`
: download.channel
? `${download.channel}`
: ''}
</a>
</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-word">
<p>{download.url}</p>
</TooltipContent>
</Tooltip>
</span>
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
{download.selectedFormat ? (
<>
{download.selectedFormat.height ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.height}p
{download.selectedFormat.fps === 60 ? '60' : ''}
</Badge>
) : null}
{download.selectedFormat.ext ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.ext.toUpperCase()}
</Badge>
) : null}
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
<span className="text-[10px] opacity-75">
{formatFileSize(
download.selectedFormat.filesize ||
download.selectedFormat.filesize_approx
)}
</span>
) : null}
</>
) : (
<>
{download.quality ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.quality}
</Badge>
) : null}
{download.format ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.format.toUpperCase()}
</Badge>
) : null}
{download.codec ? (
<span className="text-[10px] opacity-75">{download.codec}</span>
) : null}
</>
)}
</div>
</div>
<div className={actionsContainerClass}>
{isHistory ? (
<>
{download.outputPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFolder')}</p>
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleRemoveHistory}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.removeItem')}</p>
</TooltipContent>
</Tooltip>
</>
) : (
<>
{download.status === 'completed' && download.outputPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFolder')}</p>
</TooltipContent>
</Tooltip>
</>
)}
{(download.status === 'downloading' ||
download.status === 'pending' ||
download.status === 'processing') && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
</div>
{/* Progress */}
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
<Progress value={download.progress.percent} className="h-1.5 w-full" />
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
<span className="font-medium shrink-0">
{download.progress.percent.toFixed(1)}%
</span>
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total}
</span>
)}
{download.progress.currentSpeed && (
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
)}
{download.progress.eta && (
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
)}
</div>
</div>
</div>
)}
{/* Error message */}
{download.status === 'error' && download.error && (
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { useAtomValue, useSetAtom } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearCompleted = useSetAtom(clearCompletedAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
useHistorySync()
const filteredRecords = useMemo(() => {
return allRecords.filter((record) => {
switch (statusFilter) {
case 'all':
return true
case 'active':
return (
record.status === 'downloading' ||
record.status === 'processing' ||
record.status === 'pending'
)
case 'completed':
case 'error':
return record.status === statusFilter
default:
return true
}
})
}, [allRecords, statusFilter])
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
{ key: 'all', label: t('download.all'), count: downloadStats.total },
{ key: 'active', label: t('download.active'), count: downloadStats.active },
{ key: 'completed', label: t('download.completed'), count: downloadStats.completed },
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
return (
<Button
key={filter.key}
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={
isActive
? 'h-8 rounded-full px-3 shadow-sm'
: 'h-8 rounded-full border border-border/60 px-3'
}
onClick={() => setStatusFilter(filter.key)}
>
<span>{filter.label}</span>
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
</Button>
)
})}
</div>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => (
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,61 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { cn } from '@renderer/lib/utils'
import { ChevronDownIcon } from 'lucide-react'
import type * as React from 'react'
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,36 @@
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,48 @@
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,54 @@
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,26 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cn } from '@renderer/lib/utils'
import { CheckIcon } from 'lucide-react'
import type * as React from 'react'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,101 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@renderer/lib/utils'
import { X } from 'lucide-react'
import * as React from 'react'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View File

@@ -0,0 +1,225 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@renderer/lib/utils'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
}

View File

@@ -0,0 +1,67 @@
import { cn } from '@renderer/lib/utils'
import { ImageIcon } from 'lucide-react'
import { useState } from 'react'
interface ImageWithPlaceholderProps {
src?: string
alt: string
className?: string
placeholderClassName?: string
fallbackIcon?: React.ReactNode
onError?: () => void
}
export function ImageWithPlaceholder({
src,
alt,
className,
placeholderClassName,
fallbackIcon,
onError
}: ImageWithPlaceholderProps) {
const [hasError, setHasError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const handleError = () => {
setHasError(true)
setIsLoading(false)
onError?.()
}
const handleLoad = () => {
setIsLoading(false)
}
// Show placeholder if no src, error occurred, or still loading
if (!src || hasError) {
return (
<div
className={cn('flex items-center justify-center bg-muted text-muted-foreground', className)}
>
{fallbackIcon || <ImageIcon className="h-6 w-6" />}
</div>
)
}
return (
<div className={cn('relative', className)}>
{isLoading && (
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-muted text-muted-foreground',
placeholderClassName
)}
>
{fallbackIcon || <ImageIcon className="h-6 w-6" />}
</div>
)}
<img
src={src}
alt={alt}
className={cn('w-full h-full object-cover', isLoading && 'opacity-0')}
onError={handleError}
onLoad={handleLoad}
/>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,182 @@
import { Slot } from '@radix-ui/react-slot'
import { Separator } from '@renderer/components/ui/separator'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-group"
className={cn('group/item-group flex flex-col rounded-md overflow-hidden', className)}
{...props}
/>
)
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<div className="px-4 bg-muted/40">
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
</div>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/40'
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5'
},
rounded: {
default: 'rounded-none',
none: 'rounded-none',
top: 'rounded-t-md',
bottom: 'rounded-b-md',
both: 'rounded-md'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
rounded: 'default'
}
}
)
function Item({
className,
variant = 'default',
size = 'default',
rounded = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
data-rounded={rounded}
className={cn(itemVariants({ variant, size, rounded, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter
}

View File

@@ -0,0 +1,18 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,25 @@
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,53 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,149 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@renderer/lib/utils'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import * as React from 'react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}

View File

@@ -0,0 +1,25 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,207 @@
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { saveSettingAtom } from '@renderer/store/settings'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
import MingcuteInformationFill from '~icons/mingcute/information-fill'
import MingcuteInformationLine from '~icons/mingcute/information-line'
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
type Page = 'home' | 'settings' | 'about' | 'sites'
interface NavigationItem {
id: Page
icon: {
active: React.ComponentType<{ className?: string }>
inactive: React.ComponentType<{ className?: string }>
}
label: string
}
interface SidebarProps {
currentPage: Page
onPageChange: (page: Page) => void
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' }
]
const navigationItems: NavigationItem[] = [
{
id: 'home',
icon: {
active: MingcuteDownload3Fill,
inactive: MingcuteDownload3Line
},
label: t('menu.download')
},
{
id: 'sites',
icon: {
active: MingcuteCheckCircleFill,
inactive: MingcuteCheckCircleLine
},
label: t('menu.supportedSites')
}
]
const bottomNavigationItems: NavigationItem[] = [
{
id: 'settings',
icon: {
active: MingcuteSettingsFill,
inactive: MingcuteSettingsLine
},
label: t('menu.preferences')
},
{
id: 'about',
icon: {
active: MingcuteInformationFill,
inactive: MingcuteInformationLine
},
label: t('menu.about')
}
]
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: string) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18n.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
return (
<div key={item.id} className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
{showLabel && (
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
{item.label}
</span>
)}
</div>
)
}
return (
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
{/* App Logo */}
<div className="flex flex-col items-center gap-1 py-4 mb-1">
<div className="w-12 h-12 flex items-center justify-center">
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
</div>
<span className="text-xs text-muted-foreground font-bold text-center leading-tight">
VidBee
</span>
</div>
{/* Navigation Items */}
{navigationItems.map((item) => renderNavigationItem(item))}
<div className="flex-1" />
{/* Language Selector */}
<div className="flex flex-col items-center gap-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t('settings.language')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="end">
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold' : undefined}
>
{option.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bottom Navigation Items */}
{bottomNavigationItems.map((item) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
return (
<div key={item.id} className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
</div>
)
})}
</aside>
)
}

View File

@@ -0,0 +1,27 @@
import { useTheme } from 'next-themes'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
}
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,26 @@
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
'use client'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className
)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,17 @@
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,79 @@
import { Button } from '@renderer/components/ui/button'
import { useEffect, useState } from 'react'
import IconFluentDismiss20Regular from '~icons/fluent/dismiss-20-regular'
import IconFluentMaximize20Regular from '~icons/fluent/maximize-20-regular'
import IconFluentSquareMultiple20Regular from '~icons/fluent/square-multiple-20-regular'
import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
import { ipcEvents, ipcServices } from '../../lib/ipc'
import '../../assets/title-bar.css'
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
// 监听窗口最大化状态变化
const handleMaximized = () => {
setIsMaximized(true)
}
const handleUnmaximized = () => {
setIsMaximized(false)
}
ipcEvents.on('window-maximized', handleMaximized)
ipcEvents.on('window-unmaximized', handleUnmaximized)
return () => {
ipcEvents.removeListener('window-maximized', handleMaximized)
ipcEvents.removeListener('window-unmaximized', handleUnmaximized)
}
}, [])
const handleMinimize = () => {
ipcServices.window.minimize()
}
const handleMaximize = () => {
ipcServices.window.maximize()
}
const handleClose = () => {
ipcServices.window.close()
}
return (
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
{/* Window controls */}
<div className="flex items-center gap-1 no-drag">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
onClick={handleMinimize}
>
<IconFluentSubtract20Regular className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
onClick={handleMaximize}
>
{isMaximized ? (
<IconFluentSquareMultiple20Regular className="h-4 w-4" />
) : (
<IconFluentMaximize20Regular className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-500 hover:text-white"
onClick={handleClose}
>
<IconFluentDismiss20Regular className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,100 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@renderer/components/ui/accordion'
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../../lib/ipc'
import { settingsAtom } from '../../store/settings'
interface AdvancedOptionsProps {
startTime: string
endTime: string
downloadSubs: boolean
onStartTimeChange: (value: string) => void
onEndTimeChange: (value: string) => void
onDownloadSubsChange: (value: boolean) => void
}
export function AdvancedOptions({
startTime,
endTime,
downloadSubs,
onStartTimeChange,
onEndTimeChange,
onDownloadSubsChange
}: AdvancedOptionsProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const handleSelectLocation = async () => {
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
await ipcServices.settings.set('downloadPath', path)
toast.success(t('notifications.settingsSaved'))
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
}
}
return (
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Time Range */}
<div className="space-y-2">
<Label>{t('advancedOptions.timeRange')}</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={t('advancedOptions.startPlaceholder')}
value={startTime}
onChange={(e) => onStartTimeChange(e.target.value)}
title={t('advancedOptions.startHint')}
/>
</div>
<span className="text-muted-foreground">-</span>
<div className="flex-1">
<Input
placeholder={t('advancedOptions.endPlaceholder')}
value={endTime}
onChange={(e) => onEndTimeChange(e.target.value)}
title={t('advancedOptions.endHint')}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('advancedOptions.startHint')}</p>
</div>
{/* Subtitles */}
<div className="flex items-center justify-between">
<Label>{t('advancedOptions.downloadSubs')}</Label>
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
</div>
{/* Download Location */}
<div className="space-y-2">
<Label>{t('advancedOptions.downloadLocation')}</Label>
<div className="flex items-center gap-2">
<Input value={settings.downloadPath} readOnly className="flex-1" />
<Button onClick={handleSelectLocation} variant="outline">
{t('settings.selectPath')}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)
}

View File

@@ -0,0 +1,87 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { VideoInfo } from '../../../../shared/types'
interface AudioExtractorProps {
videoInfo: VideoInfo
onExtract: (type: 'extract') => void
}
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
const { t } = useTranslation()
const [extractFormat, setExtractFormat] = useState('mp3')
const [extractQuality, setExtractQuality] = useState('5')
const audioFormats = [
{ value: 'mp3', label: 'MP3' },
{ value: 'm4a', label: 'M4A' },
{ value: 'opus', label: 'Opus' },
{ value: 'wav', label: 'WAV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'alac', label: 'ALAC' },
{ value: 'vorbis', label: 'Vorbis (OGG)' }
]
const qualities = [
{ value: '0', label: t('audioExtract.best') },
{ value: '2', label: t('audioExtract.good') },
{ value: '5', label: t('audioExtract.normal') },
{ value: '8', label: t('audioExtract.bad') },
{ value: '10', label: t('audioExtract.worst') }
]
return (
<Card>
<CardHeader>
<CardTitle>{t('audioExtract.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => onExtract('extract')} className="w-full">
{t('audioExtract.extract')}
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,225 @@
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
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> = {
auto: 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
}
export function FormatSelector({
formats,
type,
onVideoFormatChange,
onAudioFormatChange
}: 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(() => {
// Filter and sort formats
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
const audios = formats.filter(
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
)
// Apply showMoreFormats filter
const filteredVideos = settings.showMoreFormats
? videos
: videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp'))
const filteredAudios = settings.showMoreFormats
? audios
: audios.filter((f) => f.ext !== 'webm')
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
if (preferred) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
}, [
formats,
settings,
selectedVideo,
selectedAudio,
onAudioFormatChange,
onVideoFormatChange,
pickVideoFormatForPreset
])
const formatSize = (bytes?: number) => {
if (!bytes) return t('download.unknownSize')
const mb = bytes / 1000000
return `${mb.toFixed(2)} MB`
}
const formatVideoLabel = (format: VideoFormat) => {
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
const size = formatSize(format.filesize || format.filesize_approx)
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
}
const formatAudioLabel = (format: VideoFormat) => {
const quality = format.format_note || t('download.unknownQuality')
const ext = format.ext === 'webm' ? 'opus' : format.ext
const size = formatSize(format.filesize || format.filesize_approx)
return `${quality} | ${ext} | ${size}`
}
if (type === 'video') {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatVideoLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatAudioLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
// Audio only
return (
<div className="space-y-2">
<Label>{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatAudioLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,231 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Separator } from '@renderer/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { useSetAtom } from 'jotai'
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { VideoInfo } from '../../../../shared/types'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ipcServices } from '../../lib/ipc'
import { addDownloadAtom } from '../../store/downloads'
import { clearVideoInfoAtom } from '../../store/video'
import { AdvancedOptions } from './AdvancedOptions'
import { AudioExtractor } from './AudioExtractor'
import { FormatSelector } from './FormatSelector'
interface VideoInfoCardProps {
videoInfo: VideoInfo
}
function formatDuration(seconds?: number): string {
if (!seconds) return 'Unknown'
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 formatViews(views?: number): string {
if (!views) return 'Unknown'
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M`
if (views >= 1000) return `${(views / 1000).toFixed(1)}K`
return views.toString()
}
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
const { t } = useTranslation()
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
const addDownload = useSetAtom(addDownloadAtom)
const titleId = useId()
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
const [title, setTitle] = useState(videoInfo.title)
const [selectedVideoFormat, setSelectedVideoFormat] = useState('')
const [selectedAudioForVideo, setSelectedAudioForVideo] = useState('')
const [selectedAudioFormat, setSelectedAudioFormat] = useState('')
const [startTime, setStartTime] = useState('')
const [endTime, setEndTime] = useState('')
const [downloadSubs, setDownloadSubs] = useState(false)
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
const downloadItem = {
id,
url: videoInfo.webpage_url || '',
title,
thumbnail: videoInfo.thumbnail,
type: type === 'extract' ? 'audio' : 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 options = {
url: videoInfo.webpage_url || '',
type,
format: type === 'video' ? selectedVideoFormat : selectedAudioFormat,
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
startTime: startTime || undefined,
endTime: endTime || undefined,
downloadSubs
}
addDownload(downloadItem)
try {
await ipcServices.download.startDownload(id, options)
// Update the download info in the main process queue
await ipcServices.download.updateDownloadInfo(id, {
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'))
clearVideoInfo()
} catch (error) {
console.error('Failed to start download:', error)
toast.error(t('notifications.downloadFailed'))
}
}
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
<ArrowLeft className="h-4 w-4" />
{t('download.back')}
</Button>
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row gap-6">
{/* Thumbnail */}
<div className="shrink-0">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full md:w-80 rounded-lg aspect-video"
fallbackIcon={<Play className="h-12 w-12" />}
/>
</div>
{/* Video Metadata */}
<div className="flex-1 space-y-3">
<div>
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
<CardDescription className="flex flex-wrap gap-2 items-center">
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
{formatDuration(videoInfo.duration)}
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1">
<Eye className="h-3 w-3" />
{formatViews(videoInfo.view_count)}
</Badge>
)}
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
</CardDescription>
</div>
<div className="space-y-2">
<Label htmlFor={titleId}>{t('download.title')}</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
/>
</div>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6 space-y-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-4 mt-4">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
onVideoFormatChange={setSelectedVideoFormat}
onAudioFormatChange={setSelectedAudioForVideo}
/>
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadVideo')}
</Button>
</TabsContent>
<TabsContent value="audio" className="space-y-4 mt-4">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
onAudioFormatChange={setSelectedAudioFormat}
/>
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadAudio')}
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,98 @@
export interface PopularSite {
id: string
label: string
description?: string
}
export const popularSites: PopularSite[] = [
{
id: 'youtube',
label: 'YouTube',
description: 'Long-form and livestream video from creators worldwide.'
},
{
id: 'youtubemusic',
label: 'YouTube Music',
description: 'Official music videos, albums, and live performances.'
},
{
id: 'tiktok',
label: 'TikTok',
description: 'Short-form mobile videos, effects, and live streams.'
},
{
id: 'facebook',
label: 'Facebook',
description: 'Feed, Watch, and Reels videos from public pages.'
},
{
id: 'instagram',
label: 'Instagram',
description: 'Feed, Stories, Reels, and Highlights content.'
},
{
id: 'twitter',
label: 'X (Twitter)',
description: 'Timeline posts, Spaces recordings, and broadcasts.'
},
{
id: 'soundcloud',
label: 'SoundCloud',
description: 'Music tracks, playlists, and DJ sets.'
},
{
id: 'reddit',
label: 'Reddit',
description: 'Embedded clips and hosted videos from communities.'
},
{
id: 'vimeo',
label: 'Vimeo',
description: 'High-quality creator and business video hosting.'
},
{
id: 'dailymotion',
label: 'Dailymotion',
description: 'Global news, sports, and entertainment clips.'
},
{
id: 'twitch',
label: 'Twitch',
description: 'Gaming, music, and IRL live streams and VODs.'
},
{
id: 'linkedin',
label: 'LinkedIn',
description: 'Professional talks, webinars, and learning videos.'
},
{
id: 'pinterest',
label: 'Pinterest',
description: 'Idea pins, how-to reels, and lifestyle inspiration videos.'
},
{
id: 'tumblr',
label: 'Tumblr',
description: 'Creative short-form media and fan edits.'
},
{
id: 'mixcloud',
label: 'Mixcloud',
description: 'DJ mixes, radio shows, and long-form audio.'
},
{
id: 'niconico',
label: 'Niconico',
description: 'Japanese animation, music, and live broadcast archive.'
},
{
id: 'kick',
label: 'Kick',
description: 'Creator live streams and replays on the Kick platform.'
},
{
id: 'bandcamp',
label: 'Bandcamp',
description: 'Independent artist albums and community releases.'
}
]

2
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { ipcServices } from '../lib/ipc'
export const useCachedThumbnail = (url?: string | null): string | undefined => {
const [cachedUrl, setCachedUrl] = useState<string | undefined>()
useEffect(() => {
let isActive = true
const loadThumbnail = async () => {
if (!url) {
setCachedUrl(undefined)
return
}
if (url.startsWith('file://') || url.startsWith('data:')) {
setCachedUrl(url)
return
}
try {
const localUrl = await ipcServices.thumbnail.getThumbnailPath(url)
if (!isActive) return
setCachedUrl(localUrl ?? undefined)
} catch (error) {
console.error('Failed to load cached thumbnail:', error)
if (!isActive) return
setCachedUrl(undefined)
}
}
void loadThumbnail()
return () => {
isActive = false
}
}, [url])
return cachedUrl
}

View File

@@ -0,0 +1,36 @@
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'
// import type { DownloadHistoryItem } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
import { addHistoryRecordAtom, clearHistoryRecordsAtom } from '../store/downloads'
export function useHistorySync() {
const addHistoryItem = useSetAtom(addHistoryRecordAtom)
const clearHistory = useSetAtom(clearHistoryRecordsAtom)
useEffect(() => {
// Load initial history from main process
const loadHistory = async () => {
try {
const historyData = await ipcServices.history.getHistory()
// Clear existing history and load from main process
clearHistory()
historyData.forEach((item) => {
addHistoryItem(item)
})
} catch (error) {
console.error('Failed to load history:', error)
}
}
loadHistory()
// Listen for new history items from main process
// const _handleHistoryAdded = (item: DownloadHistoryItem) => {
// addHistoryItem(item)
// }
// Note: We would need to add IPC events for real-time updates
// For now, we'll rely on manual refresh or page navigation
}, [addHistoryItem, clearHistory])
}

View File

@@ -0,0 +1,129 @@
import { ipcServices } from '@renderer/lib/ipc'
import { useState } from 'react'
import { toast } from 'sonner'
/**
* Example custom hook demonstrating IPC communication using electron-ipc-decorator
* This shows how to create reusable hooks for IPC calls with type safety
*
* 展示两种服务的使用:
* 1. AppService - 应用相关功能(版本、语言切换等)
* 2. ExampleService - 示例功能ping、问候等
*/
export function useIpcExample() {
const [loading, setLoading] = useState(false)
const [response, setResponse] = useState<string>('')
// Example Service Methods - These are commented out as the example service doesn't exist
const ping = async () => {
setLoading(true)
try {
// Example service not available
setResponse('Example service not available')
toast.info('Example service not available')
return 'Example service not available'
} catch (error) {
toast.error('Failed to ping')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const greet = async (name: string) => {
setLoading(true)
try {
// Example service not available
setResponse(`Hello ${name}!`)
toast.success(`Hello ${name}!`)
return `Hello ${name}!`
} catch (error) {
toast.error('Failed to greet')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const getSystemInfo = async () => {
setLoading(true)
try {
// Get platform info from app service
const platform = await ipcServices?.app.getPlatform()
toast.success(`Platform: ${platform}`)
return { platform }
} catch (error) {
toast.error('Failed to get system info')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
// App Service Methods - 展示应用服务的使用
const getAppVersion = async () => {
setLoading(true)
try {
const version = await ipcServices?.app.getVersion()
setResponse(`App Version: ${version}`)
toast.success(`应用版本: ${version}`)
return version
} catch (error) {
toast.error('获取应用版本失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const getAppInfo = async () => {
setLoading(true)
try {
const version = await ipcServices?.app.getVersion()
const platform = await ipcServices?.app.getPlatform()
const info = { name: 'VidBee', version, platform }
setResponse(`App: ${info.name} v${info.version} (${info.platform})`)
toast.success(`应用: ${info.name} v${info.version}`)
return info
} catch (error) {
toast.error('获取应用信息失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const switchAppLocale = async (_locale: string) => {
setLoading(true)
try {
// Language switching not implemented in app service
setResponse(`Language switching not implemented`)
toast.info(`语言切换功能未实现`)
return true
} catch (error) {
toast.error('切换语言失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
return {
loading,
response,
// Example Service
ping,
greet,
getSystemInfo,
// App Service
getAppVersion,
getAppInfo,
switchAppLocale
}
}

18
src/renderer/src/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import zh from './locales/zh.json'
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
export default i18n

View File

@@ -0,0 +1,45 @@
/**
* IPC Services for Renderer Process
*
* This file provides a convenient way to access IPC services in the renderer process.
* All services are type-safe and automatically generated from the main process.
*
* Usage:
* import { ipcServices, ipcEvents } from '@renderer/lib/ipc'
*
* const version = await ipcServices.app.getAppVersion()
* const info = await ipcServices.app.getAppInfo()
* await ipcServices.app.switchAppLocale('zh-CN')
*
* // Event listening
* const unsubscribe = ipcEvents.on('download:started', (id: string) => {
* console.log('Download started:', id)
* })
* ipcEvents.removeListener('download:started', unsubscribe)
*/
import type { IpcServices } from '@shared/types/ipc'
import { createIpcProxy } from 'electron-ipc-decorator/client'
// ipcRenderer should be exposed through electron's context bridge
// Create type-safe IPC proxy for renderer process
export const ipcServices = createIpcProxy<IpcServices>(
window.electron.ipcRenderer as unknown as Electron.IpcRenderer
) as NonNullable<ReturnType<typeof createIpcProxy<IpcServices>>>
// Export event listening utilities
export const ipcEvents = {
on: (channel: string, callback: (...args: unknown[]) => void) => {
return window.api.on(channel, callback)
},
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
window.api.removeListener(channel, callback)
},
send: (channel: string, ...args: unknown[]) => {
window.api.send(channel, ...args)
}
}
// Export types for use in other files
export type { IpcServices }

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,293 @@
{
"about": {
"actions": {
"checkUpdates": "Check updates",
"email": "Email",
"feedback": "Feedback",
"openRepo": "Open GitHub repository",
"view": "View",
"visit": "Visit"
},
"appName": "VidBee",
"autoUpdateDescription": "Download and install new releases automatically in the background.",
"autoUpdateTitle": "Auto updates",
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
"here": "here",
"homepage": "Homepage",
"notifications": {
"checkingUpdates": "Looking for updates...",
"updateAvailable": "Update available: {{version}}",
"noUpdatesAvailable": "You're using the latest version",
"updateError": "Failed to check for updates: {{error}}",
"downloadUpdate": "Download and install update {{version}}?",
"downloadStarted": "Download started...",
"downloadError": "Failed to download update",
"updateDownloaded": "Update downloaded, restart to install",
"restartToUpdate": "Restart now to install update?"
},
"preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles",
"resources": {
"changelog": "Release notes",
"changelogDescription": "Catch up on what changed in each version.",
"contact": "Email support",
"contactDescription": "Reach out directly for help or collaboration.",
"documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.",
"license": "License",
"licenseDescription": "Review the open-source license terms.",
"website": "Official website",
"websiteDescription": "Product highlights, roadmap, and community news."
},
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
"resourcesTitle": "Resources",
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word",
"shareDescription": "Share VidBee with your community in one click.",
"shareActions": {
"twitter": "Share on X (Twitter)",
"facebook": "Share on Facebook",
"copy": "Copy link"
},
"sourceCode": "Source Code is available",
"tagline": "An AI-friendly download helper for every creator",
"title": "About",
"version": "Version",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "Close app when download finishes",
"currentLocation": "Current download location - ",
"downloadLocation": "Download location",
"downloadSubs": "Download subtitles if available",
"end": "End",
"endHint": "If kept empty, it will be downloaded to the end",
"endPlaceholder": "10:00",
"selectLocation": "Select Download Location",
"start": "Start",
"startHint": "If kept empty, it will start from the beginning",
"startPlaceholder": "00:00",
"subtitles": "Subtitles",
"timeRange": "Download particular time-range",
"title": "Advanced Options"
},
"app": {
"description": "Download videos and audios from hundreds of sites",
"title": "VidBee"
},
"audioExtract": {
"bad": "Bad",
"best": "Best",
"extract": "Extract",
"good": "Good",
"normal": "Normal",
"selectFormat": "Select Format",
"selectQuality": "Select Quality",
"title": "Extract Audio",
"worst": "Worst"
},
"download": {
"active": "active",
"all": "All",
"audio": "Audio",
"back": "Back",
"cancel": "Cancel",
"cancelled": "Cancelled",
"clearCompleted": "Clear Completed",
"clearDownloads": "Clear Downloads",
"completed": "Completed",
"downloadAudio": "Download Audio",
"downloadBtn": "Download",
"downloadPending": "Pending",
"downloadQueue": "Download Queue",
"downloadVideo": "Download Video",
"downloading": "Downloading...",
"enterUrl": "Enter Video URL",
"enterUrlDescription": "Paste or type a video URL. ",
"error": "Error",
"fetch": "Fetch",
"fetchingVideoInfo": "Fetching video info...",
"history": "History",
"imageLoadError": "Image failed to load",
"imagePlaceholder": "No image available",
"infoUnavailable": "One-Click Download (Info unavailable)",
"loading": "Loading",
"moreOptions": "More options",
"noActiveDownloads": "No active downloads",
"noAudio": "No Audio",
"noHistory": "No download history",
"noItems": "No items found",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
"oneClickDownloadNow": "Download Now",
"oneClickDownloadStarted": "Download started with default settings",
"paste": "Paste",
"pastePlaylistUrl": "Click to paste playlist link from clipboard [Ctrl + V]",
"pasteUrl": "Click to paste video URL or ID [Ctrl + V]",
"preparing": "Preparing...",
"processing": "Processing",
"progress": "Progress",
"selectAudioFormat": "Select Audio Format",
"selectFormat": "Select Format",
"selectVideoFormat": "Select Video Format",
"singleVideo": "Single Video",
"speed": "Speed",
"title": "Title",
"total": "Total",
"unknownQuality": "Unknown quality",
"unknownSize": "Unknown size",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Video Information",
"videoInfoUpdated": "Video information updated"
},
"errors": {
"clickToCopy": "Click to copy details",
"clipboardEmpty": "Clipboard is empty",
"downloadFailed": "Download failed",
"downloadNecessaryFilesFailed": "Failed to download necessary files. Please check your network and try again",
"emptyUrl": "Please enter a URL",
"errorDetails": "Error Details",
"fetchInfoFailed": "Failed to fetch video information",
"networkError": "Some error has occurred. Check your network and use correct URL",
"pasteFromClipboard": "Failed to paste from clipboard"
},
"history": {
"clearCancelled": "Clear Cancelled",
"clearCompleted": "Clear Completed",
"clearErrors": "Clear Errors",
"copyUrl": "Copy URL",
"openInBrowser": "Click to open in browser",
"date": "Date",
"description": "View and manage your download history",
"duration": "Duration",
"fileSize": "File Size",
"filters": {
"all": "All",
"cancelled": "Cancelled",
"completed": "Completed",
"errors": "Errors"
},
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openFile": "Open File",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
"outputPath": "Output Path",
"removeItem": "Remove Item",
"stats": {
"cancelled": "Cancelled",
"completed": "Completed",
"errors": "Errors",
"total": "Total"
},
"status": {
"cancelled": "Cancelled",
"completed": "Completed",
"error": "Error"
},
"title": "Download History"
},
"menu": {
"about": "About",
"download": "Download",
"playlist": "Download Playlist",
"preferences": "Preferences",
"supportedSites": "Supported Sites",
"theme": "Theme:"
},
"notifications": {
"copyFailed": "Failed to copy to clipboard",
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",
"itemRemoved": "Item removed",
"openFileFailed": "Failed to open file",
"openFolderFailed": "Failed to open folder",
"removeFailed": "Failed to remove item",
"settingsSaved": "Settings saved",
"urlCopied": "URL copied to clipboard"
},
"playlist": {
"comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel",
"downloadFailed": "Failed to start playlist download",
"downloadPlaylist": "Download Playlist",
"downloadStarted": "Started downloading {{count}} videos from playlist",
"downloadType": "Download Type",
"downloading": "Downloading playlist:",
"endIndex": "End",
"enterPlaylistUrl": "Enter Playlist URL",
"fetchFailed": "Failed to fetch playlist information",
"filenameFormat": "Filename format for playlists",
"folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist",
"linkLabel": "Playlist URL",
"playlistUrlDescription": "Download all videos from a playlist in bulk",
"range": "Range (Optional)",
"resetToDefault": "Reset to default",
"startIndex": "Start (1)",
"title": "Download Playlist"
},
"settings": {
"aboutTab": "About",
"advanced": "Advanced",
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"configFile": "Use configuration file",
"dark": "Dark",
"description": "Configure your download preferences and application settings",
"downloadPath": "Download location",
"general": "General",
"language": "Language",
"languageOptions": {
"chinese": "Chinese (Simplified)",
"english": "English"
},
"light": "Light",
"maxConcurrentDownloads": "Maximum number of active downloads",
"none": "None",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Enable one-click download with default settings",
"oneClickDownloadType": "Default download type",
"oneClickQuality": "Preferred quality",
"oneClickQualityOptions": {
"auto": "Auto",
"bad": "Bad",
"best": "Best",
"good": "Good",
"normal": "Normal",
"worst": "Worst"
},
"proxy": "Proxy",
"selectConfigFile": "Select config file",
"selectPath": "Select",
"showMoreFormats": "Show more format options",
"system": "System",
"theme": "Theme",
"title": "Settings",
"tray": {
"quit": "Quit",
"showHome": "Show Home"
},
"video": "Video Preferences"
},
"sites": {
"homeInlineDescription": "Supports {{sites}} and more.",
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
"moreTitle": "Need another site?",
"openFullList": "Open full supported sites list",
"pageDescription": "VidBee uses yt-dlp under the hood to reach hundreds of sources.",
"pageIntro": "Here are the mainstream services people download from most frequently.",
"pageTitle": "Supported Sites",
"popularSection": "Main platforms",
"viewAll": "View all supported sites"
}
}

View File

@@ -0,0 +1,282 @@
{
"about": {
"actions": {
"checkUpdates": "检查更新",
"email": "邮件",
"feedback": "反馈",
"openRepo": "打开 GitHub 仓库",
"view": "查看",
"visit": "访问"
},
"appName": "VidBee",
"autoUpdateDescription": "在后台自动下载并安装新版本。",
"autoUpdateTitle": "自动更新",
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
"betaProgramTitle": "预览通道",
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
"here": "此处",
"homepage": "主页",
"notifications": {
"checkingUpdates": "正在查找更新...",
"updateAvailable": "发现新版本: {{version}}",
"noUpdatesAvailable": "您正在使用最新版本",
"updateError": "检查更新失败: {{error}}",
"downloadUpdate": "下载并安装更新 {{version}}",
"downloadStarted": "开始下载...",
"downloadError": "下载更新失败",
"updateDownloaded": "更新已下载,重启以安装",
"restartToUpdate": "立即重启以安装更新?"
},
"preferencesDescription": "无需离开此页即可调整更新设置。",
"preferencesTitle": "快速切换",
"resources": {
"changelog": "发行说明",
"changelogDescription": "了解每个版本的变更内容。",
"contact": "邮件支持",
"contactDescription": "直接联系我们以获取帮助或开展合作。",
"documentation": "帮助中心",
"documentationDescription": "指南、常见问题和常见流程。",
"feedback": "反馈与问题",
"feedbackDescription": "在 GitHub 上分享想法或报告问题。",
"license": "许可证",
"licenseDescription": "查阅开源许可证条款。",
"website": "官方网站",
"websiteDescription": "产品亮点、路线图与社区动态。"
},
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"sourceCode": "源代码已开放",
"tagline": "面向每位创作者的 AI 友好下载助手",
"title": "关于",
"version": "版本",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "下载完成后关闭应用",
"currentLocation": "当前下载位置 - ",
"downloadLocation": "下载位置",
"downloadSubs": "若有字幕则下载",
"end": "结束",
"endHint": "如果留空,将下载到结尾",
"endPlaceholder": "10:00",
"selectLocation": "选择下载位置",
"start": "开始",
"startHint": "如果留空,将从开头开始",
"startPlaceholder": "00:00",
"subtitles": "字幕",
"timeRange": "下载指定时间范围",
"title": "高级选项"
},
"app": {
"description": "从数百个网站下载视频和音频",
"title": "VidBee"
},
"audioExtract": {
"bad": "较差",
"best": "最佳",
"extract": "提取",
"good": "良好",
"normal": "标准",
"selectFormat": "选择格式",
"selectQuality": "选择质量",
"title": "提取音频",
"worst": "最差"
},
"download": {
"active": "进行中",
"all": "全部",
"audio": "音频",
"back": "返回",
"cancel": "取消",
"cancelled": "已取消",
"clearCompleted": "清除已完成",
"clearDownloads": "清除下载",
"completed": "已完成",
"downloadAudio": "下载音频",
"downloadBtn": "下载",
"downloadPending": "待处理",
"downloadQueue": "下载队列",
"downloadVideo": "下载视频",
"downloading": "正在下载...",
"enterUrl": "输入视频链接",
"enterUrlDescription": "粘贴或输入一个视频链接。",
"error": "错误",
"fetch": "获取",
"fetchingVideoInfo": "正在获取视频信息...",
"history": "历史",
"imageLoadError": "图像加载失败",
"imagePlaceholder": "暂无图像",
"infoUnavailable": "一键下载(信息不可用)",
"loading": "加载中",
"moreOptions": "更多选项",
"noActiveDownloads": "暂无进行中的下载",
"noAudio": "无音频",
"noHistory": "暂无下载历史",
"noItems": "未找到项目",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "使用默认设置直接下载,无需确认",
"oneClickDownloadNow": "立即下载",
"oneClickDownloadStarted": "已使用默认设置开始下载",
"paste": "粘贴",
"pastePlaylistUrl": "点击从剪贴板粘贴播放列表链接 [Ctrl + V]",
"pasteUrl": "点击粘贴视频链接或 ID [Ctrl + V]",
"preparing": "正在准备...",
"processing": "处理中",
"progress": "进度",
"selectAudioFormat": "选择音频格式",
"selectFormat": "选择格式",
"selectVideoFormat": "选择视频格式",
"singleVideo": "单个视频",
"speed": "速度",
"title": "标题",
"total": "总计",
"unknownQuality": "未知质量",
"unknownSize": "未知大小",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "视频",
"videoInfo": "视频信息",
"videoInfoUpdated": "视频信息已更新"
},
"errors": {
"clickToCopy": "点击复制详情",
"clipboardEmpty": "剪贴板为空",
"downloadFailed": "下载失败",
"downloadNecessaryFilesFailed": "必要文件下载失败。请检查网络后再试",
"emptyUrl": "请输入链接",
"errorDetails": "错误详情",
"fetchInfoFailed": "获取视频信息失败",
"networkError": "发生错误。请检查网络并确认链接正确",
"pasteFromClipboard": "从剪贴板粘贴失败"
},
"history": {
"clearCancelled": "清除已取消",
"clearCompleted": "清除已完成",
"clearErrors": "清除错误",
"copyUrl": "复制链接",
"openInBrowser": "点击在浏览器中打开",
"date": "日期",
"description": "查看并管理下载历史",
"duration": "时长",
"fileSize": "文件大小",
"filters": {
"all": "全部",
"cancelled": "已取消",
"completed": "已完成",
"errors": "错误"
},
"noHistory": "暂无下载历史",
"noHistoryDescription": "完成的下载会显示在这里",
"openFile": "打开文件",
"openFileLocation": "打开文件位置",
"openFolder": "打开文件夹",
"openDownloadFolder": "打开下载文件夹",
"outputPath": "输出路径",
"removeItem": "移除项目",
"stats": {
"cancelled": "已取消",
"completed": "已完成",
"errors": "错误",
"total": "总计"
},
"status": {
"cancelled": "已取消",
"completed": "已完成",
"error": "错误"
},
"title": "下载历史"
},
"menu": {
"about": "关于",
"download": "下载",
"playlist": "下载播放列表",
"preferences": "偏好设置",
"supportedSites": "支持的网站",
"theme": "主题:"
},
"notifications": {
"copyFailed": "复制到剪贴板失败",
"downloadCompleted": "下载完成",
"downloadFailed": "下载失败",
"downloadStarted": "下载已开始",
"itemRemoved": "项目已移除",
"openFileFailed": "打开文件失败",
"openFolderFailed": "打开文件夹失败",
"removeFailed": "移除项目失败",
"settingsSaved": "设置已保存",
"urlCopied": "链接已复制到剪贴板"
},
"playlist": {
"comingSoon": "播放列表下载功能即将推出!",
"completed": "播放列表已下载",
"description": "下载 YouTube 播放列表或频道中的全部视频",
"downloadFailed": "启动播放列表下载失败",
"downloadPlaylist": "下载播放列表",
"downloadStarted": "已开始下载播放列表中的 {{count}} 个视频",
"downloadType": "下载类型",
"downloading": "正在下载播放列表:",
"endIndex": "结束",
"enterPlaylistUrl": "输入播放列表链接",
"fetchFailed": "获取播放列表信息失败",
"filenameFormat": "播放列表文件名格式",
"folderFormat": "播放列表文件夹命名格式",
"foundVideos": "在播放列表中找到 {{count}} 个视频",
"linkLabel": "播放列表链接",
"playlistUrlDescription": "批量下载播放列表中的所有视频",
"range": "范围(可选)",
"resetToDefault": "恢复默认",
"startIndex": "开始1",
"title": "下载播放列表"
},
"settings": {
"aboutTab": "关于",
"advanced": "高级",
"app": "应用设置",
"audio": "音频偏好",
"browserForCookies": "选择用于读取 Cookie 的浏览器",
"configFile": "使用配置文件",
"dark": "深色",
"description": "配置下载偏好和应用设置",
"downloadPath": "下载位置",
"general": "通用",
"language": "语言",
"languageOptions": {
"chinese": "简体中文",
"english": "英语"
},
"light": "浅色",
"maxConcurrentDownloads": "最大活动下载数",
"none": "无",
"oneClickAudioForVideo": "视频默认音频",
"oneClickAudioFormat": "默认音频格式",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "启用使用默认设置的一键下载",
"oneClickDownloadType": "默认下载类型",
"oneClickVideoFormat": "默认视频格式",
"preferredAudioQuality": "首选音频质量",
"preferredVideoCodec": "首选视频编码",
"preferredVideoQuality": "首选视频质量",
"proxy": "代理",
"selectConfigFile": "选择配置文件",
"selectPath": "选择",
"showMoreFormats": "显示更多格式选项",
"system": "系统",
"theme": "主题",
"title": "设置",
"tray": {
"quit": "退出",
"showHome": "显示首页"
},
"video": "视频偏好"
},
"sites": {
"homeInlineDescription": "支持 {{sites}} 等更多网站。",
"moreDescription": "完整的 yt-dlp 列表由社区持续更新。",
"moreTitle": "需要其他网站?",
"openFullList": "打开全部支持网站列表",
"pageDescription": "VidBee 使用 yt-dlp 覆盖数百个资源。",
"pageIntro": "以下是大家最常下载的主流服务。",
"pageTitle": "支持的网站",
"popularSection": "主流平台",
"viewAll": "查看全部支持的网站"
}
}

17
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import './assets/main.css'
import './assets/global.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './i18n'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,285 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
import type { LucideIcon } from 'lucide-react'
import {
Facebook,
FileText,
Github,
Link as LinkIcon,
Mail,
MessageCircle,
RefreshCw,
ShieldCheck,
Twitter
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
interface AboutResource {
icon: LucideIcon
label: string
description?: string
actionLabel: string
href?: string
onClick?: () => void
}
export function About() {
const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
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
}
}, [])
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
}
const handleCheckForUpdates = async () => {
try {
toast.info(t('about.notifications.checkingUpdates'))
const result = await ipcServices.update.checkForUpdates()
if (result.available) {
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
} else {
toast.success(t('about.notifications.noUpdatesAvailable'))
}
} catch (error) {
console.error('Failed to check for updates:', error)
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
}
}
const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.tagline'))
return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
twitter: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`
}
}, [t])
const openShareUrl = (url: string) => {
if (typeof window === 'undefined') {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
const handleShareTwitter = () => {
openShareUrl(shareLinks.twitter)
}
const handleShareFacebook = () => {
openShareUrl(shareLinks.facebook)
}
const handleCopyShareLink = async () => {
try {
await navigator.clipboard.writeText(shareTargetUrl)
toast.success(t('notifications.urlCopied'))
} catch (error) {
console.error('Failed to copy share link:', error)
toast.error(t('notifications.copyFailed'))
}
}
const aboutResources = useMemo<AboutResource[]>(
() => [
{
icon: FileText,
label: t('about.resources.changelog'),
description: t('about.resources.changelogDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/releases'
},
{
icon: MessageCircle,
label: t('about.resources.feedback'),
description: t('about.resources.feedbackDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: ShieldCheck,
label: t('about.resources.license'),
description: t('about.resources.licenseDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
},
{
icon: Mail,
label: t('about.resources.contact'),
description: t('about.resources.contactDescription'),
actionLabel: t('about.actions.email'),
href: 'mailto:nexmoex@gmail.com'
}
],
[t]
)
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
<p className="text-muted-foreground">{t('about.description')}</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.tagline')}</p>
</div>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-4 w-4" />
</a>
</Button>
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-4 pt-6">
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
<p className="text-sm text-muted-foreground">{t('about.autoUpdateDescription')}</p>
</div>
<Switch
checked={settings.autoUpdate}
onCheckedChange={(value) => handleSettingChange('autoUpdate', value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="flex flex-col divide-y">
{aboutResources.map((resource) => {
const Icon = resource.icon
return (
<div
key={resource.label}
className="flex items-center justify-between gap-4 px-6 py-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">{resource.label}</p>
{resource.description ? (
<p className="text-sm text-muted-foreground">{resource.description}</p>
) : null}
</div>
</div>
{resource.href ? (
<Button variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer">
{resource.actionLabel}
</a>
</Button>
) : (
<Button variant="outline" size="sm" onClick={resource.onClick}>
{resource.actionLabel}
</Button>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,662 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Tabs, TabsContent } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { VideoInfoCard } from '../components/video/VideoInfoCard'
import { ipcEvents, ipcServices } from '../lib/ipc'
import {
addDownloadAtom,
addHistoryRecordAtom,
removeDownloadAtom,
updateDownloadAtom
} from '../store/downloads'
import { loadSettingsAtom, settingsAtom } from '../store/settings'
import {
currentVideoInfoAtom,
fetchVideoInfoAtom,
videoInfoErrorAtom,
videoInfoLoadingAtom
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const dedupe = (candidates: Array<string | undefined>): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const candidate of candidates) {
if (!candidate) continue
if (seen.has(candidate)) continue
seen.add(candidate)
result.push(candidate)
}
return result
}
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
settings.oneClickQuality ?? 'auto'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio', 'worst']
}
const abrLimit = qualityPresetToAudioAbr[preset]
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
return 'worstvideo+worstaudio/worst'
}
const maxHeight = qualityPresetToVideoHeight[preset]
const videoCandidates = dedupe([
maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,
'bestvideo'
])
const audioSelectors = buildAudioSelectors(preset)
const combinations: string[] = []
for (const video of videoCandidates) {
for (const audio of audioSelectors) {
combinations.push(`${video}+${audio}`)
}
}
if (audioSelectors.includes('none')) {
for (const video of videoCandidates) {
combinations.push(video)
}
} else {
combinations.push('best')
}
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
}
interface HomeProps {
onOpenSupportedSites?: () => void
}
export function Home({ onOpenSupportedSites }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
const [error] = useAtom(videoInfoErrorAtom)
const [settings] = useAtom(settingsAtom)
const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const updateDownload = useSetAtom(updateDownloadAtom)
const addDownload = useSetAtom(addDownloadAtom)
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const [url, setUrl] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const inlinePreviewSites = popularSites
.slice(0, 3)
.map((site) => site.label)
.join(', ')
// Playlist states
const playlistUrlId = useId()
const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('')
const [playlistLoading, setPlaylistLoading] = useState(false)
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const syncHistoryItem = useCallback(
async (id: string) => {
try {
const historyItem = await ipcServices.history.getHistoryById(id)
if (historyItem) {
addHistoryRecord(historyItem)
removeDownload(id)
}
} catch (error) {
console.error('Failed to sync history item:', error)
}
},
[addHistoryRecord, removeDownload]
)
useEffect(() => {
// Load settings on mount
loadSettings()
// Listen for download events from main process
ipcEvents.on('download:started', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download started:', id)
updateDownload({ id, changes: { status: 'downloading' } })
})
ipcEvents.on('download:progress', (...args: unknown[]) => {
const data = args[0] as { id: string; progress: unknown }
console.log('Download progress:', data)
const progress = data.progress as {
percent: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
updateDownload({
id: data.id,
changes: {
progress: {
percent: progress.percent || 0,
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
},
speed: progress.currentSpeed || ''
}
})
})
ipcEvents.on('download:completed', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download completed:', id)
updateDownload({ id, changes: { status: 'completed' } })
toast.success(t('notifications.downloadCompleted'))
void syncHistoryItem(id)
})
ipcEvents.on('download:error', (...args: unknown[]) => {
const data = args[0] as { id: string; error: string }
console.error('Download error:', data)
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
toast.error(t('notifications.downloadFailed'))
void syncHistoryItem(data.id)
})
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download cancelled:', id)
updateDownload({ id, changes: { status: 'cancelled' } })
void syncHistoryItem(id)
})
return () => {
// Note: Event listeners are automatically cleaned up when the component unmounts
// The removeListener calls are not needed as the event system handles cleanup
}
}, [loadSettings, syncHistoryItem, t, updateDownload])
const handlePasteUrl = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setUrl(text.trim())
inputRef.current?.focus()
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
const handleFetchVideo = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
await fetchVideoInfo(url.trim())
}, [url, fetchVideoInfo, t])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleFetchVideo()
}
},
[handleFetchVideo]
)
const handleOneClickDownload = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
// Create initial download item with placeholder info
const trimmedUrl = url.trim()
const downloadItem = {
id,
url: trimmedUrl,
title: t('download.fetchingVideoInfo'),
type: settings.oneClickDownloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
const options = {
url: trimmedUrl,
type: settings.oneClickDownloadType,
format:
settings.oneClickDownloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
}
addDownload(downloadItem)
try {
// Start download immediately
await ipcServices.download.startDownload(id, options)
// Fetch video info in parallel to update the download item
try {
const videoInfo = await ipcServices.download.getVideoInfo(url.trim())
// Update the download item in the renderer state
updateDownload({
id,
changes: {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
// Extract additional metadata if available
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the download info in the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
})
// Show a subtle notification that video info was updated
toast.success(t('download.videoInfoUpdated'))
} catch (infoError) {
console.warn('Failed to fetch video info for one-click download:', infoError)
// Keep the placeholder title if video info fetch fails
// Update the title to indicate info fetch failed
updateDownload({
id,
changes: {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
})
}
toast.success(t('download.oneClickDownloadStarted'))
setUrl('') // Clear the URL after starting download
} catch (error) {
console.error('Failed to start one-click download:', error)
toast.error(t('notifications.downloadFailed'))
}
}, [url, settings, addDownload, updateDownload, t])
// Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setPlaylistUrl(text.trim())
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
const handleDownloadPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
setPlaylistLoading(true)
try {
// Get playlist info first to show user what will be downloaded
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount }))
// Build format preference based on settings
const format =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
// Start playlist download
const downloadIds = await ipcServices.download.startPlaylistDownload({
url: playlistUrl.trim(),
type: downloadType,
format,
startIndex: parseInt(startIndex, 10) || 1,
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
})
// Add all downloads to the renderer state
for (const id of downloadIds) {
const downloadItem = {
id,
url: playlistUrl.trim(),
title: t('download.fetchingVideoInfo'),
type: downloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
addDownload(downloadItem)
}
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
setPlaylistUrl('') // Clear the URL after starting download
} catch (error) {
console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed'))
} finally {
setPlaylistLoading(false)
}
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
style={{ maxWidth: '100%' }}
>
<Tabs defaultValue="single" className="w-full">
{/* <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList> */}
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6">
{/* URL Input Card */}
{!videoInfo && (
<Card>
<CardHeader>
<CardTitle>{t('download.enterUrl')}</CardTitle>
<CardDescription>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="px-0"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
placeholder={t('download.urlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
disabled={loading}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
{t('download.paste')}
</Button>
{settings.oneClickDownload ? (
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('download.oneClickDownloadNow')}
</>
)}
</Button>
) : (
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
{t('download.fetch')}
</>
)}
</Button>
)}
</div>
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('download.oneClickDownload')}
</p>
<p className="text-sm text-blue-700">
{t('download.oneClickDownloadDescription')}
</p>
</div>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
className="flex-1"
disabled={playlistLoading}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistLoading}
>
{t('download.paste')}
</Button>
</div>
</div>
<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={playlistLoading}
>
<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 className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
</div>
</div>
</div>
<Button
onClick={handleDownloadPlaylist}
className="w-full"
size="lg"
disabled={playlistLoading || !playlistUrl.trim()}
>
{playlistLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Unified Download History */}
<UnifiedDownloadHistory />
</div>
)
}

View File

@@ -0,0 +1,341 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemSeparator,
ItemTitle
} from '@renderer/components/ui/item'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
export function Settings() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom)
useEffect(() => {
loadSettings()
}, [loadSettings])
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
}
const handleSelectPath = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectDirectory()
if (path) {
await handleSettingChange('downloadPath', path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
}
}
const handleSelectConfigFile = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile()
if (path) {
await handleSettingChange('configPath', path)
}
} catch (error) {
console.error('Failed to select file:', error)
toast.error('Failed to select file')
}
}
const handleThemeChange = async (value: 'light' | 'dark' | 'system') => {
const currentTheme = (theme ?? settings.theme ?? 'system') as 'light' | 'dark' | 'system'
if (currentTheme === value) {
return
}
setTheme(value)
await handleSettingChange('theme', value)
}
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h1>
<p className="text-muted-foreground">{t('settings.description')}</p>
</div>
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 mt-2">
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.downloadPath')}</ItemTitle>
<ItemDescription>Choose where to save downloaded files</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.downloadPath} readOnly className="flex-1" />
<Button onClick={handleSelectPath}>{t('settings.selectPath')}</Button>
</div>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.theme')}</ItemTitle>
<ItemDescription>
Choose a light, dark, or system theme for VidBee
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={theme ?? settings.theme ?? 'system'}
onValueChange={(value) =>
void handleThemeChange(value as 'light' | 'dark' | 'system')
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickDownload')}</ItemTitle>
<ItemDescription>{t('settings.oneClickDownloadDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.oneClickDownload}
onCheckedChange={(value) => handleSettingChange('oneClickDownload', value)}
/>
</ItemActions>
</Item>
{settings.oneClickDownload && (
<>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>
<ItemDescription>
Choose the default download type for one-click downloads. Quality uses the
preset below.
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.oneClickDownloadType}
onValueChange={(value) =>
handleSettingChange('oneClickDownloadType', value)
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>
<ItemDescription>
Select the quality preset used for one-click downloads
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.oneClickQuality}
onValueChange={(value) =>
handleSettingChange('oneClickQuality', value as OneClickQualityPreset)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t('settings.oneClickQualityOptions.auto')}
</SelectItem>
<SelectItem value="best">
{t('settings.oneClickQualityOptions.best')}
</SelectItem>
<SelectItem value="good">
{t('settings.oneClickQualityOptions.good')}
</SelectItem>
<SelectItem value="normal">
{t('settings.oneClickQualityOptions.normal')}
</SelectItem>
<SelectItem value="bad">
{t('settings.oneClickQualityOptions.bad')}
</SelectItem>
<SelectItem value="worst">
{t('settings.oneClickQualityOptions.worst')}
</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
</>
)}
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
<ItemDescription>
Display additional format options in the interface
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.showMoreFormats}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-2">
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
<ItemDescription>Maximum number of simultaneous downloads</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.maxConcurrentDownloads.toString()}
onValueChange={(value) =>
handleSettingChange('maxConcurrentDownloads', Number(value))
}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
<ItemDescription>
Browser to extract cookies from for authentication
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.browserForCookies}
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">Chrome</SelectItem>
<SelectItem value="firefox">Firefox</SelectItem>
<SelectItem value="edge">Edge</SelectItem>
<SelectItem value="safari">Safari</SelectItem>
<SelectItem value="brave">Brave</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.proxy')}</ItemTitle>
<ItemDescription>Proxy server for network requests</ItemDescription>
</ItemContent>
<ItemActions>
<Input
placeholder="http://proxy:port"
value={settings.proxy}
onChange={(e) => handleSettingChange('proxy', e.target.value)}
className="w-64"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>Custom configuration file for yt-dlp</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.configPath} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
</div>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { popularSites } from '@renderer/data/popularSites'
import { useTranslation } from 'react-i18next'
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
export function SupportedSites() {
const { t } = useTranslation()
return (
<div className="container mx-auto max-w-5xl p-6 space-y-6" style={{ maxWidth: '100%' }}>
<Card>
<CardHeader>
<CardTitle>{t('sites.pageTitle')}</CardTitle>
<CardDescription>{t('sites.pageDescription')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('sites.pageIntro')}</p>
</CardContent>
</Card>
<section className="space-y-3">
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
<ul className="grid gap-3 sm:grid-cols-2">
{popularSites.map((site) => (
<li key={site.id} className="rounded-md border border-border px-6 py-5">
<p className="text-sm font-medium">{site.label}</p>
{site.description ? (
<p className="mt-1 text-xs text-muted-foreground">{site.description}</p>
) : null}
</li>
))}
</ul>
</section>
<Card>
<CardHeader>
<CardTitle>{t('sites.moreTitle')}</CardTitle>
<CardDescription>{t('sites.moreDescription')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<a href={referenceUrl} target="_blank" rel="noreferrer">
{t('sites.openFullList')}
</a>
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { atom } from 'jotai'
import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
export type DownloadRecord = DownloadItem & {
entryType: 'active' | 'history'
downloadedAt?: number
}
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
const toActiveRecord = (item: DownloadItem): DownloadRecord => ({
...item,
entryType: 'active'
})
const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
id: item.id,
url: item.url,
title: item.title,
thumbnail: item.thumbnail,
type: item.type,
status: item.status,
progress: undefined,
error: item.error,
outputPath: item.outputPath,
speed: undefined,
duration: item.duration,
fileSize: item.fileSize,
format: item.format,
quality: item.quality,
codec: item.codec,
createdAt: item.downloadedAt,
startedAt: item.downloadedAt,
completedAt: item.completedAt ?? item.downloadedAt,
description: item.description,
channel: item.channel,
uploader: item.uploader,
viewCount: item.viewCount,
tags: item.tags,
selectedFormat: item.selectedFormat,
entryType: 'history',
downloadedAt: item.downloadedAt
})
export const downloadRecordsAtom = atom<Map<string, DownloadRecord>>(new Map())
export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.set(recordKey('active', item.id), toActiveRecord(item))
set(downloadRecordsAtom, downloads)
})
export const updateDownloadAtom = atom(
null,
(get, set, update: { id: string; changes: Partial<DownloadItem> }) => {
const downloads = new Map(get(downloadRecordsAtom))
const key = recordKey('active', update.id)
const existing = downloads.get(key)
if (!existing) {
return
}
downloads.set(key, { ...existing, ...update.changes })
set(downloadRecordsAtom, downloads)
}
)
export const removeDownloadAtom = atom(null, (get, set, id: string) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.delete(recordKey('active', id))
set(downloadRecordsAtom, downloads)
})
export const clearCompletedAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'active' && item.status === 'completed') {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.set(recordKey('history', item.id), toHistoryRecord(item))
set(downloadRecordsAtom, downloads)
})
export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.delete(recordKey('history', id))
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history') {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsByStatusAtom = atom(
null,
(get, set, status: DownloadHistoryItem['status']) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history' && item.status === status) {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
}
)
export const downloadsArrayAtom = atom((get) => {
const downloads = get(downloadRecordsAtom)
return Array.from(downloads.values()).sort((a, b) => b.createdAt - a.createdAt)
})
export const activeDownloadsArrayAtom = atom((get) =>
get(downloadsArrayAtom).filter((item) => item.entryType === 'active')
)
export const historyDownloadsArrayAtom = atom((get) =>
get(downloadsArrayAtom).filter((item) => item.entryType === 'history')
)
export const historyStatsAtom = atom((get) => {
const history = get(historyDownloadsArrayAtom)
return history.reduce(
(acc, item) => {
acc.total += 1
if (item.status === 'completed') acc.completed += 1
if (item.status === 'error') acc.error += 1
if (item.status === 'cancelled') acc.cancelled += 1
return acc
},
{ total: 0, completed: 0, error: 0, cancelled: 0 }
)
})
export const downloadStatsAtom = atom((get) => {
const downloads = get(downloadsArrayAtom)
return downloads.reduce(
(acc, item) => {
acc.total += 1
if (
item.entryType === 'active' &&
(item.status === 'downloading' || item.status === 'processing' || item.status === 'pending')
) {
acc.active += 1
}
if (item.status === 'completed') acc.completed += 1
if (item.status === 'error') acc.error += 1
if (item.status === 'cancelled') acc.cancelled += 1
return acc
},
{ total: 0, active: 0, completed: 0, error: 0, cancelled: 0 }
)
})
export const activeDownloadsCountAtom = atom((get) => {
const downloads = get(downloadRecordsAtom)
let count = 0
for (const item of downloads.values()) {
if (
item.entryType === 'active' &&
(item.status === 'downloading' || item.status === 'processing')
) {
count++
}
}
return count
})

View File

@@ -0,0 +1,45 @@
import { atom } from 'jotai'
import type { AppSettings } from '../../../shared/types'
import { defaultSettings } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
// Settings atom
export const settingsAtom = atom<AppSettings>(defaultSettings)
// Load settings from main process
export const loadSettingsAtom = atom(null, async (_get, set) => {
try {
const settings = await ipcServices.settings.getAll()
set(settingsAtom, settings)
} catch (error) {
console.error('Failed to load settings:', error)
}
})
// Save a specific setting
export const saveSettingAtom = atom(
null,
async (get, set, update: { key: keyof AppSettings; value: AppSettings[keyof AppSettings] }) => {
try {
await ipcServices.settings.set(update.key, update.value)
const settings = get(settingsAtom)
set(settingsAtom, { ...settings, [update.key]: update.value })
} catch (error) {
console.error('Failed to save setting:', error)
}
}
)
// Save all settings
export const saveAllSettingsAtom = atom(
null,
async (get, set, newSettings: Partial<AppSettings>) => {
try {
await ipcServices.settings.setAll(newSettings)
const settings = get(settingsAtom)
set(settingsAtom, { ...settings, ...newSettings })
} catch (error) {
console.error('Failed to save settings:', error)
}
}
)

View File

@@ -0,0 +1,35 @@
import { atom } from 'jotai'
import type { VideoInfo } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
// Current video info being prepared for download
export const currentVideoInfoAtom = atom<VideoInfo | null>(null)
// Loading state for video info
export const videoInfoLoadingAtom = atom<boolean>(false)
// Error state for video info
export const videoInfoErrorAtom = atom<string | null>(null)
// Fetch video info
export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
set(videoInfoLoadingAtom, true)
set(videoInfoErrorAtom, null)
set(currentVideoInfoAtom, null)
try {
const info = await ipcServices.download.getVideoInfo(url)
set(currentVideoInfoAtom, info)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'
set(videoInfoErrorAtom, errorMessage)
} finally {
set(videoInfoLoadingAtom, false)
}
})
// Clear video info
export const clearVideoInfoAtom = atom(null, (_get, set) => {
set(currentVideoInfoAtom, null)
set(videoInfoErrorAtom, null)
})