Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11eaaf0f88 | ||
|
|
512b9d1175 | ||
|
|
a1307bdd0b | ||
|
|
06132a0704 | ||
|
|
a7e4eb865b | ||
|
|
5246ab8369 |
@@ -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>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -90,6 +90,11 @@ export const buildDownloadArgs = (
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { downloadEngine } from './lib/download-engine'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyDockVisibility } from './utils/dock'
|
||||
|
||||
// Initialize electron-log for main process
|
||||
log.initialize()
|
||||
@@ -27,6 +28,7 @@ export function createWindow(): void {
|
||||
height: 800,
|
||||
show: false,
|
||||
titleBarStyle: 'hidden', // Hide title bar on macOS
|
||||
trafficLightPosition: { x: 12.5, y: 10 },
|
||||
autoHideMenuBar: true,
|
||||
icon: appIcon, // Set application icon
|
||||
frame: false,
|
||||
@@ -94,12 +96,12 @@ function setupDownloadEvents(): void {
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Initializing auto-updater...')
|
||||
log.info('Initializing auto-updater...')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
autoUpdater.logger = log
|
||||
@@ -108,31 +110,26 @@ function initAutoUpdater(): void {
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
console.log('Update available:', info.version)
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log.info('Update not available:', info.version)
|
||||
console.log('Update not available:', info.version)
|
||||
mainWindow?.webContents.send('update:not-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Update error:', err)
|
||||
console.error('Update error:', err)
|
||||
mainWindow?.webContents.send('update:error', err.message)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
log.info('Download progress:', progressObj.percent)
|
||||
console.log('Download progress:', progressObj.percent)
|
||||
mainWindow?.webContents.send('update:download-progress', progressObj)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
console.log('Update downloaded:', info.version)
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
if (mainWindow) {
|
||||
@@ -146,15 +143,12 @@ function initAutoUpdater(): void {
|
||||
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, checking for updates...')
|
||||
console.log('Auto-update is enabled, checking for updates...')
|
||||
void autoUpdater.checkForUpdatesAndNotify()
|
||||
}
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
console.log('Auto-updater initialized successfully')
|
||||
} catch (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)
|
||||
}
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
|
||||
createWindow()
|
||||
|
||||
initAutoUpdater()
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
} from '../../../shared/types'
|
||||
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
|
||||
async startPlaylistDownload(
|
||||
_context: IpcContext,
|
||||
options: PlaylistDownloadOptions
|
||||
): Promise<string[]> {
|
||||
): Promise<PlaylistDownloadResult> {
|
||||
return downloadEngine.startPlaylistDownload(options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
|
||||
class SettingsService extends IpcService {
|
||||
static readonly groupName = 'settings'
|
||||
@@ -18,6 +19,10 @@ class SettingsService extends IpcService {
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -32,11 +37,16 @@ class SettingsService extends IpcService {
|
||||
if (settings.language) {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
const isNewerVersion = (latest: string, current: string): boolean => {
|
||||
const toSegments = (version: string) =>
|
||||
version.split(/[.-]/).map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const latestSegments = toSegments(latest)
|
||||
const currentSegments = toSegments(current)
|
||||
const maxLength = Math.max(latestSegments.length, currentSegments.length)
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const latestValue = latestSegments[index] ?? 0
|
||||
const currentValue = currentSegments[index] ?? 0
|
||||
|
||||
if (latestValue > currentValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (latestValue < currentValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class UpdateService extends IpcService {
|
||||
static readonly groupName = 'update'
|
||||
|
||||
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
|
||||
_context: IpcContext
|
||||
): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
// In production, use checkForUpdatesAndNotify for automatic notifications
|
||||
const result = await autoUpdater.checkForUpdatesAndNotify()
|
||||
const currentVersion = app.getVersion()
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const latestVersion = result?.updateInfo?.version
|
||||
|
||||
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
|
||||
return {
|
||||
available: true,
|
||||
version: latestVersion
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: result !== null,
|
||||
version: result?.updateInfo?.version
|
||||
available: false,
|
||||
version: latestVersion ?? currentVersion
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
DownloadOptions,
|
||||
DownloadProgress,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
@@ -61,6 +62,11 @@ class DownloadEngine extends EventEmitter {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
@@ -115,7 +121,7 @@ class DownloadEngine extends EventEmitter {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
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
|
||||
args.push('--encoding', 'utf-8')
|
||||
@@ -130,8 +136,57 @@ class DownloadEngine extends EventEmitter {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', `"${settings.configPath}"`)
|
||||
}
|
||||
|
||||
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) => {
|
||||
const process = ytdlp.exec(args)
|
||||
let stdout = ''
|
||||
@@ -148,51 +203,107 @@ class DownloadEngine extends EventEmitter {
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const lines = stdout.trim().split('\n')
|
||||
const entries = lines.map((line) => JSON.parse(line))
|
||||
const playlistEntry = entries[0]
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
id?: string
|
||||
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({
|
||||
id: playlistEntry.id || '',
|
||||
title: playlistEntry.title || 'Playlist',
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry.id || '',
|
||||
title: entry.title || 'Unknown',
|
||||
url: entry.url || entry.webpage_url || ''
|
||||
})),
|
||||
id: parsed.id || url,
|
||||
title: parsed.title || 'Playlist',
|
||||
entries,
|
||||
entryCount: entries.length
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to parse playlist info for:', url, error)
|
||||
reject(new Error(`Failed to parse playlist info: ${error}`))
|
||||
}
|
||||
} 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'))
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<string[]> {
|
||||
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
|
||||
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
|
||||
const startIndex = (options.startIndex || 1) - 1 // Convert to 0-based index
|
||||
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
|
||||
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
|
||||
const totalEntries = playlistInfo.entries.length
|
||||
if (totalEntries === 0) {
|
||||
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(
|
||||
`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
|
||||
for (const entry of entriesToDownload) {
|
||||
const downloadId = `playlist_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
downloadIds.push(downloadId)
|
||||
for (const entry of selectedEntries) {
|
||||
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
||||
|
||||
const downloadOptions: DownloadOptions = {
|
||||
url: entry.url,
|
||||
@@ -202,6 +313,13 @@ class DownloadEngine extends EventEmitter {
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
downloadEntries.push({
|
||||
downloadId,
|
||||
entryId: entry.id,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
index: entry.index
|
||||
})
|
||||
|
||||
// Add to queue
|
||||
this.queue.add(downloadId, downloadOptions, {
|
||||
@@ -211,17 +329,35 @@ class DownloadEngine extends EventEmitter {
|
||||
type: options.type,
|
||||
status: 'pending',
|
||||
progress: { percent: 0 },
|
||||
createdAt
|
||||
createdAt,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
|
||||
this.upsertHistoryEntry(downloadId, downloadOptions, {
|
||||
title: entry.title,
|
||||
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 {
|
||||
@@ -597,6 +733,18 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.tags !== undefined) {
|
||||
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) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -641,7 +789,11 @@ class DownloadEngine extends EventEmitter {
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -673,7 +825,11 @@ class DownloadEngine extends EventEmitter {
|
||||
viewCount: updates.viewCount,
|
||||
tags: updates.tags,
|
||||
// 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 = {
|
||||
|
||||
16
src/main/utils/dock.ts
Normal file
16
src/main/utils/dock.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ function AppContent() {
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{/* Custom Title Bar - Hide on macOS */}
|
||||
{platform !== 'darwin' && <TitleBar />}
|
||||
{/* Custom Title Bar */}
|
||||
<TitleBar platform={platform} />
|
||||
|
||||
<ScrollArea
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
|
||||
@@ -214,6 +214,28 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { History as HistoryIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||
|
||||
@@ -47,6 +49,56 @@ export function UnifiedDownloadHistory() {
|
||||
{ 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(
|
||||
(item) => item.entryType === 'active' && item.status === 'completed'
|
||||
)
|
||||
@@ -110,10 +162,32 @@ export function UnifiedDownloadHistory() {
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
|
||||
{filteredRecords.map((record) => (
|
||||
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
|
||||
))}
|
||||
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
|
||||
{groupedView.order.map((item) => {
|
||||
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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,15 @@ import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
|
||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||
import '../../assets/title-bar.css'
|
||||
|
||||
export function TitleBar() {
|
||||
interface TitleBarProps {
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export function TitleBar({ platform }: TitleBarProps) {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听窗口最大化状态变化
|
||||
// Listen for window maximize state changes
|
||||
const handleMaximized = () => {
|
||||
setIsMaximized(true)
|
||||
}
|
||||
@@ -41,8 +45,17 @@ export function TitleBar() {
|
||||
ipcServices.window.close()
|
||||
}
|
||||
|
||||
const isMac = platform === 'darwin'
|
||||
const containerClass = `flex drag-region bg-background select-none ${
|
||||
isMac ? 'h-10 items-center px-4' : 'justify-end pt-4 px-5'
|
||||
}`
|
||||
|
||||
if (isMac) {
|
||||
return <div className={containerClass} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
|
||||
<div className={containerClass}>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-1 no-drag">
|
||||
<Button
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
|
||||
"shareTitle": "Spread the word",
|
||||
"sourceCode": "Source Code is available",
|
||||
"tagline": "An AI-friendly download helper for every creator",
|
||||
"title": "About",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
@@ -227,6 +226,8 @@
|
||||
"videoCopied": "Video copied to clipboard"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -241,12 +242,27 @@
|
||||
"filenameFormat": "Filename format for playlists",
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"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",
|
||||
"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)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist"
|
||||
"title": "Download Playlist",
|
||||
"totalVideos": "Total videos: {{count}}",
|
||||
"untitled": "Untitled playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "About",
|
||||
@@ -255,6 +271,14 @@
|
||||
"audio": "Audio Preferences",
|
||||
"browserForCookies": "Select browser to use cookies from",
|
||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
|
||||
"cookiesFile": "Cookies file",
|
||||
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
||||
"clearCookiesFile": "Clear",
|
||||
"cookiesHelpTitle": "Using cookies",
|
||||
"cookiesHelpBrowser": "Pick your browser above to reuse its signed-in session automatically.",
|
||||
"cookiesHelpFile": "Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.",
|
||||
"cookiesHelpFaq": "Open yt-dlp cookies FAQ",
|
||||
"openLinkError": "Failed to open link",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
@@ -273,6 +297,8 @@
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"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",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.",
|
||||
"shareTitle": "Faites passer le mot",
|
||||
"sourceCode": "Le code source est disponible",
|
||||
"tagline": "Un assistant de téléchargement convivial pour l'IA pour chaque créateur",
|
||||
"title": "À propos",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.",
|
||||
"shareTitle": "Passa parola",
|
||||
"sourceCode": "Il codice sorgente è disponibile",
|
||||
"tagline": "Un assistente di download amichevole per l'IA per ogni creatore",
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
|
||||
"shareTitle": "口コミを広める",
|
||||
"sourceCode": "ソースコードが利用可能",
|
||||
"tagline": "すべてのクリエイターのためのAIフレンドリーなダウンロードアシスタント",
|
||||
"title": "について",
|
||||
"version": "バージョン",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
|
||||
"shareTitle": "소문을 퍼뜨리세요",
|
||||
"sourceCode": "소스 코드 사용 가능",
|
||||
"tagline": "모든 크리에이터를 위한 AI 친화적 다운로드 도우미",
|
||||
"title": "정보",
|
||||
"version": "버전",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
"shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.",
|
||||
"shareTitle": "Espalhe a palavra",
|
||||
"sourceCode": "Código fonte disponível",
|
||||
"tagline": "Um assistente de download amigável à IA para cada criador",
|
||||
"title": "Sobre",
|
||||
"version": "Versão",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
"followAuthorTitle": "關注開發者",
|
||||
"here": "此處",
|
||||
"homepage": "首頁",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"error": "無法取得最新版本",
|
||||
"uptodate": "您已是最新版本"
|
||||
},
|
||||
"notifications": {
|
||||
"checkingUpdates": "正在搜尋更新...",
|
||||
"downloadError": "下載更新失敗",
|
||||
@@ -60,16 +66,9 @@
|
||||
"shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。",
|
||||
"shareTitle": "廣為宣傳",
|
||||
"sourceCode": "原始碼已開放",
|
||||
"tagline": "面向每位創作者的 AI 友善下載助手",
|
||||
"title": "關於",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"uptodate": "您已是最新版本",
|
||||
"error": "無法取得最新版本"
|
||||
}
|
||||
"versionLabel": "v{{version}}"
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "下載完成後關閉應用程式",
|
||||
@@ -227,6 +226,7 @@
|
||||
"videoCopied": "影片已複製到剪貼簿"
|
||||
},
|
||||
"playlist": {
|
||||
"clearPreview": "清晰預覽",
|
||||
"comingSoon": "播放清單下載功能即將推出!",
|
||||
"completed": "播放清單已下載",
|
||||
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
||||
@@ -241,12 +241,27 @@
|
||||
"filenameFormat": "播放清單檔案名稱格式",
|
||||
"folderFormat": "播放清單資料夾命名格式",
|
||||
"foundVideos": "在播放清單中找到 {{count}} 個影片",
|
||||
"groupActive": "{{count}} 個活躍",
|
||||
"groupErrors": "{{count}} 失敗",
|
||||
"groupSummary": "{{已完成}} / {{總計}}已完成",
|
||||
"linkLabel": "播放清單連結",
|
||||
"noEntries": "在此播放列表中找不到視頻",
|
||||
"noEntriesInRange": "所選範圍內沒有視頻",
|
||||
"noRangeSelected": "沒有結束設置 - 已選擇完整播放列表",
|
||||
"playlistUrlDescription": "批量下載播放清單中的所有影片",
|
||||
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
|
||||
"previewButton": "預覽播放列表",
|
||||
"previewFailed": "預覽播放列表失敗",
|
||||
"previewRequired": "下載前預覽播放列表。",
|
||||
"previewSummary": "下載前預覽播放列表項目。",
|
||||
"range": "範圍(可選)",
|
||||
"resetToDefault": "恢復預設",
|
||||
"selectedRange": "範圍:{{開始}}-{{結束}}",
|
||||
"showingCount": "顯示 {{count}} 個視頻",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "下載播放清單"
|
||||
"title": "下載播放清單",
|
||||
"totalVideos": "視頻總數:{{count}}",
|
||||
"untitled": "無標題播放列表"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "關於",
|
||||
@@ -262,8 +277,15 @@
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"clearCookiesFile": "清除",
|
||||
"configFile": "使用設定檔",
|
||||
"configFileDescription": "yt-dlp 的自訂設定檔",
|
||||
"cookiesFile": "餅乾文件",
|
||||
"cookiesFileDescription": "要加載以進行身份驗證的 Netscape 格式的 cookie 文件",
|
||||
"cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。",
|
||||
"cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答",
|
||||
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ)並在需要時在此處選擇它。",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"dark": "深色",
|
||||
"description": "設定下載偏好和應用程式設定",
|
||||
"directorySelectError": "選擇目錄失敗",
|
||||
@@ -290,6 +312,7 @@
|
||||
"normal": "標準",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "無法打開鏈接",
|
||||
"proxy": "代理伺服器",
|
||||
"proxyDescription": "網路請求的代理伺服器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||
"resourcesTitle": "资源",
|
||||
"sourceCode": "源代码已开放",
|
||||
"tagline": "面向每位创作者的 AI 友好下载助手",
|
||||
"title": "关于",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}",
|
||||
|
||||
@@ -48,7 +48,7 @@ export function About() {
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
@@ -114,7 +114,7 @@ export function About() {
|
||||
|
||||
const shareLinks = useMemo(() => {
|
||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||
const encodedText = encodeURIComponent(t('about.tagline'))
|
||||
const encodedText = encodeURIComponent(t('about.description'))
|
||||
|
||||
return {
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
@@ -165,6 +165,13 @@ export function About() {
|
||||
|
||||
const aboutResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
{
|
||||
icon: LinkIcon,
|
||||
label: t('about.resources.website'),
|
||||
description: t('about.resources.websiteDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://vidbee.org/'
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: t('about.resources.changelog'),
|
||||
@@ -200,11 +207,6 @@ export function About() {
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
<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>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -213,7 +215,7 @@ export function About() {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<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 className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
|
||||
@@ -15,15 +15,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} 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 type { AppSettings, OneClickQualityPreset } from '@shared/types'
|
||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { AlertCircle, Download, ListVideo, Loader2, 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 {
|
||||
@@ -148,10 +149,41 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
const playlistUrlId = useId()
|
||||
const downloadTypeId = useId()
|
||||
const [playlistUrl, setPlaylistUrl] = useState('')
|
||||
const [playlistLoading, setPlaylistLoading] = useState(false)
|
||||
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
|
||||
const [startIndex, setStartIndex] = useState('1')
|
||||
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(
|
||||
async (id: string) => {
|
||||
@@ -366,70 +398,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
|
||||
// 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
|
||||
}
|
||||
setPlaylistUrl(text.trim())
|
||||
const trimmed = text.trim()
|
||||
setPlaylistUrl(trimmed)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to paste URL:', error)
|
||||
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()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
setPlaylistLoading(true)
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistPreviewLoading(true)
|
||||
try {
|
||||
// Get playlist info first to show user what will be downloaded
|
||||
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
|
||||
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])
|
||||
|
||||
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 =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
// Start playlist download
|
||||
const downloadIds = await ipcServices.download.startPlaylistDownload({
|
||||
url: playlistUrl.trim(),
|
||||
const result = await ipcServices.download.startPlaylistDownload({
|
||||
url: trimmedUrl,
|
||||
type: downloadType,
|
||||
format,
|
||||
startIndex: parseInt(startIndex, 10) || 1,
|
||||
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
|
||||
startIndex: range.start,
|
||||
endIndex: range.end
|
||||
})
|
||||
|
||||
// Add all downloads to the renderer state
|
||||
for (const id of downloadIds) {
|
||||
if (result.totalCount === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
const baseCreatedAt = Date.now()
|
||||
result.entries.forEach((entry, index) => {
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: playlistUrl.trim(),
|
||||
title: t('download.fetchingVideoInfo'),
|
||||
id: entry.downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title || t('download.fetchingVideoInfo'),
|
||||
type: downloadType,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt: Date.now()
|
||||
createdAt: baseCreatedAt + index,
|
||||
playlistId: result.groupId,
|
||||
playlistTitle: result.playlistTitle,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: result.totalCount
|
||||
}
|
||||
addDownload(downloadItem)
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
|
||||
setPlaylistUrl('') // Clear the URL after starting download
|
||||
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to start playlist download:', error)
|
||||
toast.error(t('playlist.downloadFailed'))
|
||||
} finally {
|
||||
setPlaylistLoading(false)
|
||||
setPlaylistDownloadLoading(false)
|
||||
}
|
||||
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
|
||||
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
|
||||
|
||||
// Auto-focus input on mount
|
||||
useEffect(() => {
|
||||
@@ -442,7 +535,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<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">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('download.singleVideo')}
|
||||
@@ -451,7 +544,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
<ListVideo className="h-4 w-4" />
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList> */}
|
||||
</TabsList>
|
||||
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6">
|
||||
@@ -580,14 +673,18 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => setPlaylistUrl(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPlaylistUrl(e.target.value)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
@@ -600,7 +697,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
@@ -621,7 +718,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -629,29 +726,68 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={playlistLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{playlistInfo && (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
</Card>
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||
import type { OneClickQualityPreset } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
@@ -32,11 +32,26 @@ export function Settings() {
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
@@ -71,6 +86,31 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCookiesFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('cookiesPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select cookies file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCookiesFaq = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
await ipcServices.fs.openExternal(
|
||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to open cookies FAQ:', error)
|
||||
toast.error(t('settings.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleThemeChange = async (value: 'light' | 'dark' | 'system') => {
|
||||
const currentTheme = (theme ?? settings.theme ?? 'system') as 'light' | 'dark' | 'system'
|
||||
if (currentTheme === value) {
|
||||
@@ -238,6 +278,23 @@ export function Settings() {
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
@@ -269,35 +326,6 @@ export function Settings() {
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.proxy')}</ItemTitle>
|
||||
@@ -328,6 +356,76 @@ export function Settings() {
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesFile')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
||||
disabled={!settings.cookiesPath}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||
{t('settings.cookiesHelpFaq')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
viewCount: item.viewCount,
|
||||
tags: item.tags,
|
||||
selectedFormat: item.selectedFormat,
|
||||
playlistId: item.playlistId,
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { atom } from 'jotai'
|
||||
import { normalizeLanguageCode } from '../../../shared/languages'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { defaultSettings } from '../../../shared/types'
|
||||
import i18n from '../i18n'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
// Settings atom
|
||||
@@ -10,7 +12,18 @@ export const settingsAtom = atom<AppSettings>(defaultSettings)
|
||||
export const loadSettingsAtom = atom(null, async (_get, set) => {
|
||||
try {
|
||||
const settings = await ipcServices.settings.getAll()
|
||||
set(settingsAtom, settings)
|
||||
const savedLanguage = normalizeLanguageCode(settings.language)
|
||||
const currentLanguage = normalizeLanguageCode(i18n.language)
|
||||
|
||||
if (currentLanguage !== savedLanguage) {
|
||||
try {
|
||||
await i18n.changeLanguage(savedLanguage)
|
||||
} catch (error) {
|
||||
console.error('Failed to apply saved language:', error)
|
||||
}
|
||||
}
|
||||
|
||||
set(settingsAtom, { ...settings, language: savedLanguage })
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,11 @@ export interface DownloadItem {
|
||||
tags?: string[]
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
@@ -103,6 +108,11 @@ export interface DownloadHistoryItem {
|
||||
tags?: string[]
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
@@ -117,14 +127,17 @@ export interface DownloadOptions {
|
||||
downloadSubs?: boolean
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
id: string
|
||||
title: string
|
||||
entries: Array<{
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
}>
|
||||
entries: PlaylistEntry[]
|
||||
entryCount: number
|
||||
}
|
||||
|
||||
@@ -138,6 +151,25 @@ export interface PlaylistDownloadOptions {
|
||||
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
|
||||
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
@@ -146,6 +178,7 @@ export interface AppSettings {
|
||||
showMoreFormats: boolean
|
||||
maxConcurrentDownloads: number
|
||||
browserForCookies: string
|
||||
cookiesPath: string
|
||||
proxy: string
|
||||
configPath: string
|
||||
betaProgram: boolean
|
||||
@@ -155,6 +188,7 @@ export interface AppSettings {
|
||||
oneClickDownloadType: 'video' | 'audio'
|
||||
oneClickQuality: OneClickQualityPreset
|
||||
closeToTray: boolean
|
||||
hideDockIcon: boolean
|
||||
autoUpdate: boolean
|
||||
}
|
||||
|
||||
@@ -163,6 +197,7 @@ export const defaultSettings: AppSettings = {
|
||||
showMoreFormats: false,
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
proxy: '',
|
||||
configPath: '',
|
||||
betaProgram: false,
|
||||
@@ -172,5 +207,6 @@ export const defaultSettings: AppSettings = {
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'auto',
|
||||
closeToTray: false,
|
||||
hideDockIcon: false,
|
||||
autoUpdate: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user