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:
@@ -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: {
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1278
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
1278
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 }
|
||||
|
||||
127
src/renderer/src/components/ui/sheet.tsx
Normal file
127
src/renderer/src/components/ui/sheet.tsx
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "اختر تنسيق الصوت",
|
||||
"selectFormat": "اختر التنسيق",
|
||||
"selectVideoFormat": "اختر تنسيق الفيديو",
|
||||
"startDownload": "بدء التحميل",
|
||||
"singleVideo": "فيديو واحد",
|
||||
"speed": "السرعة",
|
||||
"title": "العنوان",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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à",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||
"selectFormat": "フォーマットを選択",
|
||||
"selectVideoFormat": "ビデオフォーマットを選択",
|
||||
"startDownload": "ダウンロードを開始",
|
||||
"showDetails": "詳細を表示",
|
||||
"singleVideo": "単一ビデオ",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "오디오 형식 선택",
|
||||
"selectFormat": "형식 선택",
|
||||
"selectVideoFormat": "비디오 형식 선택",
|
||||
"startDownload": "다운로드 시작",
|
||||
"showDetails": "세부정보 표시",
|
||||
"singleVideo": "단일 비디오",
|
||||
"speed": "속도",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "Выбрать формат аудио",
|
||||
"selectFormat": "Выбрать формат",
|
||||
"selectVideoFormat": "Выбрать формат видео",
|
||||
"startDownload": "Начать загрузку",
|
||||
"singleVideo": "Одно видео",
|
||||
"speed": "Скорость",
|
||||
"title": "Название",
|
||||
|
||||
@@ -180,8 +180,10 @@
|
||||
"processing": "處理中",
|
||||
"progress": "進度",
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectDownloadType": "選擇下載類型",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"startDownload": "開始下載",
|
||||
"showDetails": "顯示詳情",
|
||||
"singleVideo": "單個影片",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -180,8 +180,10 @@
|
||||
"processing": "处理中",
|
||||
"progress": "进度",
|
||||
"selectAudioFormat": "选择音频格式",
|
||||
"selectDownloadType": "选择下载类型",
|
||||
"selectFormat": "选择格式",
|
||||
"selectVideoFormat": "选择视频格式",
|
||||
"startDownload": "开始下载",
|
||||
"showDetails": "显示详情",
|
||||
"singleVideo": "单个视频",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface PlaylistEntry {
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
|
||||
Reference in New Issue
Block a user