Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a28b0db85 | ||
|
|
ca4b851ba4 | ||
|
|
0742dac057 | ||
|
|
9d2e35c322 | ||
|
|
526bd994d8 | ||
|
|
37c1f22aee | ||
|
|
10a97b1f4a | ||
|
|
11eaaf0f88 | ||
|
|
512b9d1175 | ||
|
|
a1307bdd0b |
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -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 }}
|
||||||
|
|||||||
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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':
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
|
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
|
||||||
"shareTitle": "口コミを広める",
|
"shareTitle": "口コミを広める",
|
||||||
"sourceCode": "ソースコードが利用可能",
|
"sourceCode": "ソースコードが利用可能",
|
||||||
"tagline": "すべてのクリエイターのためのAIフレンドリーなダウンロードアシスタント",
|
|
||||||
"title": "について",
|
"title": "について",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"versionLabel": "v{{version}}",
|
"versionLabel": "v{{version}}",
|
||||||
|
|||||||
@@ -60,7 +60,6 @@
|
|||||||
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
|
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
|
||||||
"shareTitle": "소문을 퍼뜨리세요",
|
"shareTitle": "소문을 퍼뜨리세요",
|
||||||
"sourceCode": "소스 코드 사용 가능",
|
"sourceCode": "소스 코드 사용 가능",
|
||||||
"tagline": "모든 크리에이터를 위한 AI 친화적 다운로드 도우미",
|
|
||||||
"title": "정보",
|
"title": "정보",
|
||||||
"version": "버전",
|
"version": "버전",
|
||||||
"versionLabel": "v{{version}}",
|
"versionLabel": "v{{version}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -52,7 +52,6 @@
|
|||||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||||
"resourcesTitle": "资源",
|
"resourcesTitle": "资源",
|
||||||
"sourceCode": "源代码已开放",
|
"sourceCode": "源代码已开放",
|
||||||
"tagline": "面向每位创作者的 AI 友好下载助手",
|
|
||||||
"title": "关于",
|
"title": "关于",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"versionLabel": "v{{version}}",
|
"versionLabel": "v{{version}}",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user