From a1c44baf65e861f6c4855296ad8aa962c25fee74 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sat, 20 Dec 2025 17:03:12 +0800 Subject: [PATCH] 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. --- src/main/lib/subscription-manager.ts | 4 +- src/renderer/src/App.tsx | 7 +- src/renderer/src/assets/theme.css | 12 +- .../components/download/DownloadDialog.tsx | 1278 +++++++++++++++++ .../src/components/download/DownloadItem.tsx | 297 ++-- .../download/PlaylistDownloadGroup.tsx | 145 +- .../download/UnifiedDownloadHistory.tsx | 64 +- src/renderer/src/components/ui/checkbox.tsx | 3 +- src/renderer/src/components/ui/sheet.tsx | 127 ++ src/renderer/src/components/ui/sidebar.tsx | 6 +- .../src/components/video/AdvancedOptions.tsx | 159 +- .../src/components/video/AudioExtractor.tsx | 37 +- .../src/components/video/VideoInfoCard.tsx | 277 ++-- src/renderer/src/locales/ar.json | 1 + src/renderer/src/locales/de.json | 1 + src/renderer/src/locales/en.json | 4 + src/renderer/src/locales/es.json | 1 + src/renderer/src/locales/fr.json | 1 + src/renderer/src/locales/id.json | 1 + src/renderer/src/locales/it.json | 1 + src/renderer/src/locales/ja.json | 1 + src/renderer/src/locales/ko.json | 1 + src/renderer/src/locales/pt.json | 1 + src/renderer/src/locales/ru.json | 1 + src/renderer/src/locales/zh-TW.json | 2 + src/renderer/src/locales/zh.json | 2 + src/renderer/src/pages/Home.tsx | 913 +----------- src/renderer/src/pages/Subscriptions.tsx | 4 +- src/shared/types/index.ts | 1 + 29 files changed, 1880 insertions(+), 1472 deletions(-) create mode 100644 src/renderer/src/components/download/DownloadDialog.tsx create mode 100644 src/renderer/src/components/ui/sheet.tsx diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts index 95e1089..f5b65a4 100644 --- a/src/main/lib/subscription-manager.ts +++ b/src/main/lib/subscription-manager.ts @@ -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: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5dc26af..668f1a3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -54,7 +54,6 @@ function AppContent() { const { t } = useTranslation() const updateDownloadInProgressRef = useRef(false) const analyticsScriptRef = useRef(null) - const [deepLinkUrl, setDeepLinkUrl] = useState(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%' }} > -
+
setDeepLinkUrl(null)} onOpenSupportedSites={handleOpenSupportedSites} onOpenSettings={() => handlePageChange('settings')} /> diff --git a/src/renderer/src/assets/theme.css b/src/renderer/src/assets/theme.css index a4ce8bd..f902b1c 100644 --- a/src/renderer/src/assets/theme.css +++ b/src/renderer/src/assets/theme.css @@ -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); } diff --git a/src/renderer/src/components/download/DownloadDialog.tsx b/src/renderer/src/components/download/DownloadDialog.tsx new file mode 100644 index 0000000..1aeb7c0 --- /dev/null +++ b/src/renderer/src/components/download/DownloadDialog.tsx @@ -0,0 +1,1278 @@ +import { Button } from '@renderer/components/ui/button' +import { Checkbox } from '@renderer/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger +} from '@renderer/components/ui/dialog' +import { Input } from '@renderer/components/ui/input' +import { Label } from '@renderer/components/ui/label' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@renderer/components/ui/select' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' + +import { popularSites } from '@renderer/data/popularSites' + +import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types' +import { useAtom, useSetAtom } from 'jotai' +import { AlertCircle, Download, FolderOpen, List, Loader2, Plus, Search, Video } from 'lucide-react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +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' +import { AdvancedOptions } from '../video/AdvancedOptions' +import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard' + +const qualityPresetToVideoHeight: Record = { + best: null, + good: 1080, + normal: 720, + bad: 480, + worst: 360 +} + +const qualityPresetToAudioAbr: Record = { + best: 320, + good: 256, + normal: 192, + bad: 128, + worst: 96 +} + +const dedupe = (candidates: Array): string[] => { + const seen = new Set() + 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 DownloadDialogProps { + onOpenSupportedSites?: () => void + onOpenSettings?: () => void +} + +export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: DownloadDialogProps) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + 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(null) + const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single') + const inlinePreviewSites = popularSites + .slice(0, 3) + .map((site) => t(`sites.popular.${site.id}.label`)) + .join(', ') + + // VideoInfoCard state management + const [videoInfoCardState, setVideoInfoCardState] = useState({ + title: '', + activeTab: 'video', + selectedVideoFormat: '', + selectedAudioForVideo: '', + selectedAudioFormat: '', + startTime: '', + endTime: '', + downloadSubs: false, + customDownloadPath: '', + audioExtractor: { + extractFormat: 'mp3', + extractQuality: '5' + } + }) + + // Playlist states + const playlistUrlId = useId() + const downloadTypeId = useId() + const advancedOptionsId = useId() + const singleVideoAdvancedOptionsId = 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(null) + const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false) + const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false) + const [playlistPreviewError, setPlaylistPreviewError] = useState(null) + const playlistBusy = playlistPreviewLoading || playlistDownloadLoading + const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false) + const [singleVideoAdvancedOptionsOpen, setSingleVideoAdvancedOptionsOpen] = useState(false) + + 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(() => { + if (!open) return + + // Load settings when dialog opens + 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 () => { + // Event listeners are automatically cleaned up when the component unmounts + } + }, [open, loadSettings, syncHistoryItem, t, updateDownload]) + + 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] + ) + + const handleFetchVideo = useCallback(async () => { + if (!url.trim()) { + toast.error(t('errors.emptyUrl')) + return + } + await fetchVideoInfo(url.trim()) + }, [url, fetchVideoInfo, t]) + + const handlePasteUrl = useCallback(async () => { + try { + const text = await navigator.clipboard.readText() + if (!text.trim()) { + toast.error(t('errors.clipboardEmpty')) + return + } + const trimmedUrl = text.trim() + setUrl(trimmedUrl) + inputRef.current?.focus() + + // Auto-fetch video info after pasting + if (settings.oneClickDownload) { + await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) + setOpen(false) // Close dialog after download starts + } else { + await fetchVideoInfo(trimmedUrl) + } + } catch (error) { + console.error('Failed to paste URL:', error) + toast.error(t('errors.pasteFromClipboard')) + } + }, [t, settings, startOneClickDownload, fetchVideoInfo]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleFetchVideo() + } + }, + [handleFetchVideo] + ) + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + // Let the default paste behavior happen first + setTimeout(async () => { + const pastedText = e.clipboardData.getData('text') + if (!pastedText.trim()) return + + const trimmedUrl = pastedText.trim() + // Only auto-fetch if the URL actually changed + if (trimmedUrl !== url) { + if (settings.oneClickDownload) { + await startOneClickDownload(trimmedUrl, { setInputValue: false, clearInput: false }) + setOpen(false) // Close dialog after download starts + } else { + await fetchVideoInfo(trimmedUrl) + } + } + }, 0) + }, + [url, settings, startOneClickDownload, fetchVideoInfo] + ) + + const handlePlaylistPaste = useCallback( + async (e: React.ClipboardEvent) => { + if (playlistBusy) return + // Let the default paste behavior happen first + setTimeout(async () => { + const pastedText = e.clipboardData.getData('text') + if (!pastedText.trim()) return + + const trimmed = pastedText.trim() + // Only auto-preview if the URL actually changed + if (trimmed !== playlistUrl && trimmed) { + setPlaylistUrl(trimmed) + setPlaylistInfo(null) + setPlaylistPreviewError(null) + setPlaylistCustomDownloadPath('') + + // Auto-preview playlist after pasting + setPlaylistPreviewError(null) + setPlaylistPreviewLoading(true) + try { + const info = await ipcServices.download.getPlaylistInfo(trimmed) + 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) + } + } + }, 0) + }, + [playlistUrl, playlistBusy, t] + ) + + const handleOneClickDownload = useCallback(async () => { + await startOneClickDownload(url, { clearInput: true }) + setOpen(false) // Close dialog after download starts + }, [startOneClickDownload, url]) + + // 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('') + + // Auto-preview playlist after pasting + if (trimmed) { + setPlaylistPreviewError(null) + setPlaylistPreviewLoading(true) + try { + const info = await ipcServices.download.getPlaylistInfo(trimmed) + 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) + } + } + } 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 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 })) + setOpen(false) // Close dialog after download starts + } 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 + ]) + + // Update videoInfoCardState when videoInfo changes + useEffect(() => { + if (videoInfo) { + setVideoInfoCardState((prev) => ({ + ...prev, + title: videoInfo.title || prev.title + })) + } + }, [videoInfo]) + + // Handle video download from VideoInfoCard + const handleVideoDownload = useCallback( + async (type: 'video' | 'audio' | 'extract') => { + if (!videoInfo) return + + const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}` + + const downloadItem = { + id, + url: videoInfo.webpage_url || '', + title: videoInfoCardState.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' + ? videoInfoCardState.selectedVideoFormat + : type === 'extract' + ? undefined + : videoInfoCardState.selectedAudioFormat, + audioFormat: type === 'video' ? videoInfoCardState.selectedAudioForVideo : undefined, + extractFormat: + type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined, + extractQuality: + type === 'extract' ? videoInfoCardState.audioExtractor.extractQuality : undefined, + startTime: videoInfoCardState.startTime || undefined, + endTime: videoInfoCardState.endTime || undefined, + downloadSubs: videoInfoCardState.downloadSubs, + customDownloadPath: videoInfoCardState.customDownloadPath.trim() || undefined + } + + addDownload(downloadItem) + + try { + await ipcServices.download.startDownload(id, options) + + await ipcServices.download.updateDownloadInfo(id, { + title: videoInfoCardState.title, + thumbnail: videoInfo.thumbnail, + duration: videoInfo.duration, + description: videoInfo.description, + channel: videoInfo.extractor_key, + uploader: videoInfo.extractor_key, + createdAt: Date.now() + }) + + toast.success(t('notifications.downloadStarted')) + setOpen(false) // Close dialog after download starts + } catch (error) { + console.error('Failed to start download:', error) + toast.error(t('notifications.downloadFailed')) + } + }, + [videoInfo, videoInfoCardState, addDownload, t] + ) + + // Reset form when dialog closes + useEffect(() => { + if (!open) { + // Reset single video states + setUrl('') + setActiveTab('single') + setSingleVideoAdvancedOptionsOpen(false) + setVideoInfoCardState({ + title: '', + activeTab: 'video', + selectedVideoFormat: '', + selectedAudioForVideo: '', + selectedAudioFormat: '', + startTime: '', + endTime: '', + downloadSubs: false, + customDownloadPath: '', + audioExtractor: { + extractFormat: 'mp3', + extractQuality: '5' + } + }) + + // Reset playlist states + setPlaylistUrl('') + setPlaylistInfo(null) + setPlaylistPreviewError(null) + setPlaylistCustomDownloadPath('') + setStartIndex('1') + setEndIndex('') + } + }, [open]) + + return ( + + + + + + setActiveTab(value as 'single' | 'playlist')} + className="w-full flex flex-col flex-1 min-h-0" + > + + + setActiveTab('single')}> + + setActiveTab('playlist')}> + + {t('download.metadata.playlist')} + + + + + {/* Single Video Download Tab */} + +
+
+
+ setUrl(e.target.value)} + onKeyDown={handleKeyDown} + onPaste={handlePaste} + disabled={loading} + className="h-10" + /> + {loading && ( +
+ +
+ )} +
+ +
+ + {/* Sub-info section */} +
+
+ {t('sites.homeInlineDescription', { sites: inlinePreviewSites })} + +
+ + {settings.oneClickDownload && ( +
+
+ {t('download.oneClickDownloadEnabled')} + {onOpenSettings && ( + + )} +
+ )} +
+ + {/* Error Display */} + {error && ( +
+
+ +
+

+ {t('errors.fetchInfoFailed')} +

+

{error}

+
+
+
+ )} + + {/* Video Info and Download Options */} + {videoInfo && !loading && ( + <> + + setVideoInfoCardState((prev) => ({ ...prev, ...updates })) + } + onTabChange={(tab) => { + setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab })) + }} + /> + + {/* Download Location - Single Video */} +
+ +
+
+ +
+ +
+
+
+ + {videoInfoCardState.customDownloadPath && ( + + )} +
+
+
+ + )} + + {/* Advanced Options Content - Single Video */} + {videoInfo && !loading && singleVideoAdvancedOptionsOpen && ( +
+ + setVideoInfoCardState((prev) => ({ ...prev, startTime: value })) + } + onEndTimeChange={(value) => + setVideoInfoCardState((prev) => ({ ...prev, endTime: value })) + } + onDownloadSubsChange={(value) => + setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value })) + } + showAccordion={false} + /> +
+ )} +
+ + + {/* Playlist Download Tab */} + +
+
+
+ { + setPlaylistUrl(e.target.value) + setPlaylistInfo(null) + setPlaylistPreviewError(null) + setPlaylistCustomDownloadPath('') + }} + onPaste={handlePlaylistPaste} + disabled={playlistBusy} + /> + {playlistPreviewLoading && ( +
+ +
+ )} +
+ +
+ + {/* Preview State */} + {playlistInfo && !playlistPreviewLoading && ( +
+
+

{playlistInfo.title}

+
+ + {t('playlist.foundVideos', { count: playlistInfo.entryCount })} + {selectedPlaylistEntries.length !== playlistInfo.entryCount && ( + <> + + + {t('playlist.selectedVideos', { + count: selectedPlaylistEntries.length + })} + + + )} +
+
+ + +
+ {selectedPlaylistEntries.map((entry) => ( +
+
+ #{entry.index} +
+
+

+ {entry.title || t('download.fetchingVideoInfo')} +

+
+
+ ))} +
+
+ + {/* Download Location - Playlist */} +
+ +
+
+ +
+ +
+
+
+ + {playlistCustomDownloadPath && ( + + )} +
+
+
+
+ )} + + {playlistPreviewError && ( +
+
+ +
+

+ {t('playlist.previewFailed')} +

+

{playlistPreviewError}

+
+
+
+ )} + + {/* Advanced Options Content - Playlist */} + {advancedOptionsOpen && ( +
+
+
+
+ + +
+ +
+ +
+ setStartIndex(e.target.value)} + className="text-center" + disabled={playlistBusy} + /> + - + setEndIndex(e.target.value)} + className="text-center" + disabled={playlistBusy} + /> +
+
+
+
+
+ )} +
+
+ + + +
+ {(activeTab === 'playlist' || (activeTab === 'single' && videoInfo && !loading)) && ( +
+ { + if (activeTab === 'playlist') { + setAdvancedOptionsOpen(checked === true) + } else { + setSingleVideoAdvancedOptionsOpen(checked === true) + } + }} + /> + +
+ )} +
+ {activeTab === 'single' ? ( + !videoInfo ? ( + settings.oneClickDownload ? ( + + ) : ( + + ) + ) : videoInfoCardState.activeTab === 'video' ? ( + + ) : ( + <> + + + + ) + ) : playlistInfo && !playlistPreviewLoading ? ( + + ) : ( + + )} +
+
+
+ +
+ ) +} diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index 5e8f8ac..691e238 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -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 (
{isSelectedHistory && ( {/* Content */} -
-
-
-
-

+

+
+
+
+

{download.title}

{isSubscriptionDownload && ( @@ -716,103 +714,64 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D )}
-
- {(statusIcon || statusText) && ( -
- {statusIcon} - {statusText} -
- )} - {timestamp ? ( - - {formatDateShort(timestamp)} - - ) : null} -
-
- {/* Source link */} - {(sourceDisplay || download.url) && - (download.url ? ( - - - - {sourceDisplay || download.url} - - - -

{download.url}

-
-
- ) : ( - {sourceDisplay} - ))} - - {/* Playlist info */} - {download.playlistId && ( - <> - - {t('playlist.badgeLabel')} - - - {download.playlistTitle || t('playlist.untitled')} - {download.playlistIndex !== undefined && - download.playlistSize !== undefined && - ` (${download.playlistIndex}/${download.playlistSize})`} - - - )} - - {/* Quality badge */} - {qualityLabel && ( - - {qualityLabel} - - )} - - {/* File size */} - {inlineFileSize && {inlineFileSize}} - - {/* Details toggle */} - {hasMetadataDetails && ( +
+ {/* Status */} + {statusIcon && ( - +
{statusIcon}
-

{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}

+

{statusText}

)} + {/* Timestamp */} + {timestamp && ( + {formatDateShort(timestamp)} + )} + {/* Quality */} + {qualityLabel && ( + <> + {(statusIcon || timestamp) && ( + + )} + {qualityLabel} + + )} + {/* File size */} + {inlineFileSize && ( + <> + {(statusIcon || timestamp || qualityLabel) && ( + + )} + {inlineFileSize} + + )}
- {detailsOpen && hasMetadataDetails && ( -
- {metadataDetails.map((item, index) => ( -
- {item.label} - {item.value} -
- ))} -
- )}
-
+
+ {/* Info button - show details in sheet */} + {hasMetadataDetails && ( + + + + + +

{t('download.showDetails')}

+
+
+ )} {isHistory ? ( <> {download.status === 'completed' && ( @@ -822,8 +781,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D @@ -855,8 +820,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D @@ -875,8 +843,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D @@ -909,8 +883,11 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D @@ -922,13 +899,13 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D {/* Progress */} {download.progress && download.status !== 'completed' && download.status !== 'error' && ( -
- -
+
+ +
{download.progress.percent.toFixed(1)}% -
+
{download.progress.downloaded && download.progress.total && ( {download.progress.downloaded} / {download.progress.total} @@ -953,6 +930,32 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D )}
+ + {/* Video Details Sheet */} + {hasMetadataDetails && ( + + +
+ + {download.title} + {t('download.videoInfo')} + +
+
+ {metadataDetails.map((item, index) => ( +
+ + {item.label} + +
{item.value}
+
+ ))} +
+
+
+
+
+ )}
) } diff --git a/src/renderer/src/components/download/PlaylistDownloadGroup.tsx b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx index 8a379cb..4fd3d44 100644 --- a/src/renderer/src/components/download/PlaylistDownloadGroup.tsx +++ b/src/renderer/src/components/download/PlaylistDownloadGroup.tsx @@ -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 ( -
-
-
-

{displayTitle}

- {isExpanded ? ( -

- {t('playlist.groupSummary', { completed: completedCount, total: totalCount })} -

- ) : ( -

- {t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })} -

- )} +
+
+
+ +
+

{displayTitle}

+
+ + {t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })} + + {activeCount > 0 && ( + <> + + {t('playlist.groupActive', { count: activeCount })} + + )} + {errorCount > 0 && ( + <> + + + {t('playlist.groupErrors', { count: errorCount })} + + + )} +
+
-
- {activeCount > 0 && {t('playlist.groupActive', { count: activeCount })}} - {errorCount > 0 && ( - - {t('playlist.groupErrors', { count: errorCount })} - - )} +
{canDeletePlaylist && ( )} -
{!isExpanded && totalCount > 0 && ( -
- -
+ )} - {isExpanded && ( -
- {records.map((record) => ( -
- -
- ))} +
+
+
+ {records.map((record) => ( +
+ +
+ ))} +
- )} +
) } diff --git a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx index 6e2313b..629a5e8 100644 --- a/src/renderer/src/components/download/UnifiedDownloadHistory.tsx +++ b/src/renderer/src/components/download/UnifiedDownloadHistory.tsx @@ -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 (
0 && 'pb-20')}> - +
{filters.map((filter) => { @@ -439,25 +420,22 @@ export function UnifiedDownloadHistory() { ) })}
- +
+ +
- + {filteredRecords.length === 0 ? ( -
+

{t('download.noItems')}

) : ( -
+
{groupedView.order.map((item) => { if (item.type === 'single') { return ( diff --git a/src/renderer/src/components/ui/checkbox.tsx b/src/renderer/src/components/ui/checkbox.tsx index 1ca6867..7595a49 100644 --- a/src/renderer/src/components/ui/checkbox.tsx +++ b/src/renderer/src/components/ui/checkbox.tsx @@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps ) } - export { Checkbox } diff --git a/src/renderer/src/components/ui/sheet.tsx b/src/renderer/src/components/ui/sheet.tsx new file mode 100644 index 0000000..170c929 --- /dev/null +++ b/src/renderer/src/components/ui/sheet.tsx @@ -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) { + return +} + +function SheetTrigger({ ...props }: React.ComponentProps) { + return +} + +function SheetClose({ ...props }: React.ComponentProps) { + return +} + +function SheetPortal({ ...props }: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = 'right', + ...props +}: React.ComponentProps & { + side?: 'top' | 'right' | 'bottom' | 'left' +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription +} diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index b202e9e..4353c1a 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -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' : ''}`} > @@ -167,7 +167,7 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid - @@ -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' : ''}`} > diff --git a/src/renderer/src/components/video/AdvancedOptions.tsx b/src/renderer/src/components/video/AdvancedOptions.tsx index ea77b9c..770fcf3 100644 --- a/src/renderer/src/components/video/AdvancedOptions.tsx +++ b/src/renderer/src/components/video/AdvancedOptions.tsx @@ -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 = ( +
+ {/* Time Range */} +
+ +
+
+ onStartTimeChange(e.target.value)} + className="h-9 text-center" + title={t('advancedOptions.startHint')} + /> +
+ - +
+ onEndTimeChange(e.target.value)} + className="h-9 text-center" + title={t('advancedOptions.endHint')} + /> +
+
+
- 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 */} +
+
+ +

+ {t('advancedOptions.downloadSubsHint')} +

+
+ +
+
+ ) + + if (!showAccordion) { + return content } return ( - - - {t('advancedOptions.title')} - - {/* Time Range */} -
- -
-
- onStartTimeChange(e.target.value)} - title={t('advancedOptions.startHint')} - /> -
- - -
- onEndTimeChange(e.target.value)} - title={t('advancedOptions.endHint')} - /> -
-
-

{t('advancedOptions.startHint')}

-
- - {/* Subtitles */} -
- - -
- - {/* Download Location */} -
- -
- - -
-
- -
-
- - {customDownloadPath.trim() && ( - - )} -
-
- - -
-

{t('download.autoFolderHint')}

-
-
+ + + + {t('advancedOptions.title')} + + {content} ) diff --git a/src/renderer/src/components/video/AudioExtractor.tsx b/src/renderer/src/components/video/AudioExtractor.tsx index 6bcd48f..4756bc5 100644 --- a/src/renderer/src/components/video/AudioExtractor.tsx +++ b/src/renderer/src/components/video/AudioExtractor.tsx @@ -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) => 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) {
- onStateChange({ extractFormat: value })} + > @@ -65,7 +75,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
- onStateChange({ extractQuality: value })} + > @@ -79,10 +92,6 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
- - ) diff --git a/src/renderer/src/components/video/VideoInfoCard.tsx b/src/renderer/src/components/video/VideoInfoCard.tsx index 9b8f9a5..8d2fe80 100644 --- a/src/renderer/src/components/video/VideoInfoCard.tsx +++ b/src/renderer/src/components/video/VideoInfoCard.tsx @@ -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) => 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 ( -
- - - - -
+
+ + +
{/* Thumbnail */}
- } - /> +
+ } + /> +
{/* Video Metadata */} -
-
- {t('download.videoInfo')} - - {videoInfo.duration && ( - - - {formatDuration(videoInfo.duration)} - - )} - {videoInfo.view_count && ( - - - {formatViews(videoInfo.view_count)} - - )} - {videoInfo.uploader && ( - - {videoInfo.uploader} - - )} - +
+
+ + {videoInfo.extractor_key || t('download.videoInfo')} + + {videoInfo.duration && ( + + + {formatDuration(videoInfo.duration)} + + )} + {videoInfo.view_count && ( + + + {formatViews(videoInfo.view_count)} + + )}
-
- - setTitle(e.target.value)} - className="font-medium h-10" - /> +
+

{title}

+ {videoInfo.uploader && ( +

{videoInfo.uploader}

+ )}
@@ -182,76 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) { - - setActiveTab(v as 'video' | 'audio')}> - - + + { + onTabChange(v as 'video' | 'audio') + onStateChange({ activeTab: v as 'video' | 'audio' }) + }} + className="w-full" + > + + {t('download.video')} - + {t('download.audio')} - + onStateChange({ selectedVideoFormat: format })} + onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })} /> - - - - - + onStateChange({ selectedAudioFormat: format })} /> - - - + onStateChange({ + audioExtractor: { ...state.audioExtractor, ...updates } + }) + } /> - - diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 7f7d8e3..fd748e0 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -157,6 +157,7 @@ "selectAudioFormat": "اختر تنسيق الصوت", "selectFormat": "اختر التنسيق", "selectVideoFormat": "اختر تنسيق الفيديو", + "startDownload": "بدء التحميل", "singleVideo": "فيديو واحد", "speed": "السرعة", "title": "العنوان", diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index cd3596c..9d48960 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -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", diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index e495f54..94897e9 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -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", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 5b52774..f56d7f2 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -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", diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index 81fc0b0..b01f669 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -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", diff --git a/src/renderer/src/locales/id.json b/src/renderer/src/locales/id.json index 6d1861f..71fdcb8 100644 --- a/src/renderer/src/locales/id.json +++ b/src/renderer/src/locales/id.json @@ -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", diff --git a/src/renderer/src/locales/it.json b/src/renderer/src/locales/it.json index 1026fa8..b38743f 100644 --- a/src/renderer/src/locales/it.json +++ b/src/renderer/src/locales/it.json @@ -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à", diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index 3302e9b..78af15a 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -182,6 +182,7 @@ "selectAudioFormat": "オーディオフォーマットを選択", "selectFormat": "フォーマットを選択", "selectVideoFormat": "ビデオフォーマットを選択", + "startDownload": "ダウンロードを開始", "showDetails": "詳細を表示", "singleVideo": "単一ビデオ", "speed": "速度", diff --git a/src/renderer/src/locales/ko.json b/src/renderer/src/locales/ko.json index a8416d7..8171480 100644 --- a/src/renderer/src/locales/ko.json +++ b/src/renderer/src/locales/ko.json @@ -182,6 +182,7 @@ "selectAudioFormat": "오디오 형식 선택", "selectFormat": "형식 선택", "selectVideoFormat": "비디오 형식 선택", + "startDownload": "다운로드 시작", "showDetails": "세부정보 표시", "singleVideo": "단일 비디오", "speed": "속도", diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index 2d75f84..7fea80b 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -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", diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 1ab9e53..0040851 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -157,6 +157,7 @@ "selectAudioFormat": "Выбрать формат аудио", "selectFormat": "Выбрать формат", "selectVideoFormat": "Выбрать формат видео", + "startDownload": "Начать загрузку", "singleVideo": "Одно видео", "speed": "Скорость", "title": "Название", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 1d97a11..e168259 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -180,8 +180,10 @@ "processing": "處理中", "progress": "進度", "selectAudioFormat": "選擇音訊格式", + "selectDownloadType": "選擇下載類型", "selectFormat": "選擇格式", "selectVideoFormat": "選擇影片格式", + "startDownload": "開始下載", "showDetails": "顯示詳情", "singleVideo": "單個影片", "speed": "速度", diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 85a10b0..86bf45d 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -180,8 +180,10 @@ "processing": "处理中", "progress": "进度", "selectAudioFormat": "选择音频格式", + "selectDownloadType": "选择下载类型", "selectFormat": "选择格式", "selectVideoFormat": "选择视频格式", + "startDownload": "开始下载", "showDetails": "显示详情", "singleVideo": "单个视频", "speed": "速度", diff --git a/src/renderer/src/pages/Home.tsx b/src/renderer/src/pages/Home.tsx index 0f652c7..91a0f62 100644 --- a/src/renderer/src/pages/Home.tsx +++ b/src/renderer/src/pages/Home.tsx @@ -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 = { - best: null, - good: 1080, - normal: 720, - bad: 480, - worst: 360 -} - -const qualityPresetToAudioAbr: Record = { - best: 320, - good: 256, - normal: 192, - bad: 128, - worst: 96 -} - -const dedupe = (candidates: Array): string[] => { - const seen = new Set() - 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(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(null) - const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false) - const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false) - const [playlistPreviewError, setPlaylistPreviewError] = useState(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) => { - 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 ( -
- - setActiveTab(value as 'single' | 'playlist')} - className="w-full gap-0" - > - -
-
- - {activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')} - - - {activeTab === 'single' ? ( -
- {t('sites.homeInlineDescription', { sites: inlinePreviewSites })} - -
- ) : ( - t('playlist.playlistUrlDescription') - )} -
-
- - - - - - {t('download.singleVideo')} - - - {t('download.singleVideo')} - - - - - - {t('playlist.title')} - - - {t('playlist.title')} - - -
-
- - - {/* Single Video Download Tab */} - - {/* URL Input Card */} - {!videoInfo && ( -
-
-
- setUrl(e.target.value)} - onKeyDown={handleKeyDown} - className="pr-10" - disabled={loading} - /> - {loading && ( -
- -
- )} -
- - {settings.oneClickDownload ? ( - - ) : ( - - )} -
- - {/* One-Click Download Info */} - {settings.oneClickDownload && ( -
-
- {t('download.oneClickDownloadEnabled')} -
- {onOpenSettings && ( - - )} -
- )} - - {/* Error Display */} - {error && ( -
-
- -
-

- {t('errors.fetchInfoFailed')} -

-

{error}

-
-
-
- )} -
- )} - - {/* Video Info and Download Options */} - {videoInfo && !loading && } -
- - {/* Playlist Download Tab */} - -
-
- -
- { - setPlaylistUrl(e.target.value) - setPlaylistInfo(null) - setPlaylistPreviewError(null) - setPlaylistCustomDownloadPath('') - }} - className="flex-1" - disabled={playlistBusy} - /> - -
-
- -
-
- - -
- -
- -
- setStartIndex(e.target.value)} - min="1" - disabled={playlistBusy} - /> - setEndIndex(e.target.value)} - min="1" - disabled={playlistBusy} - /> -
-
-
- -
-
- - {playlistCustomDownloadPath.trim() && ( - - )} -
-
- - -
-

{t('download.autoFolderHint')}

-
- -
- - {playlistInfo && ( - - )} -
- - {playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && ( -

{t('playlist.previewRequired')}

- )} - - {playlistPreviewError && ( -
- {playlistPreviewError} -
- )} -
-
-
-
-
- - {/* Playlist Preview Card (outside main card) */} - {playlistInfo && ( - +
+ {/* Unified Download History */} + - )} - - {/* Unified Download History */} - +
) } diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx index 6b8d7a1..82aa11a 100644 --- a/src/renderer/src/pages/Subscriptions.tsx +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -134,7 +134,7 @@ function SubscriptionTab({ @@ -338,7 +338,7 @@ export function Subscriptions() { ))} {/* Add RSS Button */}