feat: refactor download components and enhance UI/UX

* Rename ElectronStore to LegacyStore for clarity in subscription manager.
* Remove unused deep link state in AppContent and add a TODO for future handling.
* Introduce a new DownloadDialog component for managing downloads.
* Update DownloadItem to include a details sheet for video metadata.
* Enhance PlaylistDownloadGroup to persist expanded state in local storage.
* Improve UnifiedDownloadHistory layout and integrate DownloadDialog for better user interaction.
* Update various UI components for consistency and improved styling.
* Add new localization strings for download actions across multiple languages.
This commit is contained in:
Nexmoe
2025-12-20 17:03:12 +08:00
parent 86e6b93476
commit a1c44baf65
29 changed files with 1880 additions and 1472 deletions

View File

@@ -493,7 +493,9 @@ export class SubscriptionManager extends EventEmitter {
private migrateLegacyStore(): void {
try {
const LegacyStore = require('electron-store')
const ElectronStore = require('electron-store')
// Access the default export
const LegacyStore = ElectronStore.default || ElectronStore
const store = new LegacyStore({
name: 'subscriptions',
defaults: {

View File

@@ -54,7 +54,6 @@ function AppContent() {
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
const [deepLinkUrl, setDeepLinkUrl] = useState<string | null>(null)
const navigate = useNavigate()
const location = useLocation()
const currentPage = pathToPage(location.pathname)
@@ -81,8 +80,8 @@ function AppContent() {
if (!url) {
return
}
setDeepLinkUrl(url)
handlePageChange('home')
// TODO: Handle deep link URL in download dialog
}
ipcEvents.on('download:deeplink', handleDeepLink)
@@ -260,14 +259,12 @@ function AppContent() {
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
style={{ maxWidth: '100%' }}
>
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
<Routes>
<Route
path="/"
element={
<Home
deepLinkUrl={deepLinkUrl}
onConsumeDeepLink={() => setDeepLinkUrl(null)}
onOpenSupportedSites={handleOpenSupportedSites}
onOpenSettings={() => handlePageChange('settings')}
/>

View File

@@ -34,7 +34,7 @@
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--radius: 0.625rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
@@ -89,7 +89,6 @@
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
}
@theme inline {
@@ -131,7 +130,10 @@
--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);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,21 @@ import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
import { Progress } from '@renderer/components/ui/progress'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@renderer/components/ui/sheet'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useAtomValue, useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronUp,
Copy,
FolderOpen,
Info,
Loader2,
Play,
Trash2,
@@ -187,7 +193,6 @@ const formatDateShort = (timestamp?: number) => {
if (!timestamp) return ''
const date = new Date(timestamp)
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
@@ -204,19 +209,15 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
const isSubscriptionDownload = download.origin === 'subscription'
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
'relative z-20 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 actionsContainerClass =
'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity'
const resolvedExtension = resolveDownloadExtension(download)
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
const selectionEnabled = isHistory && Boolean(onToggleSelect)
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
const [detailsOpen, setDetailsOpen] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
// Check if file exists when download data changes
useEffect(() => {
@@ -647,44 +648,41 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
return (
<div
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
isSelectedHistory ? 'border-primary/60 bg-primary/10 ring-1 ring-primary/30' : ''
isSelectedHistory ? 'bg-primary/10' : ''
}`}
>
{isSelectedHistory && (
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
)}
<button
type="button"
className={`absolute inset-0 z-10 rounded-lg bg-transparent ${
selectionEnabled ? 'cursor-pointer' : 'cursor-default'
} disabled:cursor-default disabled:opacity-100`}
aria-label={t('history.selectItem')}
disabled={!selectionEnabled}
onClick={() => onToggleSelect?.(download.id)}
/>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
<div
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
selectionEnabled ? 'cursor-pointer' : ''
}`}
onClick={selectionEnabled ? () => onToggleSelect?.(download.id) : undefined}
onKeyDown={
selectionEnabled
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggleSelect?.(download.id)
}
}
: undefined
}
role={selectionEnabled ? 'button' : undefined}
tabIndex={selectionEnabled ? 0 : undefined}
aria-label={selectionEnabled ? t('history.selectItem') : undefined}
>
{/* Thumbnail */}
<button
type="button"
className={`relative z-20 shrink-0 overflow-hidden rounded-md border bg-background/60 w-32 h-20 disabled:opacity-100 disabled:cursor-default ${
selectionEnabled ? 'cursor-pointer' : 'cursor-default'
} ${isSelectedHistory ? 'border-primary/40' : 'border-border/60'}`}
aria-pressed={selectionEnabled ? Boolean(isSelected) : undefined}
onClick={(event) => {
event.stopPropagation()
if (selectionEnabled) {
onToggleSelect?.(download.id)
}
}}
disabled={!selectionEnabled}
>
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
{selectionEnabled && (
<div
className={`absolute left-1 top-1 rounded-md bg-background/80 p-0.5 shadow-sm transition ${
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
}`}
onClick={(event) => event.stopPropagation()}
>
<Checkbox
checked={Boolean(isSelected)}
@@ -698,16 +696,16 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
src={download.thumbnail}
alt={download.title}
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6" />}
fallbackIcon={<Play className="h-4 w-4" />}
/>
</button>
</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">
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
{download.title}
</p>
{isSubscriptionDownload && (
@@ -716,103 +714,64 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
</Badge>
)}
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
{(statusIcon || statusText) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcon}
<span>{statusText}</span>
</div>
)}
{timestamp ? (
<span className="truncate text-muted-foreground/80">
{formatDateShort(timestamp)}
</span>
) : null}
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
{/* Source link */}
{(sourceDisplay || download.url) &&
(download.url ? (
<Tooltip>
<TooltipTrigger asChild>
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="relative z-20 max-w-[180px] truncate hover:text-primary transition-colors"
>
{sourceDisplay || download.url}
</a>
</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-word">
<p>{download.url}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="truncate">{sourceDisplay}</span>
))}
{/* Playlist info */}
{download.playlistId && (
<>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('playlist.badgeLabel')}
</Badge>
<span className="max-w-[200px] truncate">
{download.playlistTitle || t('playlist.untitled')}
{download.playlistIndex !== undefined &&
download.playlistSize !== undefined &&
` (${download.playlistIndex}/${download.playlistSize})`}
</span>
</>
)}
{/* Quality badge */}
{qualityLabel && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
{qualityLabel}
</Badge>
)}
{/* File size */}
{inlineFileSize && <span>{inlineFileSize}</span>}
{/* Details toggle */}
{hasMetadataDetails && (
<div className="flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
{/* Status */}
{statusIcon && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={detailsOpen ? 'default' : 'ghost'}
size="icon"
className="relative z-20 h-6 w-6 shrink-0"
type="button"
onClick={() => setDetailsOpen((prev) => !prev)}
>
{detailsOpen ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
<div className="flex items-center shrink-0">{statusIcon}</div>
</TooltipTrigger>
<TooltipContent>
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
<p>{statusText}</p>
</TooltipContent>
</Tooltip>
)}
{/* Timestamp */}
{timestamp && (
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
)}
{/* Quality */}
{qualityLabel && (
<>
{(statusIcon || timestamp) && (
<span className="text-muted-foreground/60 shrink-0"></span>
)}
<span className="shrink-0">{qualityLabel}</span>
</>
)}
{/* File size */}
{inlineFileSize && (
<>
{(statusIcon || timestamp || qualityLabel) && (
<span className="text-muted-foreground/60 shrink-0"></span>
)}
<span className="shrink-0">{inlineFileSize}</span>
</>
)}
</div>
{detailsOpen && hasMetadataDetails && (
<div className="space-y-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-3 text-xs">
{metadataDetails.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex gap-3">
<span className="w-24 shrink-0 text-muted-foreground">{item.label}</span>
<span className="flex-1 wrap-break-word text-foreground">{item.value}</span>
</div>
))}
</div>
)}
</div>
<div className={actionsContainerClass}>
<div className={`${actionsContainerClass} pointer-events-auto`}>
{/* Info button - show details in sheet */}
{hasMetadataDetails && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
setSheetOpen(true)
}}
>
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('download.showDetails')}</p>
</TooltipContent>
</Tooltip>
)}
{isHistory ? (
<>
{download.status === 'completed' && (
@@ -822,8 +781,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleCopyToClipboard()
}}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
@@ -838,8 +800,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleOpenFolder()
}}
>
<FolderOpen className="h-4 w-4" />
</Button>
@@ -855,8 +820,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleRemoveHistory}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleRemoveHistory()
}}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -875,8 +843,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleCopyToClipboard()
}}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
@@ -891,8 +862,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleOpenFolder()
}}
>
<FolderOpen className="h-4 w-4" />
</Button>
@@ -909,8 +883,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCancel}
className="h-8 w-8 shrink-0 rounded-full"
onClick={(e) => {
e.stopPropagation()
handleCancel()
}}
>
<X className="h-4 w-4" />
</Button>
@@ -922,13 +899,13 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
{/* 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">
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
<Progress value={download.progress.percent} className="h-1 w-full" />
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] 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">
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total}
@@ -953,6 +930,32 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
)}
</div>
</div>
{/* Video Details Sheet */}
{hasMetadataDetails && (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col p-0">
<div className="flex flex-col h-full overflow-hidden">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle className="line-clamp-2">{download.title}</SheetTitle>
<SheetDescription>{t('download.videoInfo')}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
{metadataDetails.map((item, index) => (
<div key={`${item.label}-${index}`} className="flex flex-col gap-1">
<span className="text-sm font-medium text-muted-foreground">
{item.label}
</span>
<div className="text-sm text-foreground break-words">{item.value}</div>
</div>
))}
</div>
</div>
</div>
</SheetContent>
</Sheet>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { DownloadRecord } from '../../store/downloads'
import { Button } from '../ui/button'
@@ -16,6 +16,30 @@ interface PlaylistDownloadGroupProps {
onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void
}
const STORAGE_KEY_PREFIX = 'playlist_expanded_'
const getStorageKey = (groupId: string): string => {
return `${STORAGE_KEY_PREFIX}${groupId}`
}
const loadExpandedState = (groupId: string): boolean => {
try {
const stored = localStorage.getItem(getStorageKey(groupId))
return stored === 'true'
} catch (error) {
console.error('Failed to load playlist expanded state:', error)
return false
}
}
const saveExpandedState = (groupId: string, isExpanded: boolean): void => {
try {
localStorage.setItem(getStorageKey(groupId), String(isExpanded))
} catch (error) {
console.error('Failed to save playlist expanded state:', error)
}
}
export function PlaylistDownloadGroup({
groupId,
title,
@@ -26,7 +50,11 @@ export function PlaylistDownloadGroup({
onDeletePlaylist
}: PlaylistDownloadGroupProps) {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(true)
const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId))
useEffect(() => {
saveExpandedState(groupId, isExpanded)
}, [groupId, isExpanded])
const completedCount = records.filter((record) => record.status === 'completed').length
const errorCount = records.filter((record) => record.status === 'error').length
@@ -50,33 +78,53 @@ export function PlaylistDownloadGroup({
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
return (
<div className="space-y-3 rounded-md border border-border/50 bg-background/60 px-3 py-2.5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
{isExpanded ? (
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
</p>
)}
<div className="space-y-2 rounded-md bg-muted/20 px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<button
type="button"
className="flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setIsExpanded((prev) => !prev)}
aria-expanded={isExpanded}
aria-label={toggleLabel}
title={toggleLabel}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{displayTitle}</p>
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
</span>
{activeCount > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<span>{t('playlist.groupActive', { count: activeCount })}</span>
</>
)}
{errorCount > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
</>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
{errorCount > 0 && (
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
<div className="flex shrink-0 items-center gap-1">
{canDeletePlaylist && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-6 w-6 rounded-full"
onClick={() =>
onDeletePlaylist?.(
groupId,
@@ -87,45 +135,36 @@ export function PlaylistDownloadGroup({
aria-label={t('history.deletePlaylist')}
title={t('history.deletePlaylist')}
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<button
type="button"
className="ml-1 inline-flex h-7 w-7 items-center justify-center rounded-full text-foreground/70 transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setIsExpanded((prev) => !prev)}
aria-expanded={isExpanded}
aria-label={toggleLabel}
title={toggleLabel}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
</div>
</div>
{!isExpanded && totalCount > 0 && (
<div className="space-y-1.5">
<Progress value={aggregatePercent} className="h-1 w-full" />
</div>
<Progress value={aggregatePercent} className="h-0.5 w-full" />
)}
{isExpanded && (
<div className="space-y-2">
{records.map((record) => (
<div key={`${groupId}:${record.entryType}:${record.id}`}>
<DownloadItem
download={record}
isSelected={selectedIds?.has(record.id) ?? false}
onToggleSelect={onToggleSelect}
/>
</div>
))}
<div
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out"
style={{
gridTemplateRows: isExpanded ? '1fr' : '0fr'
}}
>
<div className="min-h-0">
<div className="space-y-3 pt-1">
{records.map((record) => (
<div key={`${groupId}:${record.entryType}:${record.id}`}>
<DownloadItem
download={record}
isSelected={selectedIds?.has(record.id) ?? false}
onToggleSelect={onToggleSelect}
/>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -18,19 +18,18 @@ import { useHistorySync } from '../../hooks/use-history-sync'
import { ipcServices } from '../../lib/ipc'
import type { DownloadRecord } from '../../store/downloads'
import {
clearHistoryRecordsAtom,
downloadStatsAtom,
downloadsArrayAtom,
removeHistoryRecordsAtom,
removeHistoryRecordsByPlaylistAtom
} from '../../store/downloads'
import { settingsAtom } from '../../store/settings'
import { DownloadDialog } from './DownloadDialog'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
type ConfirmAction =
| { type: 'clear-all' }
| { type: 'delete-selected'; ids: string[] }
| { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }
@@ -112,11 +111,18 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
return download.type === 'audio' ? 'mp3' : 'mp4'
}
export function UnifiedDownloadHistory() {
interface UnifiedDownloadHistoryProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
}
export function UnifiedDownloadHistory({
onOpenSupportedSites,
onOpenSettings
}: UnifiedDownloadHistoryProps) {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearHistoryRecords = useSetAtom(clearHistoryRecordsAtom)
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
const settings = useAtomValue(settingsAtom)
@@ -165,7 +171,6 @@ export function UnifiedDownloadHistory() {
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
[filteredRecords]
)
const hasHistory = historyRecords.length > 0
const selectableCount = selectableIds.length
const selectionSummary =
selectableCount === 0
@@ -207,13 +212,6 @@ export function UnifiedDownloadHistory() {
setSelectedIds(new Set())
}
const handleRequestClearAll = () => {
if (!hasHistory) {
return
}
setConfirmAction({ type: 'clear-all' })
}
const handleRequestDeleteSelected = () => {
if (selectedIds.size === 0) {
return
@@ -249,13 +247,6 @@ export function UnifiedDownloadHistory() {
return null
}
switch (confirmAction.type) {
case 'clear-all': {
return {
title: t('history.confirmClearAllTitle'),
description: t('history.confirmClearAllDescription', { count: historyRecords.length }),
actionLabel: t('history.clearAllAction')
}
}
case 'delete-selected': {
return {
title: t('history.confirmDeleteSelectedTitle'),
@@ -278,7 +269,7 @@ export function UnifiedDownloadHistory() {
default:
return null
}
}, [confirmAction, historyRecords.length, t])
}, [confirmAction, t])
const deleteHistoryFiles = async (records: DownloadRecord[]) => {
const failedIds: string[] = []
@@ -315,12 +306,6 @@ export function UnifiedDownloadHistory() {
}
setConfirmBusy(true)
try {
if (confirmAction.type === 'clear-all') {
await ipcServices.history.clearHistory()
clearHistoryRecords()
setSelectedIds(new Set())
toast.success(t('notifications.historyCleared'))
}
if (confirmAction.type === 'delete-selected') {
await ipcServices.history.removeHistoryItems(confirmAction.ids)
removeHistoryRecords(confirmAction.ids)
@@ -340,10 +325,6 @@ export function UnifiedDownloadHistory() {
}
setConfirmAction(null)
} catch (error) {
if (confirmAction.type === 'clear-all') {
console.error('Failed to clear history:', error)
toast.error(t('notifications.historyClearFailed'))
}
if (confirmAction.type === 'delete-selected') {
console.error('Failed to remove selected history items:', error)
toast.error(t('notifications.itemsRemoveFailed'))
@@ -409,7 +390,7 @@ export function UnifiedDownloadHistory() {
return (
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
<CardHeader className="gap-4 p-0">
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
{filters.map((filter) => {
@@ -439,25 +420,22 @@ export function UnifiedDownloadHistory() {
)
})}
</div>
<Button
variant="outline"
size="sm"
className="h-8 rounded-full px-3"
onClick={handleRequestClearAll}
disabled={!hasHistory}
>
{t('history.clearAll')}
</Button>
<div className="flex items-center gap-2">
<DownloadDialog
onOpenSupportedSites={onOpenSupportedSites}
onOpenSettings={onOpenSettings}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
<CardContent className="space-y-3 p-0 overflow-x-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">
<div className="flex flex-col items-center justify-center gap-3 rounded-xl 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-3 sm:space-y-4 overflow-hidden w-full">
<div className="space-y-4 w-full">
{groupedView.order.map((item) => {
if (item.type === 'single') {
return (

View File

@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
<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',
'peer 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}
@@ -22,5 +22,4 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,127 @@
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { cn } from '@renderer/lib/utils'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription
}

View File

@@ -130,7 +130,7 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
variant="ghost"
size="icon"
onClick={handleClick}
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
@@ -167,7 +167,7 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="no-drag w-12 h-12">
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
@@ -211,7 +211,7 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>

View File

@@ -4,15 +4,9 @@ import {
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
@@ -21,8 +15,7 @@ interface AdvancedOptionsProps {
onStartTimeChange: (value: string) => void
onEndTimeChange: (value: string) => void
onDownloadSubsChange: (value: boolean) => void
customDownloadPath: string
onCustomDownloadPathChange: (value: string) => void
showAccordion?: boolean
}
export function AdvancedOptions({
@@ -32,112 +25,64 @@ export function AdvancedOptions({
onStartTimeChange,
onEndTimeChange,
onDownloadSubsChange,
customDownloadPath,
onCustomDownloadPathChange
showAccordion = true
}: 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(t('settings.directorySelectError'))
}
}
const content = (
<div className="space-y-6">
{/* Time Range */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground ml-1">
{t('advancedOptions.timeRange')}
</Label>
<div className="flex items-center gap-4">
<div className="flex-1 relative group">
<Input
placeholder={t('advancedOptions.startPlaceholder')}
value={startTime}
onChange={(e) => onStartTimeChange(e.target.value)}
className="h-9 text-center"
title={t('advancedOptions.startHint')}
/>
</div>
<span className="text-muted-foreground text-xs">-</span>
<div className="flex-1 relative group">
<Input
placeholder={t('advancedOptions.endPlaceholder')}
value={endTime}
onChange={(e) => onEndTimeChange(e.target.value)}
className="h-9 text-center"
title={t('advancedOptions.endHint')}
/>
</div>
</div>
</div>
const handleSelectCustomLocation = async () => {
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
onCustomDownloadPathChange(path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError'))
}
{/* Subtitles */}
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label className="text-sm font-semibold">{t('advancedOptions.downloadSubs')}</Label>
<p className="text-[11px] text-muted-foreground">
{t('advancedOptions.downloadSubsHint')}
</p>
</div>
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
</div>
</div>
)
if (!showAccordion) {
return content
}
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>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('download.customDownloadFolder')}</Label>
{customDownloadPath.trim() && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCustomDownloadPathChange('')}
>
{t('download.useAutoFolder')}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Input
value={customDownloadPath}
readOnly
className="flex-1"
placeholder={t('download.autoFolderPlaceholder')}
/>
<Button onClick={handleSelectCustomLocation} variant="outline">
{t('settings.selectPath')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('download.autoFolderHint')}</p>
</div>
</AccordionContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced" className="border-b">
<AccordionTrigger className="flex items-center gap-2 py-4 text-sm font-semibold hover:no-underline">
<span className="flex-1 text-left">{t('advancedOptions.title')}</span>
</AccordionTrigger>
<AccordionContent className="pb-6 pt-2">{content}</AccordionContent>
</AccordionItem>
</Accordion>
)

View File

@@ -1,4 +1,3 @@
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 {
@@ -8,19 +7,27 @@ import {
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 interface AudioExtractorState {
extractFormat: string
extractQuality: string
}
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
interface AudioExtractorProps {
videoInfo: VideoInfo
state: AudioExtractorState
onStateChange: (state: Partial<AudioExtractorState>) => void
}
export function AudioExtractor({
videoInfo: _videoInfo,
state,
onStateChange
}: AudioExtractorProps) {
const { t } = useTranslation()
const [extractFormat, setExtractFormat] = useState('mp3')
const [extractQuality, setExtractQuality] = useState('5')
const { extractFormat, extractQuality } = state
const audioFormats = [
{ value: 'mp3', label: 'MP3' },
@@ -49,7 +56,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<Select
value={extractFormat}
onValueChange={(value) => onStateChange({ extractFormat: value })}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
@@ -65,7 +75,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<Select
value={extractQuality}
onValueChange={(value) => onStateChange({ extractQuality: value })}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
@@ -79,10 +92,6 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
</Select>
</div>
</div>
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
{t('audioExtract.extract')}
</Button>
</CardContent>
</Card>
)

View File

@@ -1,33 +1,34 @@
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 { Card, CardContent, CardHeader } 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 { useEffect, useId, useState } from 'react'
import { Clock, Eye, Play } from 'lucide-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 { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
import { FormatSelector } from './FormatSelector'
export interface VideoInfoCardState {
title: string
activeTab: 'video' | 'audio'
selectedVideoFormat: string
selectedAudioForVideo: string
selectedAudioFormat: string
startTime: string
endTime: string
downloadSubs: boolean
customDownloadPath: string
audioExtractor: AudioExtractorState
}
interface VideoInfoCardProps {
videoInfo: VideoInfo
state: VideoInfoCardState
onStateChange: (state: Partial<VideoInfoCardState>) => void
onTabChange: (tab: 'video' | 'audio') => void
}
function formatDuration(seconds?: number): string {
@@ -46,135 +47,62 @@ function formatViews(views?: number): string {
return views.toString()
}
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
export function VideoInfoCard({
videoInfo,
state,
onStateChange,
onTabChange
}: 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 [customDownloadPath, setCustomDownloadPath] = useState('')
useEffect(() => {
setCustomDownloadPath('')
}, [videoInfo.id])
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,
customDownloadPath: customDownloadPath.trim() || undefined
}
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'))
}
}
const { title, activeTab } = state
return (
<div className="space-y-5">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
<ArrowLeft className="h-4 w-4" />
{t('download.back')}
</Button>
<Card className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex flex-col md:flex-row gap-6">
<div>
<Card className="overflow-hidden border shadow-sm">
<CardHeader className="p-3">
<div className="flex gap-3">
{/* Thumbnail */}
<div className="shrink-0">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
fallbackIcon={<Play className="h-12 w-12" />}
/>
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full h-full object-cover"
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
/>
</div>
</div>
{/* Video Metadata */}
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-3">
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
<CardDescription className="flex flex-wrap gap-2 items-center">
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Clock className="h-3.5 w-3.5" />
<span>{formatDuration(videoInfo.duration)}</span>
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Eye className="h-3.5 w-3.5" />
<span>{formatViews(videoInfo.view_count)}</span>
</Badge>
)}
{videoInfo.uploader && (
<Badge variant="outline" className="px-2.5 py-1">
{videoInfo.uploader}
</Badge>
)}
</CardDescription>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex flex-wrap gap-1.5 items-center">
<Badge
variant="outline"
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
>
{videoInfo.extractor_key || t('download.videoInfo')}
</Badge>
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
<Clock className="h-2.5 w-2.5" />
<span>{formatDuration(videoInfo.duration)}</span>
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
<Eye className="h-2.5 w-2.5" />
<span>{formatViews(videoInfo.view_count)}</span>
</Badge>
)}
</div>
<div className="space-y-2.5">
<Label htmlFor={titleId} className="text-sm font-semibold">
{t('download.title')}
</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium h-10"
/>
<div className="space-y-1">
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
{videoInfo.uploader && (
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
)}
</div>
</div>
</div>
@@ -182,76 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
<Separator />
<CardContent className="pt-6 pb-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="video" className="text-sm font-medium">
<CardContent className="p-3 pt-3">
<Tabs
value={activeTab}
onValueChange={(v) => {
onTabChange(v as 'video' | 'audio')
onStateChange({ activeTab: v as 'video' | 'audio' })
}}
className="w-full"
>
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
<TabsTrigger value="video" className="text-xs">
{t('download.video')}
</TabsTrigger>
<TabsTrigger value="audio" className="text-sm font-medium">
<TabsTrigger value="audio" className="text-xs">
{t('download.audio')}
</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-5 mt-0">
<TabsContent value="video" className="space-y-3 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
onVideoFormatChange={setSelectedVideoFormat}
onAudioFormatChange={setSelectedAudioForVideo}
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
/>
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
customDownloadPath={customDownloadPath}
onCustomDownloadPathChange={setCustomDownloadPath}
/>
<Button
onClick={() => handleDownload('video')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadVideo')}
</Button>
</TabsContent>
<TabsContent value="audio" className="space-y-5 mt-0">
<TabsContent value="audio" className="space-y-3 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
onAudioFormatChange={setSelectedAudioFormat}
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
/>
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
customDownloadPath={customDownloadPath}
onCustomDownloadPathChange={setCustomDownloadPath}
<AudioExtractor
videoInfo={videoInfo}
state={state.audioExtractor}
onStateChange={(updates) =>
onStateChange({
audioExtractor: { ...state.audioExtractor, ...updates }
})
}
/>
<Button
onClick={() => handleDownload('audio')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadAudio')}
</Button>
</TabsContent>
</Tabs>
</CardContent>

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "اختر تنسيق الصوت",
"selectFormat": "اختر التنسيق",
"selectVideoFormat": "اختر تنسيق الفيديو",
"startDownload": "بدء التحميل",
"singleVideo": "فيديو واحد",
"speed": "السرعة",
"title": "العنوان",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Audio-Format auswählen",
"selectFormat": "Format auswählen",
"selectVideoFormat": "Video-Format auswählen",
"startDownload": "Download starten",
"singleVideo": "Einzelnes Video",
"speed": "Geschwindigkeit",
"title": "Titel",

View File

@@ -159,7 +159,9 @@
"showDetails": "Show details",
"hideDetails": "Hide details",
"selectAudioFormat": "Select Audio Format",
"selectDownloadType": "Select download type",
"selectFormat": "Select Format",
"startDownload": "Start Download",
"selectVideoFormat": "Select Video Format",
"singleVideo": "Single Video",
"speed": "Speed",
@@ -330,6 +332,8 @@
"range": "Range (Optional)",
"resetToDefault": "Reset to default",
"selectedRange": "Range: {{start}}-{{end}}",
"selectedVideos": "{{count}} selected",
"downloadCurrentRange": "Download Selected",
"showingCount": "Showing {{count}} videos",
"startIndex": "Start (1)",
"title": "Download Playlist",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Seleccionar Formato de Audio",
"selectFormat": "Seleccionar Formato",
"selectVideoFormat": "Seleccionar Formato de Video",
"startDownload": "Iniciar descarga",
"singleVideo": "Video Individual",
"speed": "Velocidad",
"title": "Título",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Sélectionner le Format Audio",
"selectFormat": "Sélectionner le Format",
"selectVideoFormat": "Sélectionner le Format Vidéo",
"startDownload": "Démarrer le téléchargement",
"showDetails": "Afficher les détails",
"singleVideo": "Vidéo Unique",
"speed": "Vitesse",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Pilih Format Audio",
"selectFormat": "Pilih Format",
"selectVideoFormat": "Pilih Format Video",
"startDownload": "Mulai unduhan",
"singleVideo": "Video Tunggal",
"speed": "Kecepatan",
"title": "Judul",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Seleziona Formato Audio",
"selectFormat": "Seleziona Formato",
"selectVideoFormat": "Seleziona Formato Video",
"startDownload": "Avvia download",
"showDetails": "Mostra dettagli",
"singleVideo": "Video Singolo",
"speed": "Velocità",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "オーディオフォーマットを選択",
"selectFormat": "フォーマットを選択",
"selectVideoFormat": "ビデオフォーマットを選択",
"startDownload": "ダウンロードを開始",
"showDetails": "詳細を表示",
"singleVideo": "単一ビデオ",
"speed": "速度",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "오디오 형식 선택",
"selectFormat": "형식 선택",
"selectVideoFormat": "비디오 형식 선택",
"startDownload": "다운로드 시작",
"showDetails": "세부정보 표시",
"singleVideo": "단일 비디오",
"speed": "속도",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Selecionar Formato de Áudio",
"selectFormat": "Selecionar Formato",
"selectVideoFormat": "Selecionar Formato de Vídeo",
"startDownload": "Iniciar download",
"showDetails": "Mostrar detalhes",
"singleVideo": "Vídeo Único",
"speed": "Velocidade",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Выбрать формат аудио",
"selectFormat": "Выбрать формат",
"selectVideoFormat": "Выбрать формат видео",
"startDownload": "Начать загрузку",
"singleVideo": "Одно видео",
"speed": "Скорость",
"title": "Название",

View File

@@ -180,8 +180,10 @@
"processing": "處理中",
"progress": "進度",
"selectAudioFormat": "選擇音訊格式",
"selectDownloadType": "選擇下載類型",
"selectFormat": "選擇格式",
"selectVideoFormat": "選擇影片格式",
"startDownload": "開始下載",
"showDetails": "顯示詳情",
"singleVideo": "單個影片",
"speed": "速度",

View File

@@ -180,8 +180,10 @@
"processing": "处理中",
"progress": "进度",
"selectAudioFormat": "选择音频格式",
"selectDownloadType": "选择下载类型",
"selectFormat": "选择格式",
"selectVideoFormat": "选择视频格式",
"startDownload": "开始下载",
"showDetails": "显示详情",
"singleVideo": "单个视频",
"speed": "速度",

View File

@@ -1,914 +1,23 @@
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, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, List, Loader2, Play, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
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> = {
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | 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 ?? 'best'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio']
}
const abrLimit = qualityPresetToAudioAbr[preset]
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
}
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 {
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
combinations.push('bestvideo+bestaudio')
}
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
}
interface HomeProps {
deepLinkUrl?: string | null
onConsumeDeepLink?: () => void
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
}
export function Home({
deepLinkUrl,
onConsumeDeepLink,
onOpenSupportedSites,
onOpenSettings
}: 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 [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
const inlinePreviewSites = popularSites
.slice(0, 3)
.map((site) => t(`sites.popular.${site.id}.label`))
.join(', ')
// Playlist states
const playlistUrlId = useId()
const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('')
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const [playlistCustomDownloadPath, setPlaylistCustomDownloadPath] = useState('')
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const computePlaylistRange = useCallback(
(info: PlaylistInfo) => {
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
const endValue =
rawEnd !== undefined
? info.entryCount > 0
? Math.min(rawEnd, info.entryCount)
: rawEnd
: undefined
return { start, end: endValue }
},
[startIndex, endIndex]
)
const selectedPlaylistEntries = useMemo(() => {
if (!playlistInfo) {
return []
}
const range = computePlaylistRange(playlistInfo)
const previewEnd = range.end ?? playlistInfo.entryCount
return playlistInfo.entries.filter(
(entry) => entry.index >= range.start && entry.index <= previewEnd
)
}, [playlistInfo, computePlaylistRange])
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 startOneClickDownload = useCallback(
async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {
const trimmedUrl = targetUrl.trim()
if (!trimmedUrl) {
toast.error(t('errors.emptyUrl'))
return
}
if (options?.setInputValue) {
setUrl(trimmedUrl)
}
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
const downloadItem = {
id,
url: trimmedUrl,
title: t('download.fetchingVideoInfo'),
type: settings.oneClickDownloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
const format =
settings.oneClickDownloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
addDownload(downloadItem)
try {
await ipcServices.download.startDownload(id, {
url: trimmedUrl,
type: settings.oneClickDownloadType,
format
})
try {
const videoInfo = await ipcServices.download.getVideoInfo(trimmedUrl)
updateDownload({
id,
changes: {
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()
}
})
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()
})
toast.success(t('download.videoInfoUpdated'))
} catch (infoError) {
console.warn('Failed to fetch video info for one-click download:', infoError)
updateDownload({
id,
changes: {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
}
})
await ipcServices.download.updateDownloadInfo(id, {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
})
}
toast.success(t('download.oneClickDownloadStarted'))
if (options?.clearInput) {
setUrl('')
}
} catch (error) {
console.error('Failed to start one-click download:', error)
toast.error(t('notifications.downloadFailed'))
}
},
[settings, addDownload, updateDownload, t, setUrl]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleFetchVideo()
}
},
[handleFetchVideo]
)
const handleOneClickDownload = useCallback(async () => {
await startOneClickDownload(url, { clearInput: true })
}, [startOneClickDownload, url])
useEffect(() => {
if (!deepLinkUrl) {
return
}
setActiveTab('single')
void startOneClickDownload(deepLinkUrl, { setInputValue: true })
onConsumeDeepLink?.()
}, [deepLinkUrl, onConsumeDeepLink, startOneClickDownload])
// Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => {
if (playlistBusy) return
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
const trimmed = text.trim()
setPlaylistUrl(trimmed)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
setPlaylistCustomDownloadPath('')
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [playlistBusy, t])
const handleSelectPlaylistDirectory = useCallback(async () => {
if (playlistBusy) return
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
setPlaylistCustomDownloadPath(path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError'))
}
}, [playlistBusy, t])
const handleClearPlaylistPreview = useCallback(() => {
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}, [])
const handlePreviewPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
setPlaylistPreviewError(null)
setPlaylistPreviewLoading(true)
try {
const trimmedUrl = playlistUrl.trim()
const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
toast.success(t('playlist.foundVideos', { count: info.entryCount }))
} catch (error) {
console.error('Failed to fetch playlist info:', error)
const message =
error instanceof Error && error.message ? error.message : t('playlist.previewFailed')
setPlaylistPreviewError(message)
setPlaylistInfo(null)
toast.error(t('playlist.previewFailed'))
} finally {
setPlaylistPreviewLoading(false)
}
}, [playlistUrl, t])
const handleDownloadPlaylist = useCallback(async () => {
const trimmedUrl = playlistUrl.trim()
if (!trimmedUrl) {
toast.error(t('errors.emptyUrl'))
return
}
if (!playlistInfo) {
toast.error(t('playlist.previewRequired'))
return
}
setPlaylistPreviewError(null)
setPlaylistDownloadLoading(true)
try {
const info = playlistInfo
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
const range = computePlaylistRange(info)
const previewEnd = range.end ?? info.entryCount
if (previewEnd < range.start || previewEnd === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
const format =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
const result = await ipcServices.download.startPlaylistDownload({
url: trimmedUrl,
type: downloadType,
format,
startIndex: range.start,
endIndex: range.end,
customDownloadPath: playlistCustomDownloadPath.trim() || undefined
})
if (result.totalCount === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
const baseCreatedAt = Date.now()
result.entries.forEach((entry, index) => {
const downloadItem = {
id: entry.downloadId,
url: entry.url,
title: entry.title || t('download.fetchingVideoInfo'),
type: downloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: baseCreatedAt + index,
playlistId: result.groupId,
playlistTitle: result.playlistTitle,
playlistIndex: entry.index,
playlistSize: result.totalCount
}
addDownload(downloadItem)
})
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
} catch (error) {
console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed'))
} finally {
setPlaylistDownloadLoading(false)
}
}, [
playlistUrl,
playlistInfo,
computePlaylistRange,
downloadType,
settings,
addDownload,
t,
playlistCustomDownloadPath
])
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
return (
<div
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
style={{ maxWidth: '100%' }}
>
<Card>
<Tabs
defaultValue="single"
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
className="w-full gap-0"
>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle>
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
</CardTitle>
<CardDescription>
{activeTab === 'single' ? (
<div className="flex flex-wrap items-center gap-2">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="p-0 h-auto"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
) : (
t('playlist.playlistUrlDescription')
)}
</CardDescription>
</div>
<TabsList className="grid grid-cols-2">
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="single"
aria-label={t('download.singleVideo')}
className="flex items-center justify-center data-[state=inactive]:text-muted-foreground data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
>
<Play className="h-4 w-4" />
<span className="sr-only">{t('download.singleVideo')}</span>
</TabsTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{t('download.singleVideo')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<TabsTrigger
value="playlist"
aria-label={t('playlist.title')}
className="flex items-center justify-center data-[state=inactive]:text-muted-foreground data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
>
<List className="h-4 w-4" />
<span className="sr-only">{t('playlist.title')}</span>
</TabsTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{t('playlist.title')}</TooltipContent>
</Tooltip>
</TabsList>
</div>
</CardHeader>
<CardContent>
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6 mt-0">
{/* URL Input Card */}
{!videoInfo && (
<div 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="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('download.oneClickDownloadEnabled')}</span>
</div>
{onOpenSettings && (
<Button
type="button"
variant="link"
className="h-auto px-2 py-0 text-xs"
onClick={onOpenSettings}
>
{t('download.goToSettings')}
</Button>
)}
</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>
)}
</div>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6 mt-0">
<div 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)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
setPlaylistCustomDownloadPath('')
}}
className="flex-1"
disabled={playlistBusy}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistBusy}
>
{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={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label 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={playlistBusy}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>{t('download.customDownloadFolder')}</Label>
{playlistCustomDownloadPath.trim() && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPlaylistCustomDownloadPath('')}
disabled={playlistBusy}
>
{t('download.useAutoFolder')}
</Button>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={playlistCustomDownloadPath}
readOnly
className="flex-1"
placeholder={t('download.autoFolderPlaceholder')}
disabled={playlistBusy}
/>
<Button
onClick={handleSelectPlaylistDirectory}
variant="outline"
disabled={playlistBusy}
>
{t('settings.selectPath')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('download.autoFolderHint')}</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}
variant="outline"
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
)}
</div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
{/* Playlist Preview Card (outside main card) */}
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
<div className="w-full h-full flex flex-col min-h-0">
<div
className="container mx-auto max-w-7xl p-6 w-full h-full flex flex-col min-h-0"
style={{ maxWidth: '100%' }}
>
{/* Unified Download History */}
<UnifiedDownloadHistory
onOpenSupportedSites={onOpenSupportedSites}
onOpenSettings={onOpenSettings}
/>
)}
{/* Unified Download History */}
<UnifiedDownloadHistory />
</div>
</div>
)
}

View File

@@ -134,7 +134,7 @@ function SubscriptionTab({
<TabsTrigger
value={subscription.id}
className={cn(
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
'flex h-auto w-20 flex-col rounded-2xl items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
isActive && 'bg-muted/45'
)}
>
@@ -338,7 +338,7 @@ export function Subscriptions() {
))}
{/* Add RSS Button */}
<Button
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
className="flex h-auto w-20 flex-col items-center gap-1 rounded-2xl px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
variant="ghost"
onClick={() => setAddDialogOpen(true)}
>

View File

@@ -147,6 +147,7 @@ export interface PlaylistEntry {
title: string
url: string
index: number
thumbnail?: string
}
export interface PlaylistInfo {