Compare commits

...

3 Commits

Author SHA1 Message Date
Nexmoe
11eaaf0f88 chore: release v0.3.0 2025-10-29 20:34:05 +08:00
Nexmoe
512b9d1175 feat: playlist (#2)
* feat: implement playlist download feature with UI components, enhance download service for playlist handling, and update translations for playlist-related terms

* refactor: replace console logs with log.info for auto-updater events and enhance zh-TW translations with new keys and updates
2025-10-29 20:32:27 +08:00
Nexmoe
a1307bdd0b feat: add hide-dock setting and fix about text handling (#3)
- Add "hideDockIcon" setting label and description to English locale,
  enabling users to remove VidBee from the macOS Dock and rely on the
  menu bar/tray icon.
- Apply dock visibility changes when settings change, when settings
  are loaded, and after settings are reset by calling
  applyDockVisibility from the settings service.
- Update About page to use about.description for share text and display
  content, replacing the removed tagline key usage.
- Remove unused "tagline" entries from multiple locale files (en, fr,
  it, pt, ja, zh) to keep translations in sync with UI text changes.

These changes add OS integration for hiding the dock icon and ensure UI
copy is consistent and correctly internationalized.
2025-10-29 20:32:03 +08:00
27 changed files with 778 additions and 136 deletions

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>

BIN
icon.icns

Binary file not shown.

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,6 +1,6 @@
{ {
"name": "vidbee", "name": "vidbee",
"version": "0.2.2", "version": "0.3.0",
"description": "A modern Electron application for downloading videos and audios", "description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "VidBee", "author": "VidBee",

View File

@@ -10,6 +10,7 @@ import { downloadEngine } from './lib/download-engine'
import { ytdlpManager } from './lib/ytdlp-manager' import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings' import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray' import { createTray, destroyTray } from './tray'
import { applyDockVisibility } from './utils/dock'
// Initialize electron-log for main process // Initialize electron-log for main process
log.initialize() log.initialize()
@@ -27,6 +28,7 @@ export function createWindow(): void {
height: 800, height: 800,
show: false, show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS titleBarStyle: 'hidden', // Hide title bar on macOS
trafficLightPosition: { x: 12.5, y: 10 },
autoHideMenuBar: true, autoHideMenuBar: true,
icon: appIcon, // Set application icon icon: appIcon, // Set application icon
frame: false, frame: false,
@@ -94,12 +96,12 @@ function setupDownloadEvents(): void {
function initAutoUpdater(): void { function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
console.log('Skipping auto-updater initialization in development mode') log.info('Skipping auto-updater initialization in development mode')
return return
} }
try { try {
console.log('Initializing auto-updater...') log.info('Initializing auto-updater...')
log.transports.file.level = 'info' log.transports.file.level = 'info'
autoUpdater.logger = log autoUpdater.logger = log
@@ -108,31 +110,26 @@ function initAutoUpdater(): void {
autoUpdater.on('update-available', (info) => { autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version) log.info('Update available:', info.version)
console.log('Update available:', info.version)
mainWindow?.webContents.send('update:available', info) mainWindow?.webContents.send('update:available', info)
}) })
autoUpdater.on('update-not-available', (info) => { autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version) log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
mainWindow?.webContents.send('update:not-available', info) mainWindow?.webContents.send('update:not-available', info)
}) })
autoUpdater.on('error', (err) => { autoUpdater.on('error', (err) => {
log.error('Update error:', err) log.error('Update error:', err)
console.error('Update error:', err)
mainWindow?.webContents.send('update:error', err.message) mainWindow?.webContents.send('update:error', err.message)
}) })
autoUpdater.on('download-progress', (progressObj) => { autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent) log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
mainWindow?.webContents.send('update:download-progress', progressObj) mainWindow?.webContents.send('update:download-progress', progressObj)
}) })
autoUpdater.on('update-downloaded', (info) => { autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version) log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info) mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) { if (mainWindow) {
@@ -146,15 +143,12 @@ function initAutoUpdater(): void {
if (settingsManager.get('autoUpdate')) { if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...') log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify() void autoUpdater.checkForUpdatesAndNotify()
} }
log.info('Auto-updater initialized successfully') log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
} catch (error) { } catch (error) {
log.error('Failed to initialize auto-updater:', error) log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
} }
} }
@@ -183,6 +177,8 @@ app.whenReady().then(async () => {
log.error('Failed to initialize yt-dlp:', error) log.error('Failed to initialize yt-dlp:', error)
} }
applyDockVisibility(settingsManager.get('hideDockIcon'))
createWindow() createWindow()
initAutoUpdater() initAutoUpdater()

View File

@@ -3,6 +3,7 @@ import type {
DownloadItem, DownloadItem,
DownloadOptions, DownloadOptions,
PlaylistDownloadOptions, PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo, PlaylistInfo,
VideoInfo VideoInfo
} from '../../../shared/types' } from '../../../shared/types'
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
async startPlaylistDownload( async startPlaylistDownload(
_context: IpcContext, _context: IpcContext,
options: PlaylistDownloadOptions options: PlaylistDownloadOptions
): Promise<string[]> { ): Promise<PlaylistDownloadResult> {
return downloadEngine.startPlaylistDownload(options) return downloadEngine.startPlaylistDownload(options)
} }
} }

View File

