chore: optimize ui and save structure (#19)
* feat: persist and use saved file name for downloads * feat(settings): ensure and migrate download directory to VidBee/Downloads * feat: format download dates with locale date and time for clarity * feat(files): try multiple candidate file paths for file ops and DRY logic * fix(download): handle multi-path file deletion and warn on failure
This commit is contained in:
@@ -80,7 +80,7 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s via VidBee.%(ext)s')
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
|
||||
@@ -742,13 +742,16 @@ class DownloadEngine extends EventEmitter {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
|
||||
const savedFileName = path.basename(actualFilePath)
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined
|
||||
codec: actualCodec || undefined,
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
this.emit('download-completed', id)
|
||||
@@ -882,6 +885,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||
@@ -936,6 +942,7 @@ class DownloadEngine extends EventEmitter {
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { AppSettings } from '../shared/types'
|
||||
@@ -8,6 +9,24 @@ const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
||||
|
||||
const ensureDirectoryExists = (dir: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
|
||||
class SettingsManager {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
|
||||
private store: any
|
||||
@@ -16,9 +35,10 @@ class SettingsManager {
|
||||
this.store = new Store({
|
||||
defaults: {
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
}
|
||||
})
|
||||
this.ensureDownloadDirectory()
|
||||
}
|
||||
|
||||
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||
@@ -26,6 +46,9 @@ class SettingsManager {
|
||||
}
|
||||
|
||||
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key, value)
|
||||
}
|
||||
|
||||
@@ -35,6 +58,9 @@ class SettingsManager {
|
||||
|
||||
setAll(settings: Partial<AppSettings>): void {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key === 'downloadPath' && typeof value === 'string') {
|
||||
ensureDirectoryExists(value)
|
||||
}
|
||||
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
|
||||
}
|
||||
}
|
||||
@@ -43,8 +69,22 @@ class SettingsManager {
|
||||
this.store.clear()
|
||||
this.store.set({
|
||||
...defaultSettings,
|
||||
downloadPath: path.join(os.homedir(), 'Downloads')
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
try {
|
||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||
if (!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH) {
|
||||
this.store.set('downloadPath', DEFAULT_DOWNLOAD_PATH)
|
||||
return
|
||||
}
|
||||
ensureDirectoryExists(currentPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to verify download directory:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,19 @@ import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeho
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
@@ -17,19 +28,43 @@ import {
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
// Helper function to generate file path with proper path separators
|
||||
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
|
||||
const fileName = `${title}.${format}`
|
||||
// Use proper path joining for cross-platform compatibility
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
const generateFilePathCandidates = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const candidateFileNames = savedFileName
|
||||
? [savedFileName]
|
||||
: [`${title} via VidBee.${format}`, `${title}.${format}`]
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
return `${normalizedDownloadPath}/${fileName}`
|
||||
return Array.from(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
}
|
||||
|
||||
const tryFileOperation = async (
|
||||
paths: string[],
|
||||
operation: (filePath: string) => Promise<boolean>
|
||||
): Promise<boolean> => {
|
||||
for (const filePath of paths) {
|
||||
const success = await operation(filePath)
|
||||
if (success) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
label: string
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
@@ -53,6 +88,18 @@ const formatDate = (timestamp?: number) => {
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
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',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
@@ -70,19 +117,32 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
const checkFileExists = async () => {
|
||||
if (!download.title || !download.downloadPath || !download.format) {
|
||||
if (!download.title || !download.downloadPath) {
|
||||
setFileExists(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = generateFilePath(download.downloadPath, download.title, download.format)
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
setFileExists(exists)
|
||||
const formatForPath = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePaths = generateFilePathCandidates(
|
||||
download.downloadPath,
|
||||
download.title,
|
||||
formatForPath,
|
||||
download.savedFileName
|
||||
)
|
||||
for (const filePath of filePaths) {
|
||||
const exists = await ipcServices.fs.fileExists(filePath)
|
||||
if (exists) {
|
||||
setFileExists(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
setFileExists(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to check file existence:', error)
|
||||
setFileExists(false)
|
||||
@@ -90,7 +150,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [download.title, download.downloadPath, download.format])
|
||||
}, [
|
||||
download.title,
|
||||
download.downloadPath,
|
||||
download.format,
|
||||
download.savedFileName,
|
||||
download.type
|
||||
])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -104,12 +170,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.openFileLocation(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.openFileLocation(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -120,7 +192,12 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return !!(download.title && download.downloadPath && download.format && fileExists)
|
||||
return !!(
|
||||
download.title &&
|
||||
download.downloadPath &&
|
||||
fileExists &&
|
||||
(download.savedFileName || download.format)
|
||||
)
|
||||
}
|
||||
|
||||
// need title, downloadPath, format
|
||||
@@ -142,9 +219,16 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const filePath = generateFilePath(downloadPath, title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
const success = await ipcServices.fs.copyFileToClipboard(filePath)
|
||||
const success = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.copyFileToClipboard(filePath)
|
||||
)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
@@ -160,16 +244,25 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
format,
|
||||
download.savedFileName
|
||||
)
|
||||
|
||||
// Remove from history first
|
||||
await ipcServices.history.removeHistoryItem(download.id)
|
||||
|
||||
// Then try to delete the file
|
||||
await ipcServices.fs.deleteFile(filePath)
|
||||
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||
ipcServices.fs.deleteFile(filePath)
|
||||
)
|
||||
if (!deleted) {
|
||||
console.warn('Failed to delete download file for history item:', download.id)
|
||||
}
|
||||
|
||||
removeHistory(download.id)
|
||||
toast.success(t('notifications.itemRemoved'))
|
||||
@@ -218,6 +311,246 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const statusIcon = getStatusIcon()
|
||||
const statusText = getStatusText()
|
||||
const sourceDisplay =
|
||||
download.uploader && download.channel && download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader || download.channel || ''
|
||||
|
||||
const metadataDetails: MetadataDetail[] = []
|
||||
|
||||
if (timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('history.date'),
|
||||
value: formatDate(timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
if (sourceDisplay) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.source'),
|
||||
value: sourceDisplay
|
||||
})
|
||||
}
|
||||
|
||||
if (download.playlistId) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.playlist'),
|
||||
value: (
|
||||
<span>
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined && download.playlistSize !== undefined ? (
|
||||
<span className="text-muted-foreground/80">
|
||||
{` ${t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.duration) {
|
||||
metadataDetails.push({
|
||||
label: t('history.duration'),
|
||||
value: formatDuration(download.duration)
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFormatSize =
|
||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||
|
||||
const formatLabelValue = download.selectedFormat?.ext
|
||||
? download.selectedFormat.ext.toUpperCase()
|
||||
: download.format
|
||||
? download.format.toUpperCase()
|
||||
: undefined
|
||||
|
||||
if (formatLabelValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.format'),
|
||||
value: formatLabelValue
|
||||
})
|
||||
}
|
||||
|
||||
const qualityValue = download.selectedFormat?.height
|
||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
||||
: download.quality
|
||||
|
||||
if (qualityValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.quality'),
|
||||
value: qualityValue
|
||||
})
|
||||
}
|
||||
|
||||
if (inlineFileSize) {
|
||||
metadataDetails.push({
|
||||
label: t('history.fileSize'),
|
||||
value: inlineFileSize
|
||||
})
|
||||
}
|
||||
|
||||
if (download.codec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.codec'),
|
||||
value: download.codec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.savedFileName) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.savedFile'),
|
||||
value: download.savedFileName
|
||||
})
|
||||
}
|
||||
|
||||
if (download.url) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.url'),
|
||||
value: (
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="wrap-break-word text-primary hover:underline"
|
||||
>
|
||||
{download.url}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Additional metadata fields
|
||||
if (download.description) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.description'),
|
||||
value: <span className="wrap-break-word">{download.description}</span>
|
||||
})
|
||||
}
|
||||
|
||||
if (download.viewCount !== undefined && download.viewCount !== null) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.views'),
|
||||
value: download.viewCount.toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
if (download.tags && download.tags.length > 0) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.tags'),
|
||||
value: (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{download.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.downloadPath) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.downloadPath'),
|
||||
value: <span className="wrap-break-word font-mono text-xs">{download.downloadPath}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if (download.createdAt && download.createdAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.createdAt'),
|
||||
value: formatDate(download.createdAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.startedAt) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.startedAt'),
|
||||
value: formatDate(download.startedAt)
|
||||
})
|
||||
}
|
||||
|
||||
if (download.completedAt && download.completedAt !== timestamp) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.completedAt'),
|
||||
value: formatDate(download.completedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// Speed
|
||||
if (download.speed) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.speed'),
|
||||
value: download.speed
|
||||
})
|
||||
}
|
||||
|
||||
// File size (if different from inlineFileSize)
|
||||
if (download.fileSize && download.fileSize !== selectedFormatSize) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fileSize'),
|
||||
value: formatFileSize(download.fileSize)
|
||||
})
|
||||
}
|
||||
|
||||
// Selected format details
|
||||
if (download.selectedFormat) {
|
||||
if (download.selectedFormat.width) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.width'),
|
||||
value: `${download.selectedFormat.width}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.height && !qualityValue) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.height'),
|
||||
value: `${download.selectedFormat.height}px`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.fps) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.fps'),
|
||||
value: `${download.selectedFormat.fps}`
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.vcodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.videoCodec'),
|
||||
value: download.selectedFormat.vcodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.acodec) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.audioCodec'),
|
||||
value: download.selectedFormat.acodec
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.format_note) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.formatNote'),
|
||||
value: download.selectedFormat.format_note
|
||||
})
|
||||
}
|
||||
|
||||
if (download.selectedFormat.protocol) {
|
||||
metadataDetails.push({
|
||||
label: t('download.metadata.protocol'),
|
||||
value: download.selectedFormat.protocol.toUpperCase()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasMetadataDetails = metadataDetails.length > 0
|
||||
|
||||
return (
|
||||
<div className="group relative w-full max-w-full overflow-hidden">
|
||||
@@ -242,109 +575,102 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
|
||||
{download.type}
|
||||
</Badge>
|
||||
{(statusIcon || statusText) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{statusIcon}
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{download.playlistId && (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
|
||||
>
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined && (
|
||||
<span className="ml-1 text-muted-foreground/80">
|
||||
{t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{timestamp ? (
|
||||
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
|
||||
<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="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>
|
||||
))}
|
||||
|
||||
<span className="truncate max-w-[120px] text-left">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{download.uploader &&
|
||||
download.channel &&
|
||||
download.uploader !== download.channel
|
||||
? `${download.uploader} • ${download.channel}`
|
||||
: download.uploader
|
||||
? `${download.uploader}`
|
||||
: download.channel
|
||||
? `${download.channel}`
|
||||
: ''}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs wrap-break-word">
|
||||
<p>{download.url}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
|
||||
{download.selectedFormat ? (
|
||||
{/* Playlist info */}
|
||||
{download.playlistId && (
|
||||
<>
|
||||
{download.selectedFormat.height ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.height}p
|
||||
{download.selectedFormat.fps === 60 ? '60' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.ext ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.selectedFormat.ext.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
|
||||
<span className="text-[10px] opacity-75">
|
||||
{formatFileSize(
|
||||
download.selectedFormat.filesize ||
|
||||
download.selectedFormat.filesize_approx
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.quality ? (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.quality}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.format ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
|
||||
{download.format.toUpperCase()}
|
||||
</Badge>
|
||||
) : null}
|
||||
{download.codec ? (
|
||||
<span className="text-[10px] opacity-75">{download.codec}</span>
|
||||
) : null}
|
||||
<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 */}
|
||||
{(download.selectedFormat?.height || download.quality) && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{download.selectedFormat?.height
|
||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
||||
: download.quality}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* File size */}
|
||||
{inlineFileSize && <span>{inlineFileSize}</span>}
|
||||
|
||||
{/* Details toggle */}
|
||||
{hasMetadataDetails && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={detailsOpen ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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}>
|
||||
{isHistory ? (
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
"preparing": "Preparing...",
|
||||
"processing": "Processing",
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectFormat": "Select Format",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
@@ -164,7 +166,32 @@
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Video Information",
|
||||
"videoInfoUpdated": "Video information updated"
|
||||
"videoInfoUpdated": "Video information updated",
|
||||
"metadata": {
|
||||
"source": "Source",
|
||||
"playlist": "Playlist",
|
||||
"format": "Format",
|
||||
"quality": "Quality",
|
||||
"codec": "Codec",
|
||||
"savedFile": "Saved file",
|
||||
"url": "Source URL",
|
||||
"description": "Description",
|
||||
"views": "Views",
|
||||
"tags": "Tags",
|
||||
"downloadPath": "Download path",
|
||||
"createdAt": "Created at",
|
||||
"startedAt": "Started at",
|
||||
"completedAt": "Completed at",
|
||||
"speed": "Speed",
|
||||
"fileSize": "File size",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"fps": "FPS",
|
||||
"videoCodec": "Video codec",
|
||||
"audioCodec": "Audio codec",
|
||||
"formatNote": "Format note",
|
||||
"protocol": "Protocol"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Click to copy details",
|
||||
|
||||
@@ -5,6 +5,7 @@ export type DownloadRecord = DownloadItem & {
|
||||
entryType: 'active' | 'history'
|
||||
downloadedAt?: number
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
}
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
@@ -43,6 +44,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
savedFileName: item.savedFileName,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface DownloadItem {
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
savedFileName?: string
|
||||
// Timestamps
|
||||
createdAt: number
|
||||
startedAt?: number
|
||||
@@ -92,6 +93,7 @@ export interface DownloadHistoryItem {
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
status: DownloadStatus
|
||||
downloadPath?: string
|
||||
savedFileName?: string
|
||||
fileSize?: number
|
||||
duration?: number
|
||||
downloadedAt: number
|
||||
|
||||
Reference in New Issue
Block a user