Compare commits

...

10 Commits

Author SHA1 Message Date
Nexmoe
2a28b0db85 chore: release v0.3.2 2025-10-31 20:21:46 +08:00
Nexmoe
ca4b851ba4 Update mac build script to output separate mac packages (#9)
* build: produce separate mac binaries

* chore(build): add artifactName for mac builds to include arch in file name
2025-10-31 20:20:51 +08:00
Nexmoe
0742dac057 chore: release v0.3.1 2025-10-30 22:36:50 +08:00
Nexmoe
9d2e35c322 chore: update maintainer email in electron-builder configuration 2025-10-30 22:35:42 +08:00
Nexmoe
526bd994d8 feat(updates): add auto-update notifications and handlers (#8)
Add update-related UI and IPC handling to App. Integrate
i18n and sonner to show localized toast notifications update
lifecycle events (available, download started, progress, downloaded,
errors, and restart prompt). Use a ref to guard against concurrent
download attempts and read auto-update preference from settings atom.
Wire IPC event names and services, and ensure graceful error handling
and user-triggered download/restart actions.

This enables better user feedback for updates and supports both
automatic and manual update flows.
2025-10-30 22:25:02 +08:00
Nexmoe
37c1f22aee Improve Linux compatibility (#6)
* Improve Linux compatibility

* Run CI on Linux builds

* Update release workflow for Linux builds
2025-10-30 21:36:00 +08:00
Nexmoe
10a97b1f4a support of macOS x64 packing (#7) 2025-10-30 21:31:42 +08:00
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
32 changed files with 1000 additions and 182 deletions

View File

@@ -6,7 +6,19 @@ on:
jobs: jobs:
test: test:
runs-on: windows-latest strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
build-script: pnpm run build:win
ytdlp-asset: yt-dlp.exe
ytdlp-output: yt-dlp.exe
- os: ubuntu-latest
build-script: pnpm run build:linux
ytdlp-asset: yt-dlp
ytdlp-output: yt-dlp_linux
runs-on: ${{ matrix.os }}
steps: steps:
- name: Check out Git repository - name: Check out Git repository
@@ -25,13 +37,16 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install
- name: Download yt-dlp binaries - name: Download yt-dlp binary
shell: bash
run: | run: |
# Download yt-dlp.exe for Windows curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp-asset }}" -o "resources/${{ matrix.ytdlp-output }}"
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe if [[ "${{ runner.os }}" == "Linux" ]]; then
chmod +x "resources/${{ matrix.ytdlp-output }}"
fi
- name: Lint and format check - name: Lint and format check
run: pnpm run check run: pnpm run check
- name: build-win - name: Build application
run: pnpm run build:win run: ${{ matrix.build-script }}

View File

@@ -11,7 +11,19 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [windows-latest, macos-latest] include:
- os: windows-latest
build_script: pnpm run build:win
ytdlp_asset: yt-dlp.exe
ytdlp_output: yt-dlp.exe
- os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
- os: ubuntu-latest
build_script: pnpm run build:linux
ytdlp_asset: yt-dlp
ytdlp_output: yt-dlp_linux
steps: steps:
- name: Check out Git repository - name: Check out Git repository
@@ -30,33 +42,19 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: pnpm install run: pnpm install
- name: Download yt-dlp binaries - name: Download yt-dlp binary
shell: bash
run: | run: |
# Download yt-dlp.exe for Windows curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe if [[ "${{ runner.os }}" == "Linux" ]]; then
chmod +x "resources/${{ matrix.ytdlp_output }}"
# Download yt-dlp for macOS (for cross-platform builds) fi
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
chmod +x resources/yt-dlp_macos
# Download yt-dlp for Linux (for cross-platform builds)
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
# chmod +x resources/yt-dlp_linux
- name: Lint and format check - name: Lint and format check
run: pnpm run check run: pnpm run check
# - name: build-linux - name: Build application
# if: matrix.os == 'ubuntu-latest' run: ${{ matrix.build_script }}
# run: pnpm run build:linux
- name: build-mac
if: matrix.os == 'macos-latest'
run: pnpm run build:mac
- name: build-win
if: matrix.os == 'windows-latest'
run: pnpm run build:win
- name: release - name: release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1

View File

@@ -21,14 +21,17 @@ nsis:
mac: mac:
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
notarize: false notarize: false
dmg: artifactName: ${name}-${version}-${arch}.${ext}
artifactName: ${name}-${version}.${ext} target:
- target: dmg
arch:
- arm64
- x64
linux: linux:
target: target:
- AppImage - AppImage
- snap
- deb - deb
maintainer: yourname@example.com maintainer: nexmoex@gmail.com
category: Utility category: Utility
appImage: appImage:
artifactName: ${name}-${version}.${ext} artifactName: ${name}-${version}.${ext}

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.2",
"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",
@@ -16,7 +16,7 @@
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir", "build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win", "build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac", "build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux", "build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp" "release": "pnpm run check && bumpp"
}, },

View File

@@ -1,6 +1,6 @@
import { join } from 'node:path' import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, type BrowserWindowConstructorOptions, shell } from 'electron'
import log from 'electron-log/main' import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset' import appIcon from '../../build/icon.png?asset'
@@ -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()
@@ -21,17 +22,16 @@ let mainWindow: BrowserWindow | null = null
let isQuitting = false let isQuitting = false
export function createWindow(): void { export function createWindow(): void {
// Create the browser window const isMac = process.platform === 'darwin'
mainWindow = new BrowserWindow({ const isWindows = process.platform === 'win32'
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200, width: 1200,
height: 800, height: 800,
show: false, show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS
autoHideMenuBar: true, autoHideMenuBar: true,
icon: appIcon, // Set application icon icon: appIcon, // Set application icon
frame: false, frame: false,
vibrancy: 'fullscreen-ui', // on MacOS
backgroundMaterial: 'acrylic', // on Windows 11
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.js'),
sandbox: false, sandbox: false,
@@ -39,7 +39,20 @@ export function createWindow(): void {
nodeIntegration: false, nodeIntegration: false,
webSecurity: false // Allow drag regions to work webSecurity: false // Allow drag regions to work
} }
}) }
if (isMac) {
windowOptions.titleBarStyle = 'hidden'
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
windowOptions.vibrancy = 'fullscreen-ui'
}
if (isWindows) {
windowOptions.backgroundMaterial = 'acrylic'
}
// Create the browser window
mainWindow = new BrowserWindow(windowOptions)
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
const closeToTray = settingsManager.get('closeToTray') const closeToTray = settingsManager.get('closeToTray')
@@ -94,12 +107,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 +121,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 +154,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 +188,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

@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process' import { execFile, execSync } from 'node:child_process'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
@@ -40,7 +40,20 @@ class FileSystemService extends IpcService {
@IpcMethod() @IpcMethod()
getDefaultDownloadPath(_context: IpcContext): string { getDefaultDownloadPath(_context: IpcContext): string {
return `${os.homedir()}/Downloads` const fallbackPath = path.join(os.homedir(), 'Downloads')
if (process.platform === 'linux' || process.platform === 'freebsd') {
try {
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
if (xdgPath) {
return xdgPath
}
} catch (error) {
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
}
}
return fallbackPath
} }
@IpcMethod() @IpcMethod()

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

@@ -2,19 +2,27 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar' import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner' import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar' import { TitleBar } from '@renderer/components/ui/title-bar'
import { useAtom } from 'jotai'
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ipcServices } from './lib/ipc' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About' import { About } from './pages/About'
import { Home } from './pages/Home' import { Home } from './pages/Home'
import { Settings } from './pages/Settings' import { Settings } from './pages/Settings'
import { SupportedSites } from './pages/SupportedSites' import { SupportedSites } from './pages/SupportedSites'
import { settingsAtom } from './store/settings'
type Page = 'home' | 'settings' | 'about' | 'sites' type Page = 'home' | 'settings' | 'about' | 'sites'
function AppContent() { function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home') const [currentPage, setCurrentPage] = useState<Page>('home')
const [platform, setPlatform] = useState<string>('') const [platform, setPlatform] = useState<string>('')
const [settings] = useAtom(settingsAtom)
const { t } = useTranslation()
const autoUpdateEnabled = settings.autoUpdate
const updateDownloadInProgressRef = useRef(false)
useEffect(() => { useEffect(() => {
// Get platform info to determine if we should show title bar // Get platform info to determine if we should show title bar
@@ -31,6 +39,130 @@ function AppContent() {
getPlatform() getPlatform()
}, []) }, [])
useEffect(() => {
if (!window?.api) {
return
}
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
const startUpdateDownload = async () => {
if (updateDownloadInProgressRef.current) {
return
}
updateDownloadInProgressRef.current = true
toast.info(t('about.notifications.downloadStarted'))
try {
const result = await ipcServices.update.downloadUpdate()
if (!result.success) {
throw new Error(result.error ?? 'Unknown error')
}
} catch (error) {
updateDownloadInProgressRef.current = false
const message =
error instanceof Error && error.message
? error.message
: t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: message }))
}
}
const handleUpdateAvailable = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
const versionLabel = info.version ?? ''
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
if (autoUpdateEnabled) {
void startUpdateDownload()
} else {
toast(t('about.notifications.downloadUpdate', { version: versionLabel }), {
action: {
label: t('about.notifications.manualDownloadAction'),
onClick: () => {
void startUpdateDownload()
}
}
})
}
}
const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState()
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.success(downloadedMessage)
showRestartPrompt()
}
const handleUpdateError = (rawMessage: unknown) => {
const message = typeof rawMessage === 'string' ? rawMessage : ''
resetDownloadState()
const errorMessage = message || t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: errorMessage }))
}
const handleDownloadProgress = (rawProgress: unknown) => {
const progress = (rawProgress ?? {}) as { percent?: number }
if (typeof progress?.percent === 'number') {
console.info('Update download progress:', progress.percent.toFixed(2))
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
const versionLabel = payload.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
ipcEvents.on('update:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [autoUpdateEnabled, t])
const renderPage = () => { const renderPage = () => {
switch (currentPage) { switch (currentPage) {
case 'home': case 'home':

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

@@ -27,11 +27,15 @@
"downloadError": "Failed to download update", "downloadError": "Failed to download update",
"downloadStarted": "Download started...", "downloadStarted": "Download started...",
"downloadUpdate": "Download and install update {{version}}?", "downloadUpdate": "Download and install update {{version}}?",
"manualDownloadAction": "Download now",
"noUpdatesAvailable": "You're using the latest version", "noUpdatesAvailable": "You're using the latest version",
"restartToUpdate": "Restart now to install update?", "restartToUpdate": "Restart now to install update?",
"restartNowAction": "Restart now",
"updateAvailable": "Update available: {{version}}", "updateAvailable": "Update available: {{version}}",
"updateDownloaded": "Update downloaded, restart to install", "updateDownloaded": "Update downloaded, restart to install",
"updateError": "Failed to check for updates: {{error}}" "updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
"updateError": "Failed to check for updates: {{error}}",
"unknownErrorFallback": "Unknown error"
}, },
"preferencesDescription": "Tune update settings without leaving this page.", "preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles", "preferencesTitle": "Quick Toggles",
@@ -60,7 +64,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 +230,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 +246,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 +301,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
} }