@@ -2,6 +2,7 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types' import type { AppSettings } from '../../../shared/types'
import { settingsManager } from '../../settings' import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray' import { updateTrayMenu } from '../../tray'
import { applyDockVisibility } from '../../utils/dock'
class SettingsService extends IpcService { class SettingsService extends IpcService {
static readonly groupName = 'settings' static readonly groupName = 'settings'
@@ -18,6 +19,10 @@ class SettingsService extends IpcService {
if (key === 'language') { if (key === 'language') {
updateTrayMenu() updateTrayMenu()
} }
if (key === 'hideDockIcon') {
applyDockVisibility(value as AppSettings['hideDockIcon'])
}
} }
@IpcMethod() @IpcMethod()
@@ -32,11 +37,16 @@ class SettingsService extends IpcService {
if (settings.language) { if (settings.language) {
updateTrayMenu() updateTrayMenu()
} }
if (typeof settings.hideDockIcon === 'boolean') {
applyDockVisibility(settings.hideDockIcon)
}
} }
@IpcMethod() @IpcMethod()
reset(_context: IpcContext): void { reset(_context: IpcContext): void {
settingsManager.reset() settingsManager.reset()
applyDockVisibility(settingsManager.get('hideDockIcon'))
} }
} }

View File

@@ -7,6 +7,7 @@ import type {
DownloadOptions, DownloadOptions,
DownloadProgress, DownloadProgress,
PlaylistDownloadOptions, PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo, PlaylistInfo,
VideoFormat, VideoFormat,
VideoInfo VideoInfo
@@ -120,7 +121,7 @@ class DownloadEngine extends EventEmitter {
const ytdlp = ytdlpManager.getInstance() const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll() const settings = settingsManager.getAll()
const args = ['-j', '--flat-playlist', '--no-warnings'] const args = ['-J', '--flat-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters // Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8') args.push('--encoding', 'utf-8')
@@ -140,8 +141,52 @@ class DownloadEngine extends EventEmitter {
args.push('--cookies', cookiesPath) args.push('--cookies', cookiesPath)
} }
// Add config file if configured
if (settings.configPath) {
args.push('--config-location', `"${settings.configPath}"`)
}
args.push(url) args.push(url)
type RawPlaylistEntry = {
id?: string
title?: string
url?: string
webpage_url?: string
original_url?: string
ie_key?: string
}
const resolveEntryUrl = (entry: RawPlaylistEntry): string => {
if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) {
return entry.url
}
if (entry.webpage_url && typeof entry.webpage_url === 'string') {
return entry.webpage_url
}
if (entry.original_url && typeof entry.original_url === 'string') {
return entry.original_url
}
if (entry.url && typeof entry.url === 'string') {
if (entry.ie_key && typeof entry.ie_key === 'string') {
const extractor = entry.ie_key.toLowerCase()
if (extractor.includes('youtube')) {
return `https://www.youtube.com/watch?v=${entry.url}`
}
if (extractor.includes('youtubemusic')) {
return `https://music.youtube.com/watch?v=${entry.url}`
}
}
if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) {
return entry.url
}
}
if (entry.id && typeof entry.id === 'string') {
return entry.id
}
return ''
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const process = ytdlp.exec(args) const process = ytdlp.exec(args)
let stdout = '' let stdout = ''
@@ -158,51 +203,107 @@ class DownloadEngine extends EventEmitter {
process.on('close', (code) => { process.on('close', (code) => {
if (code === 0 && stdout) { if (code === 0 && stdout) {
try { try {
const lines = stdout.trim().split('\n') const parsed = JSON.parse(stdout) as {
const entries = lines.map((line) => JSON.parse(line)) id?: string
const playlistEntry = entries[0] title?: string
entries?: RawPlaylistEntry[]
}
const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : []
const entries = rawEntries
.map((entry, index) => {
const resolvedUrl = resolveEntryUrl(entry)
return {
id: entry.id || `${index}`,
title: entry.title || `Entry ${index + 1}`,
url: resolvedUrl,
index: index + 1
}
})
.filter((entry) => entry.url)
scopedLoggers.download.info(
'Successfully retrieved playlist info for:',
url,
'entries:',
entries.length
)
resolve({ resolve({
id: playlistEntry.id || '', id: parsed.id || url,
title: playlistEntry.title || 'Playlist', title: parsed.title || 'Playlist',
entries: entries.map((entry) => ({ entries,
id: entry.id || '',
title: entry.title || 'Unknown',
url: entry.url || entry.webpage_url || ''
})),
entryCount: entries.length entryCount: entries.length
}) })
} catch (error) { } catch (error) {
scopedLoggers.download.error('Failed to parse playlist info for:', url, error)
reject(new Error(`Failed to parse playlist info: ${error}`)) reject(new Error(`Failed to parse playlist info: ${error}`))
} }
} else { } else {
scopedLoggers.download.error(
'Failed to fetch playlist info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
reject(new Error(stderr || 'Failed to fetch playlist info')) reject(new Error(stderr || 'Failed to fetch playlist info'))
} }
}) })
process.on('error', (error) => { process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)
reject(error) reject(error)
}) })
}) })
} }
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<string[]> { async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
const playlistInfo = await this.getPlaylistInfo(options.url) const playlistInfo = await this.getPlaylistInfo(options.url)
const downloadIds: string[] = [] const downloadEntries: PlaylistDownloadResult['entries'] = []
const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
// Calculate the range of entries to download // Calculate the range of entries to download
const startIndex = (options.startIndex || 1) - 1 // Convert to 0-based index const totalEntries = playlistInfo.entries.length
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1 if (totalEntries === 0) {
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1) scopedLoggers.download.warn('Playlist has no entries:', options.url)
return {
groupId,
playlistId: playlistInfo.id,
playlistTitle: playlistInfo.title,
type: options.type,
totalCount: 0,
startIndex: 0,
endIndex: 0,
entries: []
}
}
const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0)
const requestedEnd = options.endIndex
? Math.min(options.endIndex - 1, totalEntries - 1)
: totalEntries - 1
const rangeStart = Math.min(requestedStart, requestedEnd)
const rangeEnd = Math.max(requestedStart, requestedEnd)
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
const settings = settingsManager.getAll()
const selectedEntries = rawEntries.filter((entry) => {
if (!entry.url) {
scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry)
return false
}
return true
})
const selectionSize = selectedEntries.length
scopedLoggers.download.info( scopedLoggers.download.info(
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"` `Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
) )
// Create download items for each video in the playlist // Create download items for each video in the playlist
for (const entry of entriesToDownload) { for (const entry of selectedEntries) {
const downloadId = `playlist_${Date.now()}_${Math.random().toString(36).substring(7)}` const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
downloadIds.push(downloadId)
const downloadOptions: DownloadOptions = { const downloadOptions: DownloadOptions = {
url: entry.url, url: entry.url,
@@ -212,6 +313,13 @@ class DownloadEngine extends EventEmitter {
} }
const createdAt = Date.now() const createdAt = Date.now()
downloadEntries.push({
downloadId,
entryId: entry.id,
title: entry.title,
url: entry.url,
index: entry.index
})
// Add to queue // Add to queue
this.queue.add(downloadId, downloadOptions, { this.queue.add(downloadId, downloadOptions, {
@@ -221,17 +329,35 @@ class DownloadEngine extends EventEmitter {
type: options.type, type: options.type,
status: 'pending', status: 'pending',
progress: { percent: 0 }, progress: { percent: 0 },
createdAt createdAt,
playlistId: groupId,
playlistTitle: playlistInfo.title,
playlistIndex: entry.index,
playlistSize: selectionSize
}) })
this.upsertHistoryEntry(downloadId, downloadOptions, { this.upsertHistoryEntry(downloadId, downloadOptions, {
title: entry.title, title: entry.title,
status: 'pending', status: 'pending',
downloadedAt: createdAt downloadedAt: createdAt,
downloadPath: settings.downloadPath,
playlistId: groupId,
playlistTitle: playlistInfo.title,
playlistIndex: entry.index,
playlistSize: selectionSize
}) })
} }
return downloadIds return {
groupId,
playlistId: playlistInfo.id,
playlistTitle: playlistInfo.title,
type: options.type,
totalCount: selectionSize,
startIndex: selectedEntries[0]?.index ?? rangeStart + 1,
endIndex: selectedEntries[selectedEntries.length - 1]?.index ?? rangeEnd + 1,
entries: downloadEntries
}
} }
startDownload(id: string, options: DownloadOptions): void { startDownload(id: string, options: DownloadOptions): void {
@@ -607,6 +733,18 @@ class DownloadEngine extends EventEmitter {
if (updates.tags !== undefined) { if (updates.tags !== undefined) {
historyUpdates.tags = updates.tags historyUpdates.tags = updates.tags
} }
if (updates.playlistId !== undefined) {
historyUpdates.playlistId = updates.playlistId
}
if (updates.playlistTitle !== undefined) {
historyUpdates.playlistTitle = updates.playlistTitle
}
if (updates.playlistIndex !== undefined) {
historyUpdates.playlistIndex = updates.playlistIndex
}
if (updates.playlistSize !== undefined) {
historyUpdates.playlistSize = updates.playlistSize
}
if (updates.status !== undefined) { if (updates.status !== undefined) {
historyUpdates.status = updates.status historyUpdates.status = updates.status
} }
@@ -651,7 +789,11 @@ class DownloadEngine extends EventEmitter {
channel: completedDownload?.item.channel, channel: completedDownload?.item.channel,
uploader: completedDownload?.item.uploader, uploader: completedDownload?.item.uploader,
viewCount: completedDownload?.item.viewCount, viewCount: completedDownload?.item.viewCount,
tags: completedDownload?.item.tags tags: completedDownload?.item.tags,
playlistId: completedDownload?.item.playlistId,
playlistTitle: completedDownload?.item.playlistTitle,
playlistIndex: completedDownload?.item.playlistIndex,
playlistSize: completedDownload?.item.playlistSize
}) })
} }
@@ -683,7 +825,11 @@ class DownloadEngine extends EventEmitter {
viewCount: updates.viewCount, viewCount: updates.viewCount,
tags: updates.tags, tags: updates.tags,
// Download-specific format info // Download-specific format info
selectedFormat: updates.selectedFormat selectedFormat: updates.selectedFormat,
playlistId: updates.playlistId,
playlistTitle: updates.playlistTitle,
playlistIndex: updates.playlistIndex,
playlistSize: updates.playlistSize
} }
const merged: DownloadHistoryItem = { const merged: DownloadHistoryItem = {

16
src/main/utils/dock.ts Normal file
View File

@@ -0,0 +1,16 @@
import { app } from 'electron'
/**
* Apply Dock visibility preference on macOS.
*/
export function applyDockVisibility(hideDockIcon: boolean): void {
if (process.platform !== 'darwin' || !app.dock) {
return
}
if (hideDockIcon) {
app.dock.hide()
} else {
app.dock.show()
}
}

View File

@@ -214,6 +214,28 @@ export function DownloadItem({ download }: DownloadItemProps) {
</div> </div>
)} )}
</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"> <div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? ( {timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span> <span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>

View File

@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next'
import type { DownloadRecord } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
interface PlaylistDownloadGroupProps {
groupId: string
title: string
records: DownloadRecord[]
totalCount: number
}
export function PlaylistDownloadGroup({
groupId,
title,
records,
totalCount
}: PlaylistDownloadGroupProps) {
const { t } = useTranslation()
const completedCount = records.filter((record) => record.status === 'completed').length
const errorCount = records.filter((record) => record.status === 'error').length
const activeCount = records.filter((record) =>
['downloading', 'processing', 'pending'].includes(record.status)
).length
const displayTitle = title || t('playlist.untitled')
return (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
{errorCount > 0 && (
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
</div>
</div>
<div className="space-y-2">
{records.map((record) => (
<div
key={`${groupId}:${record.entryType}:${record.id}`}
className="border-l border-border/50 pl-3"
>
<DownloadItem download={record} />
</div>
))}
</div>
</div>
)
}

View File

@@ -6,8 +6,10 @@ import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync' import { useHistorySync } from '../../hooks/use-history-sync'
import type { DownloadRecord } from '../../store/downloads'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads' import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem' import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
type StatusFilter = 'all' | 'active' | 'completed' | 'error' type StatusFilter = 'all' | 'active' | 'completed' | 'error'
@@ -47,6 +49,56 @@ export function UnifiedDownloadHistory() {
{ key: 'error', label: t('download.error'), count: downloadStats.error } { key: 'error', label: t('download.error'), count: downloadStats.error }
] ]
const groupedView = useMemo(() => {
const groups = new Map<
string,
{ id: string; title: string; totalCount: number; records: DownloadRecord[] }
>()
const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =
[]
for (const record of filteredRecords) {
if (record.playlistId) {
let group = groups.get(record.playlistId)
if (!group) {
group = {
id: record.playlistId,
title: record.playlistTitle || record.title,
totalCount: record.playlistSize || 0,
records: []
}
groups.set(record.playlistId, group)
order.push({ type: 'group', id: record.playlistId })
}
group.records.push(record)
if (!group.title && record.playlistTitle) {
group.title = record.playlistTitle
}
if (!group.totalCount && record.playlistSize) {
group.totalCount = record.playlistSize
}
} else {
order.push({ type: 'single', record })
}
}
for (const group of groups.values()) {
group.records.sort((a, b) => {
const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER
const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER
if (aIndex !== bIndex) {
return aIndex - bIndex
}
return b.createdAt - a.createdAt
})
if (!group.totalCount) {
group.totalCount = group.records.length
}
}
return { order, groups }
}, [filteredRecords])
const hasCompletedActive = allRecords.some( const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed' (item) => item.entryType === 'active' && item.status === 'completed'
) )
@@ -110,10 +162,32 @@ export function UnifiedDownloadHistory() {
<p className="text-sm font-medium">{t('download.noItems')}</p> <p className="text-sm font-medium">{t('download.noItems')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full"> <div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => ( {groupedView.order.map((item) => {
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} /> if (item.type === 'single') {
))} return (
<DownloadItem
key={`${item.record.entryType}:${item.record.id}`}
download={item.record}
/>
)
}
const group = groupedView.groups.get(item.id)
if (!group) {
return null
}
return (
<PlaylistDownloadGroup
key={`group:${group.id}`}
groupId={group.id}
title={group.title}
totalCount={group.totalCount}
records={group.records}
/>
)
})}
</div> </div>
)} )}
</CardContent> </CardContent>

View File

@@ -0,0 +1,94 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import type { PlaylistEntry, PlaylistInfo } from '@shared/types'
import { useTranslation } from 'react-i18next'
interface PlaylistPreviewCardProps {
playlist: PlaylistInfo
entries: PlaylistEntry[]
onClear?: () => void
}
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
const { t } = useTranslation()
const totalCount = playlist.entryCount
const selectedCount = entries.length
const firstIndex = entries[0]?.index ?? null
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
return (
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1 space-y-1">
<CardTitle
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
title={playlist.title}
>
{playlist.title || t('playlist.untitled')}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
{firstIndex !== null && lastIndex !== null ? (
<span className="truncate">
{t('playlist.selectedRange', {
start: firstIndex,
end: lastIndex
})}
</span>
) : (
<span className="truncate">{t('playlist.noRangeSelected')}</span>
)}
<span className="truncate">
{t('playlist.showingCount', { count: selectedCount })}
</span>
</div>
</CardDescription>
</div>
{onClear && (
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
{t('playlist.clearPreview')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/20">
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
{entries.length === 0 ? (
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
{t('playlist.noEntriesInRange')}
</li>
) : (
entries.map((entry) => (
<li
key={`${entry.index}-${entry.id}`}
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
>
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
#{entry.index}
</span>
<span
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
title={entry.title}
>
{entry.title}
</span>
</li>
))
)}
</ol>
</ScrollArea>
</div>
</CardContent>
</Card>
)
}

View File

@@ -60,7 +60,6 @@
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.", "shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word", "shareTitle": "Spread the word",
"sourceCode": "Source Code is available", "sourceCode": "Source Code is available",
"tagline": "An AI-friendly download helper for every creator",
"title": "About", "title": "About",
"version": "Version", "version": "Version",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",
@@ -227,6 +226,8 @@
"videoCopied": "Video copied to clipboard" "videoCopied": "Video copied to clipboard"
}, },
"playlist": { "playlist": {
"badgeLabel": "Playlist",
"clearPreview": "Clear preview",
"comingSoon": "Playlist download feature coming soon!", "comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded", "completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel", "description": "Download all videos from a YouTube playlist or channel",
@@ -241,12 +242,27 @@
"filenameFormat": "Filename format for playlists", "filenameFormat": "Filename format for playlists",
"folderFormat": "Folder name format for playlists", "folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist", "foundVideos": "Found {{count}} videos in playlist",
"groupActive": "{{count}} active",
"groupErrors": "{{count}} failed",
"groupSummary": "{{completed}} / {{total}} completed",
"linkLabel": "Playlist URL", "linkLabel": "Playlist URL",
"noEntries": "No videos were found in this playlist",
"noEntriesInRange": "No videos in the selected range",
"noRangeSelected": "No end set - full playlist selected",
"playlistUrlDescription": "Download all videos from a playlist in bulk", "playlistUrlDescription": "Download all videos from a playlist in bulk",
"positionLabel": "Item {{index}} of {{total}}",
"previewButton": "Preview playlist",
"previewFailed": "Failed to preview playlist",
"previewSummary": "Preview playlist items before downloading.",
"previewRequired": "Preview the playlist before downloading.",
"range": "Range (Optional)", "range": "Range (Optional)",
"resetToDefault": "Reset to default", "resetToDefault": "Reset to default",
"selectedRange": "Range: {{start}}-{{end}}",
"showingCount": "Showing {{count}} videos",
"startIndex": "Start (1)", "startIndex": "Start (1)",
"title": "Download Playlist" "title": "Download Playlist",
"totalVideos": "Total videos: {{count}}",
"untitled": "Untitled playlist"
}, },
"settings": { "settings": {
"aboutTab": "About", "aboutTab": "About",
@@ -281,6 +297,8 @@
"general": "General", "general": "General",
"language": "Language", "language": "Language",
"light": "Light", "light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
"maxConcurrentDownloads": "Maximum number of active downloads", "maxConcurrentDownloads": "Maximum number of active downloads",
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads", "maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
"none": "None", "none": "None",

View File

@@ -60,7 +60,6 @@
"shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.", "shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.",
"shareTitle": "Faites passer le mot", "shareTitle": "Faites passer le mot",
"sourceCode": "Le code source est disponible", "sourceCode": "Le code source est disponible",
"tagline": "Un assistant de téléchargement convivial pour l'IA pour chaque créateur",
"title": "À propos", "title": "À propos",
"version": "Version", "version": "Version",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -60,7 +60,6 @@
"shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.", "shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.",
"shareTitle": "Passa parola", "shareTitle": "Passa parola",
"sourceCode": "Il codice sorgente è disponibile", "sourceCode": "Il codice sorgente è disponibile",
"tagline": "Un assistente di download amichevole per l'IA per ogni creatore",
"title": "Informazioni", "title": "Informazioni",
"version": "Versione", "version": "Versione",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -60,7 +60,6 @@
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。", "shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
"shareTitle": "口コミを広める", "shareTitle": "口コミを広める",
"sourceCode": "ソースコードが利用可能", "sourceCode": "ソースコードが利用可能",
"tagline": "すべてのクリエイターのためのAIフレンドリーなダウンロードアシスタント",
"title": "について", "title": "について",
"version": "バージョン", "version": "バージョン",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -60,7 +60,6 @@
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.", "shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
"shareTitle": "소문을 퍼뜨리세요", "shareTitle": "소문을 퍼뜨리세요",
"sourceCode": "소스 코드 사용 가능", "sourceCode": "소스 코드 사용 가능",
"tagline": "모든 크리에이터를 위한 AI 친화적 다운로드 도우미",
"title": "정보", "title": "정보",
"version": "버전", "version": "버전",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -60,7 +60,6 @@
"shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.", "shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.",
"shareTitle": "Espalhe a palavra", "shareTitle": "Espalhe a palavra",
"sourceCode": "Código fonte disponível", "sourceCode": "Código fonte disponível",
"tagline": "Um assistente de download amigável à IA para cada criador",
"title": "Sobre", "title": "Sobre",
"version": "Versão", "version": "Versão",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -22,6 +22,12 @@
"followAuthorTitle": "關注開發者", "followAuthorTitle": "關注開發者",
"here": "此處", "here": "此處",
"homepage": "首頁", "homepage": "首頁",
"latestVersionBadge": "最新版本v{{version}}",
"latestVersionStatus": {
"available": "有新版本可用",
"error": "無法取得最新版本",
"uptodate": "您已是最新版本"
},
"notifications": { "notifications": {
"checkingUpdates": "正在搜尋更新...", "checkingUpdates": "正在搜尋更新...",
"downloadError": "下載更新失敗", "downloadError": "下載更新失敗",
@@ -60,16 +66,9 @@
"shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。", "shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。",
"shareTitle": "廣為宣傳", "shareTitle": "廣為宣傳",
"sourceCode": "原始碼已開放", "sourceCode": "原始碼已開放",
"tagline": "面向每位創作者的 AI 友善下載助手",
"title": "關於", "title": "關於",
"version": "版本", "version": "版本",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}"
"latestVersionBadge": "最新版本v{{version}}",
"latestVersionStatus": {
"available": "有新版本可用",
"uptodate": "您已是最新版本",
"error": "無法取得最新版本"
}
}, },
"advancedOptions": { "advancedOptions": {
"closeWhenDone": "下載完成後關閉應用程式", "closeWhenDone": "下載完成後關閉應用程式",
@@ -227,6 +226,7 @@
"videoCopied": "影片已複製到剪貼簿" "videoCopied": "影片已複製到剪貼簿"
}, },
"playlist": { "playlist": {
"clearPreview": "清晰預覽",
"comingSoon": "播放清單下載功能即將推出!", "comingSoon": "播放清單下載功能即將推出!",
"completed": "播放清單已下載", "completed": "播放清單已下載",
"description": "下載 YouTube 播放清單或頻道中的全部影片", "description": "下載 YouTube 播放清單或頻道中的全部影片",
@@ -241,12 +241,27 @@
"filenameFormat": "播放清單檔案名稱格式", "filenameFormat": "播放清單檔案名稱格式",
"folderFormat": "播放清單資料夾命名格式", "folderFormat": "播放清單資料夾命名格式",
"foundVideos": "在播放清單中找到 {{count}} 個影片", "foundVideos": "在播放清單中找到 {{count}} 個影片",
"groupActive": "{{count}} 個活躍",
"groupErrors": "{{count}} 失敗",
"groupSummary": "{{已完成}} / {{總計}}已完成",
"linkLabel": "播放清單連結", "linkLabel": "播放清單連結",
"noEntries": "在此播放列表中找不到視頻",
"noEntriesInRange": "所選範圍內沒有視頻",
"noRangeSelected": "沒有結束設置 - 已選擇完整播放列表",
"playlistUrlDescription": "批量下載播放清單中的所有影片", "playlistUrlDescription": "批量下載播放清單中的所有影片",
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
"previewButton": "預覽播放列表",
"previewFailed": "預覽播放列表失敗",
"previewRequired": "下載前預覽播放列表。",
"previewSummary": "下載前預覽播放列表項目。",
"range": "範圍(可選)", "range": "範圍(可選)",
"resetToDefault": "恢復預設", "resetToDefault": "恢復預設",
"selectedRange": "範圍:{{開始}}-{{結束}}",
"showingCount": "顯示 {{count}} 個視頻",
"startIndex": "開始1", "startIndex": "開始1",
"title": "下載播放清單" "title": "下載播放清單",
"totalVideos": "視頻總數:{{count}}",
"untitled": "無標題播放列表"
}, },
"settings": { "settings": {
"aboutTab": "關於", "aboutTab": "關於",
@@ -262,8 +277,15 @@
"firefox": "Firefox", "firefox": "Firefox",
"safari": "Safari" "safari": "Safari"
}, },
"clearCookiesFile": "清除",
"configFile": "使用設定檔", "configFile": "使用設定檔",
"configFileDescription": "yt-dlp 的自訂設定檔", "configFileDescription": "yt-dlp 的自訂設定檔",
"cookiesFile": "餅乾文件",
"cookiesFileDescription": "要加載以進行身份​​驗證的 Netscape 格式的 cookie 文件",
"cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。",
"cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答",
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ並在需要時在此處選擇它。",
"cookiesHelpTitle": "使用cookie",
"dark": "深色", "dark": "深色",
"description": "設定下載偏好和應用程式設定", "description": "設定下載偏好和應用程式設定",
"directorySelectError": "選擇目錄失敗", "directorySelectError": "選擇目錄失敗",
@@ -290,6 +312,7 @@
"normal": "標準", "normal": "標準",
"worst": "最差" "worst": "最差"
}, },
"openLinkError": "無法打開鏈接",
"proxy": "代理伺服器", "proxy": "代理伺服器",
"proxyDescription": "網路請求的代理伺服器", "proxyDescription": "網路請求的代理伺服器",
"proxyPlaceholder": "http://proxy:port", "proxyPlaceholder": "http://proxy:port",

View File

@@ -52,7 +52,6 @@
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。", "resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源", "resourcesTitle": "资源",
"sourceCode": "源代码已开放", "sourceCode": "源代码已开放",
"tagline": "面向每位创作者的 AI 友好下载助手",
"title": "关于", "title": "关于",
"version": "版本", "version": "版本",
"versionLabel": "v{{version}}", "versionLabel": "v{{version}}",

View File

@@ -48,7 +48,7 @@ export function About() {
const [appVersion, setAppVersion] = useState<string>('—') const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null) const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const saveSetting = useSetAtom(saveSettingAtom) const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://github.com/nexmoe/VidBee' const shareTargetUrl = 'https://vidbee.org'
useEffect(() => { useEffect(() => {
let isActive = true let isActive = true
@@ -114,7 +114,7 @@ export function About() {
const shareLinks = useMemo(() => { const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl) const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.tagline')) const encodedText = encodeURIComponent(t('about.description'))
return { return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
@@ -207,11 +207,6 @@ export function About() {
return ( return (
<div className="h-full bg-background"> <div className="h-full bg-background">
<div className="container mx-auto max-w-5xl p-6 space-y-6"> <div className="container mx-auto max-w-5xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
<p className="text-muted-foreground">{t('about.description')}</p>
</div>
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -220,7 +215,7 @@ export function About() {
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2> <h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.tagline')}</p> <p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary"> <Badge variant="secondary">

View File

@@ -15,15 +15,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from '@renderer/components/ui/select' } from '@renderer/components/ui/select'
import { Tabs, TabsContent } from '@renderer/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites' import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset } from '@shared/types' import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react' import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useRef, useState } from 'react' import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory' import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
import { VideoInfoCard } from '../components/video/VideoInfoCard' import { VideoInfoCard } from '../components/video/VideoInfoCard'
import { ipcEvents, ipcServices } from '../lib/ipc' import { ipcEvents, ipcServices } from '../lib/ipc'
import { import {
@@ -148,10 +149,41 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
const playlistUrlId = useId() const playlistUrlId = useId()
const downloadTypeId = useId() const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('') const [playlistUrl, setPlaylistUrl] = useState('')
const [playlistLoading, setPlaylistLoading] = useState(false)
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video') const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1') const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('') const [endIndex, setEndIndex] = useState('')
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const computePlaylistRange = useCallback(
(info: PlaylistInfo) => {
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
const endValue =
rawEnd !== undefined
? info.entryCount > 0
? Math.min(rawEnd, info.entryCount)
: rawEnd
: undefined
return { start, end: endValue }
},
[startIndex, endIndex]
)
const selectedPlaylistEntries = useMemo(() => {
if (!playlistInfo) {
return []
}
const range = computePlaylistRange(playlistInfo)
const previewEnd = range.end ?? playlistInfo.entryCount
return playlistInfo.entries.filter(
(entry) => entry.index >= range.start && entry.index <= previewEnd
)
}, [playlistInfo, computePlaylistRange])
const syncHistoryItem = useCallback( const syncHistoryItem = useCallback(
async (id: string) => { async (id: string) => {
@@ -366,70 +398,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
// Playlist handlers // Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => { const handlePastePlaylistUrl = useCallback(async () => {
if (playlistBusy) return
try { try {
const text = await navigator.clipboard.readText() const text = await navigator.clipboard.readText()
if (!text.trim()) { if (!text.trim()) {
toast.error(t('errors.clipboardEmpty')) toast.error(t('errors.clipboardEmpty'))
return return
} }
setPlaylistUrl(text.trim()) const trimmed = text.trim()
setPlaylistUrl(trimmed)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
} catch (error) { } catch (error) {
console.error('Failed to paste URL:', error) console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard')) toast.error(t('errors.pasteFromClipboard'))
} }
}, [t]) }, [playlistBusy, t])
const handleDownloadPlaylist = useCallback(async () => { const handleClearPlaylistPreview = useCallback(() => {
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}, [])
const handlePreviewPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) { if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl')) toast.error(t('errors.emptyUrl'))
return return
} }
setPlaylistPreviewError(null)
setPlaylistLoading(true) setPlaylistPreviewLoading(true)
try { try {
// Get playlist info first to show user what will be downloaded const trimmedUrl = playlistUrl.trim()
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl) 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])
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount })) 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
}
// Build format preference based on settings
const format = const format =
downloadType === 'video' downloadType === 'video'
? buildVideoFormatPreference(settings) ? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings) : buildAudioFormatPreference(settings)
// Start playlist download const result = await ipcServices.download.startPlaylistDownload({
const downloadIds = await ipcServices.download.startPlaylistDownload({ url: trimmedUrl,
url: playlistUrl.trim(),
type: downloadType, type: downloadType,
format, format,
startIndex: parseInt(startIndex, 10) || 1, startIndex: range.start,
endIndex: endIndex ? parseInt(endIndex, 10) : undefined endIndex: range.end
}) })
// Add all downloads to the renderer state if (result.totalCount === 0) {
for (const id of downloadIds) { toast.error(t('playlist.noEntriesInRange'))
return
}
const baseCreatedAt = Date.now()
result.entries.forEach((entry, index) => {
const downloadItem = { const downloadItem = {
id, id: entry.downloadId,
url: playlistUrl.trim(), url: entry.url,
title: t('download.fetchingVideoInfo'), title: entry.title || t('download.fetchingVideoInfo'),
type: downloadType, type: downloadType,
status: 'pending' as const, status: 'pending' as const,
progress: { percent: 0 }, progress: { percent: 0 },
createdAt: Date.now() createdAt: baseCreatedAt + index,
playlistId: result.groupId,
playlistTitle: result.playlistTitle,
playlistIndex: entry.index,
playlistSize: result.totalCount
} }
addDownload(downloadItem) addDownload(downloadItem)
} })
toast.success(t('playlist.downloadStarted', { count: downloadIds.length })) toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
setPlaylistUrl('') // Clear the URL after starting download
} catch (error) { } catch (error) {
console.error('Failed to start playlist download:', error) console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed')) toast.error(t('playlist.downloadFailed'))
} finally { } finally {
setPlaylistLoading(false) setPlaylistDownloadLoading(false)
} }
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t]) }, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
// Auto-focus input on mount // Auto-focus input on mount
useEffect(() => { useEffect(() => {
@@ -442,7 +535,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
> >
<Tabs defaultValue="single" className="w-full"> <Tabs defaultValue="single" className="w-full">
{/* <TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2"> <TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
{t('download.singleVideo')} {t('download.singleVideo')}
@@ -451,7 +544,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<ListVideo className="h-4 w-4" /> <ListVideo className="h-4 w-4" />
{t('playlist.title')} {t('playlist.title')}
</TabsTrigger> </TabsTrigger>
</TabsList> */} </TabsList>
{/* Single Video Download Tab */} {/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6"> <TabsContent value="single" className="space-y-6">
@@ -580,14 +673,18 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
id={playlistUrlId} id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..." placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl} value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)} onChange={(e) => {
setPlaylistUrl(e.target.value)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}}
className="flex-1" className="flex-1"
disabled={playlistLoading} disabled={playlistBusy}
/> />
<Button <Button
onClick={handlePastePlaylistUrl} onClick={handlePastePlaylistUrl}
variant="outline" variant="outline"
disabled={playlistLoading} disabled={playlistBusy}
> >
{t('download.paste')} {t('download.paste')}
</Button> </Button>
@@ -600,7 +697,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<Select <Select
value={downloadType} value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')} onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistLoading} disabled={playlistBusy}
> >
<SelectTrigger id={downloadTypeId}> <SelectTrigger id={downloadTypeId}>
<SelectValue /> <SelectValue />
@@ -621,7 +718,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
value={startIndex} value={startIndex}
onChange={(e) => setStartIndex(e.target.value)} onChange={(e) => setStartIndex(e.target.value)}
min="1" min="1"
disabled={playlistLoading} disabled={playlistBusy}
/> />
<Input <Input
type="number" type="number"
@@ -629,29 +726,68 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
value={endIndex} value={endIndex}
onChange={(e) => setEndIndex(e.target.value)} onChange={(e) => setEndIndex(e.target.value)}
min="1" min="1"
disabled={playlistLoading} disabled={playlistBusy}
/> />
</div> </div>
</div> </div>
</div> </div>
<Button <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
onClick={handleDownloadPlaylist} <Button
className="w-full" onClick={handlePreviewPlaylist}
size="lg" variant="outline"
disabled={playlistLoading || !playlistUrl.trim()} className="w-full sm:w-auto"
> disabled={playlistBusy || !playlistUrl.trim()}
{playlistLoading ? ( >
<> {playlistPreviewLoading ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> <>
{t('download.loading')} <Loader2 className="mr-2 h-5 w-5 animate-spin" />
</> {t('download.loading')}
) : ( </>
t('playlist.downloadPlaylist') ) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
)} )}
</Button> </div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -21,7 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import type { OneClickQualityPreset } from '@shared/types' import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings' import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
@@ -32,11 +32,26 @@ export function Settings() {
const [settings, _setSettings] = useAtom(settingsAtom) const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom) const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom) const saveSetting = useSetAtom(saveSettingAtom)
const [platform, setPlatform] = useState<string>('')
useEffect(() => { useEffect(() => {
loadSettings() loadSettings()
}, [loadSettings]) }, [loadSettings])
useEffect(() => {
const fetchPlatform = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const platformInfo = await ipcServices.app.getPlatform()
setPlatform(platformInfo)
} catch (error) {
console.error('Failed to get platform info:', error)
}
}
fetchPlatform()
}, [])
const handleSettingChange = async ( const handleSettingChange = async (
key: keyof typeof settings, key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings] value: (typeof settings)[keyof typeof settings]
@@ -263,6 +278,23 @@ export function Settings() {
</TabsContent> </TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-2"> <TabsContent value="advanced" className="space-y-4 mt-2">
{platform === 'darwin' && (
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.hideDockIcon')}</ItemTitle>
<ItemDescription>{t('settings.hideDockIconDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.hideDockIcon}
onCheckedChange={(value) => handleSettingChange('hideDockIcon', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
)}
<ItemGroup> <ItemGroup>
<Item variant="muted"> <Item variant="muted">
<ItemContent> <ItemContent>

View File

@@ -39,6 +39,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
viewCount: item.viewCount, viewCount: item.viewCount,
tags: item.tags, tags: item.tags,
selectedFormat: item.selectedFormat, selectedFormat: item.selectedFormat,
playlistId: item.playlistId,
playlistTitle: item.playlistTitle,
playlistIndex: item.playlistIndex,
playlistSize: item.playlistSize,
entryType: 'history', entryType: 'history',
downloadedAt: item.downloadedAt downloadedAt: item.downloadedAt
}) })

View File

@@ -76,6 +76,11 @@ export interface DownloadItem {
tags?: string[] tags?: string[]
// Download-specific format info // Download-specific format info
selectedFormat?: VideoFormat selectedFormat?: VideoFormat
// Playlist context (optional)
playlistId?: string
playlistTitle?: string
playlistIndex?: number
playlistSize?: number
} }
export interface DownloadHistoryItem { export interface DownloadHistoryItem {
@@ -103,6 +108,11 @@ export interface DownloadHistoryItem {
tags?: string[] tags?: string[]
// Download-specific format info // Download-specific format info
selectedFormat?: VideoFormat selectedFormat?: VideoFormat
// Playlist context (optional)
playlistId?: string
playlistTitle?: string
playlistIndex?: number
playlistSize?: number
} }
export interface DownloadOptions { export interface DownloadOptions {
@@ -117,14 +127,17 @@ export interface DownloadOptions {
downloadSubs?: boolean downloadSubs?: boolean
} }
export interface PlaylistEntry {
id: string
title: string
url: string
index: number
}
export interface PlaylistInfo { export interface PlaylistInfo {
id: string id: string
title: string title: string
entries: Array<{ entries: PlaylistEntry[]
id: string
title: string
url: string
}>
entryCount: number entryCount: number
} }
@@ -138,6 +151,25 @@ export interface PlaylistDownloadOptions {
folderFormat?: string folderFormat?: string
} }
export interface PlaylistDownloadEntry {
downloadId: string
entryId: string
title: string
url: string
index: number
}
export interface PlaylistDownloadResult {
groupId: string
playlistId: string
playlistTitle: string
type: 'video' | 'audio'
totalCount: number
startIndex: number
endIndex: number
entries: PlaylistDownloadEntry[]
}
// Settings types // Settings types
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst' export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
@@ -156,6 +188,7 @@ export interface AppSettings {
oneClickDownloadType: 'video' | 'audio' oneClickDownloadType: 'video' | 'audio'
oneClickQuality: OneClickQualityPreset oneClickQuality: OneClickQualityPreset
closeToTray: boolean closeToTray: boolean
hideDockIcon: boolean
autoUpdate: boolean autoUpdate: boolean
} }
@@ -174,5 +207,6 @@ export const defaultSettings: AppSettings = {
oneClickDownloadType: 'video', oneClickDownloadType: 'video',
oneClickQuality: 'auto', oneClickQuality: 'auto',
closeToTray: false, closeToTray: false,
hideDockIcon: false,
autoUpdate: true autoUpdate: true
} }