chore: add initial project configuration and structure

This commit is contained in:
Nexmoe
2025-10-23 21:31:54 +08:00
parent a014349de3
commit ac4b4a8e6f
102 changed files with 15832 additions and 2 deletions

10
.editorconfig Normal file
View File

@@ -0,0 +1,10 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

6
.gitattributes vendored
View File

@@ -1,2 +1,4 @@
# Auto detect text files and perform LF normalization
* text=auto
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf

37
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- name: Lint and format check
run: pnpm run check
- name: Build check
run: pnpm run build

91
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: Build and Release Electron App
on:
push:
tags:
- v*.*.*
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Type check
run: pnpm run typecheck
- name: Lint and format check
run: pnpm run check
- name: Build Electron app
run: |
if [ "${{ matrix.os }}" == "ubuntu-latest" ]; then
pnpm run build:linux
elif [ "${{ matrix.os }}" == "macos-latest" ]; then
pnpm run build:mac
elif [ "${{ matrix.os }}" == "windows-latest" ]; then
pnpm run build:win
fi
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
- name: Create Release
uses: softprops/action-gh-release@v2
with:
draft: false
prerelease: false
generate_release_notes: true
files: |
dist-ubuntu-latest/*.AppImage
dist-ubuntu-latest/*.snap
dist-ubuntu-latest/*.deb
dist-ubuntu-latest/*.rpm
dist-ubuntu-latest/*.tar.gz
dist-ubuntu-latest/*.yml
dist-ubuntu-latest/*.blockmap
dist-macos-latest/*.dmg
dist-macos-latest/*.zip
dist-macos-latest/*.yml
dist-macos-latest/*.blockmap
dist-windows-latest/*.exe
dist-windows-latest/*.zip
dist-windows-latest/*.yml
dist-windows-latest/*.blockmap

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*

4
.npmrc Normal file
View File

@@ -0,0 +1,4 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"]
}

29
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
"i18n-ally.keystyle": "nested"
}

5
AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
1. use pnpm instead of npm
2. use pnpm run check after tasks to check code
3. Support i18n, only translate the English version of en.json
4. use English for comments&console
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles

49
biome.json Normal file
View File

@@ -0,0 +1,49 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "warn"
},
"suspicious": {
"noExplicitAny": "warn"
},
"style": {
"useConst": "error"
},
"complexity": {
"noForEach": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParentheses": "always",
"quoteProperties": "asNeeded"
}
},
"files": {
"ignoreUnknown": false,
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
},
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
}
}

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/renderer/src/assets/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@renderer/components",
"utils": "@renderer/lib/utils",
"ui": "@renderer/components/ui",
"lib": "@renderer/lib",
"hooks": "@renderer/hooks"
},
"iconLibrary": "lucide"
}

3
dev-app-update.yml Normal file
View File

@@ -0,0 +1,3 @@
provider: generic
url: https://github.com/nexmoe/vidbee/releases/latest/download
updaterCacheDirName: vidbee-updater

40
electron-builder.yml Normal file
View File

@@ -0,0 +1,40 @@
appId: com.vidbee
productName: VidBee
directories:
buildResources: build
files:
- '!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
- resources/**
win:
executableName: vidbee
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- snap
- deb
maintainer: yourname@example.com
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}
npmRebuild: false
publish:
provider: generic
url: https://github.com/nexmoe/vidbee/releases/latest/download
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/

40
electron.vite.config.ts Normal file
View File

@@ -0,0 +1,40 @@
import { resolve } from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import Icons from 'unplugin-icons/vite'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@main': resolve('src/main'),
'@shared': resolve('src/shared')
}
},
assetsInclude: ['**/*.png', '**/*.ico', '**/*.icns'],
publicDir: 'build'
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
base: './',
resolve: {
alias: {
'@main': resolve('src/main'),
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('src/shared')
}
},
plugins: [
react(),
Icons({
compiler: 'jsx',
jsx: 'react'
}),
tailwindcss()
]
}
})

12
entitlements.mac.plist Normal file
View File

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

BIN
icon.icns Normal file

Binary file not shown.

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

87
package.json Normal file
View File

@@ -0,0 +1,87 @@
{
"name": "vidbee",
"version": "0.0.1",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
"homepage": "https://github.com/nexmoe/vidbee",
"scripts": {
"check": "biome check --write . && pnpm run typecheck",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "pnpm run build && electron-builder --win",
"build:mac": "pnpm run build && electron-builder --mac",
"build:linux": "pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@svgr/core": "^8.1.0",
"@svgr/plugin-jsx": "^8.1.0",
"@tailwindcss/vite": "^4.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.18",
"electron-ipc-decorator": "^0.2.0",
"electron-log": "^5.4.3",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"i18next": "^25.5.3",
"jotai": "^2.15.0",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react-hook-form": "^7.63.0",
"react-i18next": "^16.0.0",
"react-router": "^7.9.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"yt-dlp-wrap-plus": "^2.3.20",
"zod": "^4.1.11"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@electron-toolkit/tsconfig": "^2.0.0",
"@iconify/json": "^2.2.394",
"@types/node": "^22.18.6",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"bumpp": "^10.3.1",
"electron": "^38.1.2",
"electron-builder": "^25.1.8",
"electron-vite": "^4.0.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript": "^5.9.2",
"unplugin-icons": "^22.4.2",
"vite": "^7.1.6"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
}
}

6621
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
resources/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
# Ignore yt-dlp binaries (too large for git)
yt-dlp.exe
yt-dlp_macos
yt-dlp_linux
# But keep the README
!README.md
!.gitignore

55
resources/README.md Normal file
View File

@@ -0,0 +1,55 @@
# Resources Directory
This directory contains bundled resources for the application.
## yt-dlp Binaries
To bundle yt-dlp with the application, place the appropriate binaries in this directory:
### Required Files
1. **Windows**: `yt-dlp.exe`
2. **macOS**: `yt-dlp_macos`
3. **Linux**: `yt-dlp_linux`
### How to Download
You can download the latest yt-dlp binaries from the official GitHub releases:
**Option 1: Manual Download**
- Visit: <https://github.com/yt-dlp/yt-dlp/releases/latest>
- Download the appropriate version for each platform:
- Windows: `yt-dlp.exe`
- macOS: `yt-dlp_macos`
- Linux: `yt-dlp` (rename to `yt-dlp_linux`)
**Option 2: Using curl/wget (Linux/macOS)**
```bash
# For Windows binary
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
# For macOS binary
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
# For Linux binary
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
```
**Option 3: Using PowerShell (Windows)**
```powershell
# Download all three binaries
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile "resources/yt-dlp.exe"
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" -OutFile "resources/yt-dlp_macos"
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
```
### Note
- If you don't place binaries here, the app will attempt to download them at runtime
- The app will automatically use the bundled version if available
- File sizes: ~10-15 MB per binary

BIN
resources/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

36
src/main/assets.d.ts vendored Normal file
View File

@@ -0,0 +1,36 @@
/// <reference types="electron-vite/node" />
declare module '*.png?asset' {
const value: string
export default value
}
declare module '*.ico?asset' {
const value: string
export default value
}
declare module '*.icns?asset' {
const value: string
export default value
}
declare module '*.jpg?asset' {
const value: string
export default value
}
declare module '*.jpeg?asset' {
const value: string
export default value
}
declare module '*.gif?asset' {
const value: string
export default value
}
declare module '*.svg?asset' {
const value: string
export default value
}

1091
src/main/download-engine.ts Normal file

File diff suppressed because it is too large Load Diff

230
src/main/index.ts Normal file
View File

@@ -0,0 +1,230 @@
import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import log from 'electron-log'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { downloadEngine } from './download-engine'
import { services } from './ipc'
import { ytdlpManager } from './lib/ytdlp-manager'
import { settingsManager } from './settings'
import { createTray, destroyTray } from './tray'
let mainWindow: BrowserWindow | null = null
let isQuitting = false
export function createWindow(): void {
// Create the browser window
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS
autoHideMenuBar: true,
icon: appIcon, // Set application icon
frame: false,
vibrancy: 'fullscreen-ui', // on MacOS
backgroundMaterial: 'acrylic', // on Windows 11
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
webSecurity: false // Allow drag regions to work
}
})
mainWindow.on('close', (event) => {
const closeToTray = settingsManager.get('closeToTray')
if (closeToTray && !isQuitting) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
// Setup download engine event forwarding to renderer
setupDownloadEvents()
}
function setupDownloadEvents(): void {
downloadEngine.on('download-started', (id: string) => {
mainWindow?.webContents.send('download:started', id)
})
downloadEngine.on('download-progress', (id: string, progress: unknown) => {
mainWindow?.webContents.send('download:progress', { id, progress })
})
downloadEngine.on('download-completed', (id: string) => {
mainWindow?.webContents.send('download:completed', id)
})
downloadEngine.on('download-error', (id: string, error: Error) => {
mainWindow?.webContents.send('download:error', { id, error: error.message })
})
downloadEngine.on('download-cancelled', (id: string) => {
mainWindow?.webContents.send('download:cancelled', id)
})
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
// Set app user model id for windows
electronApp.setAppUserModelId('com.vidbee')
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC services are automatically registered by electron-ipc-decorator when imported
console.log('IPC services available:', Object.keys(services))
// Initialize yt-dlp
try {
console.log('Initializing yt-dlp...')
await ytdlpManager.initialize()
console.log('yt-dlp initialized successfully')
} catch (error) {
console.error('Failed to initialize yt-dlp:', error)
}
// Initialize auto-updater (only in production)
if (process.env.NODE_ENV === 'production') {
try {
console.log('Initializing auto-updater...')
// Configure logging
log.transports.file.level = 'info'
autoUpdater.logger = log
// Configure auto-updater for production
autoUpdater.autoDownload = true // Auto download updates
autoUpdater.autoInstallOnAppQuit = true // Auto install on quit
// Set up event listeners
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:available', info)
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
// Send error to renderer
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
// Send progress to renderer
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
// Send notification to renderer
mainWindow?.webContents.send('update:downloaded', info)
// Show system notification
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
})
}
})
// Check for updates on startup if auto-update is enabled
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
// Use checkForUpdatesAndNotify for automatic notifications
await autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
} else {
console.log('Skipping auto-updater initialization in development mode')
}
createWindow()
// Create system tray
createTray()
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('before-quit', () => {
isQuitting = true
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
const closeToTray = settingsManager.get('closeToTray')
if (process.platform !== 'darwin') {
if (closeToTray) {
// Hide to tray instead of quitting
const mainWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
if (mainWindow) {
mainWindow.hide()
}
} else {
app.quit()
}
}
})
// Cleanup tray on quit
app.on('will-quit', () => {
destroyTray()
})
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

24
src/main/ipc/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
import { AppService } from './services/app-service'
import { DownloadService } from './services/download-service'
import { FileSystemService } from './services/file-system-service'
import { HistoryService } from './services/history-service'
import { SettingsService } from './services/settings-service'
import { ThumbnailService } from './services/thumbnail-service'
import { UpdateService } from './services/update-service'
import { WindowService } from './services/window-service'
// Create services with automatic type inference
export const services = createServices([
AppService,
DownloadService,
FileSystemService,
HistoryService,
SettingsService,
ThumbnailService,
UpdateService,
WindowService
])
// Generate type definition for all services
export type IpcServices = MergeIpcService<typeof services>

View File

@@ -0,0 +1,37 @@
import os from 'node:os'
import { app, BrowserWindow, dialog } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
class AppService extends IpcService {
static readonly groupName = 'app'
@IpcMethod()
getVersion(_context: IpcContext): string {
return app.getVersion()
}
@IpcMethod()
getPlatform(_context: IpcContext): string {
return os.platform()
}
@IpcMethod()
quit(_context: IpcContext): void {
app.quit()
}
@IpcMethod()
async showMessageBox(
_context: IpcContext,
options: Electron.MessageBoxOptions
): Promise<Electron.MessageBoxReturnValue> {
const window = BrowserWindow.getFocusedWindow()
if (window) {
return dialog.showMessageBox(window, options)
}
return dialog.showMessageBox(options)
}
}
export { AppService }

View File

@@ -0,0 +1,53 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type {
DownloadItem,
DownloadOptions,
PlaylistDownloadOptions,
PlaylistInfo,
VideoInfo
} from '../../../shared/types'
import { downloadEngine } from '../../download-engine'
class DownloadService extends IpcService {
static readonly groupName = 'download'
@IpcMethod()
async getVideoInfo(_context: IpcContext, url: string): Promise<VideoInfo> {
return downloadEngine.getVideoInfo(url)
}
@IpcMethod()
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
return downloadEngine.getPlaylistInfo(url)
}
@IpcMethod()
startDownload(_context: IpcContext, id: string, options: DownloadOptions): void {
downloadEngine.startDownload(id, options)
}
@IpcMethod()
cancelDownload(_context: IpcContext, id: string): boolean {
return downloadEngine.cancelDownload(id)
}
@IpcMethod()
getQueueStatus(_context: IpcContext) {
return downloadEngine.getQueueStatus()
}
@IpcMethod()
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
downloadEngine.updateDownloadInfo(id, updates)
}
@IpcMethod()
async startPlaylistDownload(
_context: IpcContext,
options: PlaylistDownloadOptions
): Promise<string[]> {
return downloadEngine.startPlaylistDownload(options)
}
}
export { DownloadService }

View File

@@ -0,0 +1,176 @@
import type { Dirent } from 'node:fs'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
class FileSystemService extends IpcService {
static readonly groupName = 'fs'
@IpcMethod()
async selectDirectory(_context: IpcContext): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openDirectory']
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
}
@IpcMethod()
async selectFile(_context: IpcContext): Promise<string | null> {
const result = await dialog.showOpenDialog({
properties: ['openFile']
})
if (result.canceled || result.filePaths.length === 0) {
return null
}
return result.filePaths[0]
}
@IpcMethod()
getDefaultDownloadPath(_context: IpcContext): string {
return `${os.homedir()}/Downloads`
}
@IpcMethod()
async openFileLocation(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
if (stats?.isFile()) {
shell.showItemInFolder(normalizedPath)
return true
}
if (stats?.isDirectory()) {
const candidate = await this.findLikelyFile(normalizedPath, normalizedPath)
if (candidate) {
shell.showItemInFolder(candidate)
return true
}
const result = await shell.openPath(normalizedPath)
if (result) {
console.error('Failed to open directory:', result)
return false
}
return true
}
const fallbackDirectory = path.dirname(normalizedPath)
const fallbackCandidate = await this.findLikelyFile(fallbackDirectory, normalizedPath)
if (fallbackCandidate) {
shell.showItemInFolder(fallbackCandidate)
return true
}
const result = await shell.openPath(fallbackDirectory)
if (!result) {
return true
}
console.error('Failed to open directory:', result)
} catch (error) {
try {
const directory = path.dirname(path.normalize(this.sanitizePath(filePath)))
const dirStats = await fs.stat(directory)
if (dirStats.isDirectory()) {
const fallbackCandidate = await this.findLikelyFile(directory, directory)
if (fallbackCandidate) {
shell.showItemInFolder(fallbackCandidate)
return true
}
const result = await shell.openPath(directory)
if (result) {
console.error('Failed to open directory:', result)
return false
}
return true
}
} catch (dirError) {
console.error('Failed to open parent directory:', dirError)
}
console.error('Failed to open file location:', error)
return false
}
return false
}
private sanitizePath(target: string): string {
return target.trim().replace(/^['"]|['"]$/g, '')
}
private async findLikelyFile(directory: string, expectedPath: string): Promise<string | null> {
try {
const dirStats = await fs.stat(directory)
if (!dirStats.isDirectory()) {
return null
}
const entries = await fs.readdir(directory, { withFileTypes: true })
const files = entries.filter((entry: Dirent) => entry.isFile())
if (files.length === 0) {
return null
}
const expectedBase = path.basename(expectedPath).toLowerCase()
const expectedName = path.parse(expectedPath).name.toLowerCase()
const exactMatch = files.find((entry) => entry.name.toLowerCase() === expectedBase)
if (exactMatch) {
return path.join(directory, exactMatch.name)
}
if (expectedName) {
const partialMatch = files.find((entry) => entry.name.toLowerCase().includes(expectedName))
if (partialMatch) {
return path.join(directory, partialMatch.name)
}
}
let latestMatch: { filePath: string; mtimeMs: number } | null = null
for (const entry of files) {
const candidatePath = path.join(directory, entry.name)
try {
const candidateStats = await fs.stat(candidatePath)
if (!latestMatch || candidateStats.mtimeMs > latestMatch.mtimeMs) {
latestMatch = { filePath: candidatePath, mtimeMs: candidateStats.mtimeMs }
}
} catch (statError) {
console.error('Failed to stat candidate file:', statError)
}
}
return latestMatch?.filePath ?? null
} catch (error) {
console.error('Failed to search for matching file:', error)
return null
}
}
@IpcMethod()
async openExternal(_context: IpcContext, url: string): Promise<boolean> {
try {
await shell.openExternal(url)
return true
} catch (error) {
console.error('Failed to open external URL:', error)
return false
}
}
}
export { FileSystemService }

View File

@@ -0,0 +1,315 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { DownloadHistoryItem } from '../../../shared/types'
import { historyManager } from '../../lib/history-manager'
import { settingsManager } from '../../settings'
class HistoryService extends IpcService {
static readonly groupName = 'history'
@IpcMethod()
getHistory(_context: IpcContext): DownloadHistoryItem[] {
return historyManager.getHistory()
}
@IpcMethod()
getHistoryById(_context: IpcContext, id: string): DownloadHistoryItem | undefined {
return historyManager.getHistoryById(id)
}
@IpcMethod()
addHistoryItem(_context: IpcContext, item: DownloadHistoryItem): void {
historyManager.addHistoryItem(item)
}
@IpcMethod()
async removeHistoryItem(_context: IpcContext, id: string, outputPath?: string): Promise<boolean> {
const record = historyManager.getHistoryById(id)
await this.deleteOutputResource(record, outputPath)
return historyManager.removeHistoryItem(id)
}
private async deleteOutputResource(
record: DownloadHistoryItem | undefined,
fallbackOutputPath?: string
): Promise<void> {
try {
const candidatePaths = new Set<string>()
if (record?.outputPath) {
candidatePaths.add(record.outputPath)
}
if (fallbackOutputPath) {
candidatePaths.add(fallbackOutputPath)
}
for (const candidate of candidatePaths) {
if (await this.tryDeletePath(candidate)) {
return
}
}
const directories = this.collectCandidateDirectories(candidatePaths)
if (directories.length === 0) {
const defaultDownloadPath = settingsManager.get('downloadPath')
if (defaultDownloadPath) {
directories.push(defaultDownloadPath)
}
}
const matchKeys = this.collectMatchKeys(record, candidatePaths)
if (matchKeys.length === 0) {
return
}
const extensions = this.collectExtensions(candidatePaths, record)
for (const directory of directories) {
const matchedPath = await this.findMatchingFile(directory, matchKeys, extensions, record)
if (matchedPath && (await this.tryDeletePath(matchedPath))) {
return
}
}
} catch (error) {
console.error('Failed to delete output resource:', error)
}
}
private sanitizePath(target: string): string {
return target.trim().replace(/^['"]|['"]$/g, '')
}
private normalizeMatchString(value?: string): string {
if (!value) {
return ''
}
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '')
}
private collectCandidateDirectories(candidatePaths: Set<string>): string[] {
const directories = new Set<string>()
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
const normalized = path.normalize(sanitized)
if (!path.isAbsolute(normalized)) continue
const directory = path.dirname(normalized)
if (directory) {
directories.add(directory)
}
}
return Array.from(directories)
}
private collectExtensions(
candidatePaths: Set<string>,
record: DownloadHistoryItem | undefined
): Set<string> {
const extensions = new Set<string>()
const addExtension = (ext?: string) => {
if (!ext) {
return
}
const trimmed = ext.trim()
if (!trimmed) {
return
}
const normalized = trimmed.startsWith('.')
? trimmed.toLowerCase()
: `.${trimmed.toLowerCase()}`
extensions.add(normalized)
}
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
addExtension(path.extname(sanitized))
}
addExtension(record?.outputPath ? path.extname(record.outputPath) : undefined)
addExtension(record?.selectedFormat?.ext)
addExtension(record?.selectedFormat?.video_ext)
addExtension(record?.selectedFormat?.audio_ext)
if (extensions.size === 0) {
const fallback =
record?.type === 'audio'
? ['.mp3', '.m4a', '.aac', '.ogg', '.opus', '.flac', '.wav']
: ['.mp4', '.mkv', '.webm', '.mov', '.avi']
for (const ext of fallback) {
extensions.add(ext)
}
}
return extensions
}
private collectMatchKeys(
record: DownloadHistoryItem | undefined,
candidatePaths: Set<string>
): string[] {
const keys = new Set<string>()
const fallbackKeys: string[] = []
const tryAddKey = (value?: string) => {
if (!value) return
const normalized = this.normalizeMatchString(value)
if (!normalized) return
if (normalized.length >= 3) {
keys.add(normalized)
} else {
fallbackKeys.push(normalized)
}
}
for (const candidate of candidatePaths) {
const sanitized = this.sanitizePath(candidate)
if (!sanitized) continue
const baseName = path.parse(sanitized).name
tryAddKey(baseName)
}
tryAddKey(record?.title)
tryAddKey(record?.id)
if (keys.size === 0) {
for (const key of fallbackKeys) {
keys.add(key)
}
}
return Array.from(keys)
}
private async findMatchingFile(
directory: string,
matchKeys: string[],
extensions: Set<string>,
record: DownloadHistoryItem | undefined
): Promise<string | null> {
try {
const dirStats = await fs.stat(directory).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code === 'ENOENT') {
return null
}
throw error
})
if (!dirStats || !dirStats.isDirectory()) {
return null
}
const entries = await fs.readdir(directory, { withFileTypes: true })
const matches: Array<{ path: string; diff: number }> = []
const targetTimestamp = record?.completedAt ?? record?.downloadedAt
const maxDiff = 10 * 60 * 1000 // 10 minutes
for (const entry of entries) {
if (!entry.isFile()) continue
const entryPath = path.join(directory, entry.name)
const entryExt = path.extname(entry.name).toLowerCase()
if (extensions.size > 0 && entryExt && !extensions.has(entryExt)) {
continue
}
const normalizedName = this.normalizeMatchString(entry.name)
if (!normalizedName && matchKeys.length > 0) continue
const hasMatchKeys = matchKeys.length > 0
const isMatch = hasMatchKeys
? matchKeys.some((key) => key && normalizedName.includes(key))
: true
if (!isMatch) continue
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats) continue
if (targetTimestamp) {
const diff = Math.abs(stats.mtimeMs - targetTimestamp)
if (diff > maxDiff) {
continue
}
matches.push({ path: entryPath, diff })
} else {
if (!hasMatchKeys) {
continue
}
matches.push({ path: entryPath, diff: Number.POSITIVE_INFINITY })
}
}
if (matches.length === 0) {
return null
}
matches.sort((a, b) => a.diff - b.diff)
return matches[0]?.path ?? null
} catch (error) {
console.error('Failed to search for matching file:', error)
return null
}
}
private async tryDeletePath(rawPath: string): Promise<boolean> {
const sanitizedPath = this.sanitizePath(rawPath)
if (!sanitizedPath) return false
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code === 'ENOENT') {
return null
}
throw error
})
if (!stats) {
return false
}
if (stats.isFile()) {
await fs.unlink(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
if (stats.isDirectory()) {
const entries = await fs.readdir(normalizedPath)
if (entries.length === 0) {
await fs.rmdir(normalizedPath).catch((error) => {
const err = error as NodeJS.ErrnoException
if (err?.code !== 'ENOENT') {
throw error
}
})
return true
}
}
return false
}
@IpcMethod()
getHistoryCount(_context: IpcContext): {
active: number
completed: number
error: number
cancelled: number
total: number
} {
return historyManager.getHistoryCount()
}
}
export { HistoryService }

View File

@@ -0,0 +1,43 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import type { AppSettings } from '../../../shared/types'
import { settingsManager } from '../../settings'
import { updateTrayMenu } from '../../tray'
class SettingsService extends IpcService {
static readonly groupName = 'settings'
@IpcMethod()
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
return settingsManager.get(key)
}
@IpcMethod()
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
settingsManager.set(key, value)
if (key === 'language') {
updateTrayMenu()
}
}
@IpcMethod()
getAll(_context: IpcContext): AppSettings {
return settingsManager.getAll()
}
@IpcMethod()
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
settingsManager.setAll(settings)
if (settings.language) {
updateTrayMenu()
}
}
@IpcMethod()
reset(_context: IpcContext): void {
settingsManager.reset()
}
}
export { SettingsService }

View File

@@ -0,0 +1,20 @@
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { thumbnailCache } from '../../lib/thumbnail-cache'
class ThumbnailService extends IpcService {
static readonly groupName = 'thumbnail'
@IpcMethod()
async getThumbnailPath(
_context: IpcContext,
url: string | null | undefined
): Promise<string | null> {
if (!url) {
return null
}
return thumbnailCache.getThumbnailUrl(url)
}
}
export { ThumbnailService }

View File

@@ -0,0 +1,57 @@
import { app } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { autoUpdater } from 'electron-updater'
import { settingsManager } from '../../settings'
class UpdateService extends IpcService {
static readonly groupName = 'update'
@IpcMethod()
async checkForUpdates(
_context: IpcContext
): Promise<{ available: boolean; version?: string; error?: string }> {
try {
// In production, use checkForUpdatesAndNotify for automatic notifications
const result = await autoUpdater.checkForUpdatesAndNotify()
return {
available: result !== null,
version: result?.updateInfo?.version
}
} catch (error) {
return {
available: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
@IpcMethod()
async downloadUpdate(_context: IpcContext): Promise<{ success: boolean; error?: string }> {
try {
await autoUpdater.downloadUpdate()
return { success: true }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
}
@IpcMethod()
quitAndInstall(_context: IpcContext): void {
autoUpdater.quitAndInstall()
}
@IpcMethod()
getCurrentVersion(_context: IpcContext): string {
return app.getVersion()
}
@IpcMethod()
isAutoUpdateEnabled(_context: IpcContext): boolean {
return settingsManager.get('autoUpdate')
}
}
export { UpdateService }

View File

@@ -0,0 +1,42 @@
import { BrowserWindow } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
class WindowService extends IpcService {
static readonly groupName = 'window'
@IpcMethod()
minimize(_context: IpcContext): void {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.minimize()
}
}
@IpcMethod()
maximize(_context: IpcContext): void {
const window = BrowserWindow.getFocusedWindow()
if (window) {
if (window.isMaximized()) {
window.unmaximize()
} else {
window.maximize()
}
}
}
@IpcMethod()
close(_context: IpcContext): void {
const window = BrowserWindow.getFocusedWindow()
if (window) {
window.close()
}
}
@IpcMethod()
isMaximized(_context: IpcContext): boolean {
const window = BrowserWindow.getFocusedWindow()
return window ? window.isMaximized() : false
}
}
export { WindowService }

View File

@@ -0,0 +1,145 @@
import { EventEmitter } from 'node:events'
import type { DownloadItem, DownloadOptions } from '../../shared/types'
interface QueueItem {
id: string
options: DownloadOptions
item: DownloadItem
}
export class DownloadQueue extends EventEmitter {
private queue: QueueItem[] = []
private activeDownloads: Map<string, QueueItem> = new Map()
private completedDownloads: Map<string, QueueItem> = new Map()
private maxConcurrent: number
constructor(maxConcurrent: number = 5) {
super()
this.maxConcurrent = maxConcurrent
}
setMaxConcurrent(max: number): void {
this.maxConcurrent = max
this.processQueue()
}
add(id: string, options: DownloadOptions, item: DownloadItem): void {
this.queue.push({ id, options, item })
this.emit('queue-updated', this.getQueueStatus())
this.processQueue()
}
remove(id: string): boolean {
// Remove from queue
const queueIndex = this.queue.findIndex((item) => item.id === id)
if (queueIndex !== -1) {
this.queue.splice(queueIndex, 1)
this.emit('queue-updated', this.getQueueStatus())
return true
}
// Remove from active downloads
if (this.activeDownloads.has(id)) {
this.activeDownloads.delete(id)
this.emit('queue-updated', this.getQueueStatus())
this.processQueue()
return true
}
return false
}
downloadCompleted(id: string): void {
const activeDownload = this.activeDownloads.get(id)
if (activeDownload) {
this.completedDownloads.set(id, activeDownload)
this.activeDownloads.delete(id)
}
this.emit('queue-updated', this.getQueueStatus())
this.processQueue()
}
private processQueue(): void {
while (this.activeDownloads.size < this.maxConcurrent && this.queue.length > 0) {
const item = this.queue.shift()
if (item) {
this.activeDownloads.set(item.id, item)
this.emit('start-download', item)
}
}
this.emit('queue-updated', this.getQueueStatus())
}
getQueueStatus(): {
queued: number
active: number
activeIds: string[]
} {
return {
queued: this.queue.length,
active: this.activeDownloads.size,
activeIds: Array.from(this.activeDownloads.keys())
}
}
isDownloading(id: string): boolean {
return this.activeDownloads.has(id)
}
getCompletedDownload(id: string): QueueItem | undefined {
return this.completedDownloads.get(id)
}
getItemDetails(id: string): { options: DownloadOptions; item: DownloadItem } | undefined {
const inActive = this.activeDownloads.get(id)
if (inActive) {
return {
options: inActive.options,
item: { ...inActive.item }
}
}
const inQueue = this.queue.find((entry) => entry.id === id)
if (inQueue) {
return {
options: inQueue.options,
item: { ...inQueue.item }
}
}
const completed = this.completedDownloads.get(id)
if (completed) {
return {
options: completed.options,
item: { ...completed.item }
}
}
return undefined
}
updateItemInfo(id: string, updates: Partial<DownloadItem>): void {
// Update in queue
const queueItem = this.queue.find((item) => item.id === id)
if (queueItem) {
Object.assign(queueItem.item, updates)
}
// Update in active downloads
const activeItem = this.activeDownloads.get(id)
if (activeItem) {
Object.assign(activeItem.item, updates)
}
// Update in completed downloads
const completedItem = this.completedDownloads.get(id)
if (completedItem) {
Object.assign(completedItem.item, updates)
}
}
clear(): void {
this.queue = []
this.emit('queue-updated', this.getQueueStatus())
}
}

View File

@@ -0,0 +1,116 @@
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
import type { DownloadHistoryItem } from '../../shared/types'
class HistoryManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
private history: Map<string, DownloadHistoryItem> = new Map()
constructor() {
this.store = new Store({
name: 'download-history',
defaults: {
items: []
}
})
this.loadHistory()
}
private loadHistory(): void {
try {
const historyArray = this.store.get('items', [])
this.history = new Map(historyArray.map((item) => [item.id, item]))
} catch (error) {
console.error('Failed to load download history:', error)
}
}
private saveHistory(): void {
try {
const historyArray = Array.from(this.history.values())
this.store.set('items', historyArray)
} catch (error) {
console.error('Failed to save download history:', error)
}
}
addHistoryItem(item: DownloadHistoryItem): void {
this.history.set(item.id, item)
this.saveHistory()
}
getHistory(): DownloadHistoryItem[] {
return Array.from(this.history.values()).sort((a, b) => {
const aTime = a.completedAt || a.downloadedAt
const bTime = b.completedAt || b.downloadedAt
return bTime - aTime
})
}
getHistoryById(id: string): DownloadHistoryItem | undefined {
return this.history.get(id)
}
removeHistoryItem(id: string): boolean {
const deleted = this.history.delete(id)
if (deleted) {
this.saveHistory()
}
return deleted
}
clearHistory(): void {
this.history.clear()
this.saveHistory()
}
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
let removedCount = 0
for (const [id, item] of this.history.entries()) {
if (item.status === status) {
this.history.delete(id)
removedCount++
}
}
if (removedCount > 0) {
this.saveHistory()
}
return removedCount
}
getHistoryCount(): {
active: number
completed: number
error: number
cancelled: number
total: number
} {
const counts = {
active: 0,
completed: 0,
error: 0,
cancelled: 0,
total: this.history.size
}
for (const item of this.history.values()) {
if (item.status === 'completed') {
counts.completed++
} else if (item.status === 'error') {
counts.error++
} else if (item.status === 'cancelled') {
counts.cancelled++
} else {
counts.active++
}
}
return counts
}
}
export const historyManager = new HistoryManager()

View File

@@ -0,0 +1,137 @@
import crypto from 'node:crypto'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { app } from 'electron'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
const contentTypeToExtension = (contentType?: string): string => {
if (!contentType) return '.jpg'
if (contentType.includes('jpeg')) return '.jpg'
if (contentType.includes('png')) return '.png'
if (contentType.includes('webp')) return '.webp'
if (contentType.includes('gif')) return '.gif'
return '.jpg'
}
export class ThumbnailCache {
private cacheDir?: string
private pending: Map<string, Promise<string | null>> = new Map()
private ensureCacheDir(): string {
if (this.cacheDir) {
return this.cacheDir
}
const dir = path.join(app.getPath('userData'), 'thumbnails')
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
this.cacheDir = dir
return dir
}
async getThumbnailUrl(originalUrl: string): Promise<string | null> {
if (!originalUrl) {
return null
}
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
return originalUrl
}
if (this.pending.has(originalUrl)) {
return this.pending.get(originalUrl) ?? null
}
const task = this.fetchAndCache(originalUrl).finally(() => {
this.pending.delete(originalUrl)
})
this.pending.set(originalUrl, task)
return task
}
private async fetchAndCache(originalUrl: string): Promise<string | null> {
try {
const cacheDir = this.ensureCacheDir()
const { basePath, defaultExtension } = this.getBasePath(cacheDir, originalUrl)
const existingPath = await this.findExistingPath(basePath, defaultExtension)
if (existingPath) {
return pathToFileURL(existingPath).toString()
}
const response = await fetch(originalUrl)
if (!response.ok) {
throw new Error(`Failed to fetch thumbnail (${response.status})`)
}
const arrayBuffer = await response.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const extension =
contentTypeToExtension(response.headers.get('content-type') ?? undefined) ||
defaultExtension
const finalPath = `${basePath}${extension}`
await fsPromises.writeFile(finalPath, buffer)
return pathToFileURL(finalPath).toString()
} catch (error) {
console.error('Failed to cache thumbnail:', error)
return null
}
}
private async findExistingPath(
basePath: string,
defaultExtension: string
): Promise<string | null> {
for (const ext of SUPPORTED_EXTENSIONS) {
const candidate = `${basePath}${ext}`
if (await this.exists(candidate)) {
return candidate
}
}
const fallback = `${basePath}${defaultExtension}`
if (await this.exists(fallback)) {
return fallback
}
return null
}
private async exists(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath)
return true
} catch {
return false
}
}
private getBasePath(
cacheDir: string,
url: string
): {
basePath: string
defaultExtension: string
} {
const hash = crypto.createHash('sha1').update(url).digest('hex')
let extension = '.jpg'
try {
const parsedUrl = new URL(url)
const urlExt = path.extname(parsedUrl.pathname).toLowerCase()
if (SUPPORTED_EXTENSIONS.has(urlExt)) {
extension = urlExt
}
} catch {
// Ignore parsing errors and keep default extension
}
const basePath = path.join(cacheDir, `${hash}`)
return { basePath, defaultExtension: extension }
}
}
export const thumbnailCache = new ThumbnailCache()

View File

@@ -0,0 +1,121 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type YTDlpWrap from 'yt-dlp-wrap-plus'
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
const YTDlpWrapCtor: typeof YTDlpWrap = YTDlpWrapModule.default || YTDlpWrapModule
type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
class YtDlpManager {
private ytdlpPath: string | null = null
private ytdlpInstance: YTDlpWrapInstance | null = null
async initialize(): Promise<void> {
this.ytdlpPath = await this.findOrDownloadYtDlp()
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
console.log('yt-dlp initialized at:', this.ytdlpPath)
}
getInstance(): YTDlpWrapInstance {
if (!this.ytdlpInstance) {
throw new Error('yt-dlp not initialized. Call initialize() first.')
}
return this.ytdlpInstance
}
getPath(): string {
if (!this.ytdlpPath) {
throw new Error('yt-dlp not initialized. Call initialize() first.')
}
return this.ytdlpPath
}
private getResourcesPath(): string {
// In development, read from project root's resources
if (process.env.NODE_ENV === 'development') {
return path.join(process.cwd(), 'resources')
}
// In production, unpacked binaries live under app.asar.unpacked/resources
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
}
private async findOrDownloadYtDlp(): Promise<string> {
const platform = os.platform()
let bundledName: string
// Determine the binary name based on platform
if (platform === 'win32') {
bundledName = 'yt-dlp.exe'
} else if (platform === 'darwin') {
bundledName = 'yt-dlp_macos'
} else {
bundledName = 'yt-dlp_linux'
}
// Check environment variable first
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
return process.env.YTDLP_PATH
}
// Check for bundled yt-dlp in resources directory
const resourcesPath = this.getResourcesPath()
const bundledPath = path.join(resourcesPath, bundledName)
if (fs.existsSync(bundledPath)) {
console.log('Using bundled yt-dlp:', bundledPath)
// Make executable on Unix-like systems if needed
if (platform !== 'win32') {
try {
fs.chmodSync(bundledPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission:', error)
}
}
return bundledPath
}
// Check for system-installed yt-dlp on macOS
if (os.platform() === 'darwin') {
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
console.log('Using system yt-dlp:', p)
return p
}
}
}
// Check for system-installed yt-dlp on Linux/FreeBSD
if (os.platform() === 'linux' || os.platform() === 'freebsd') {
try {
const systemPath = execSync('which yt-dlp').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system yt-dlp:', systemPath)
return systemPath
}
} catch (_error) {
// yt-dlp not in PATH, continue
}
}
// At this point, no bundled/system binary found.
// For Windows, we do NOT download at runtime. Require bundling.
if (platform === 'win32') {
throw new Error(
'yt-dlp not found. Ensure resources/yt-dlp.exe is bundled (asarUnpack) in the build.'
)
}
// For macOS/Linux, instruct user to bundle or install system yt-dlp.
throw new Error(
'yt-dlp not found. Bundle it under resources/ (asarUnpack) or install yt-dlp in system PATH.'
)
}
// Removed runtime download/update to avoid network dependency in production builds
}
export const ytdlpManager = new YtDlpManager()

51
src/main/settings.ts Normal file
View File

@@ -0,0 +1,51 @@
import os from 'node:os'
import path from 'node:path'
import type { AppSettings } from '../shared/types'
import { defaultSettings } from '../shared/types'
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
// Access the default export
const Store = ElectronStore.default || ElectronStore
class SettingsManager {
// biome-ignore lint/suspicious/noExplicitAny: electron-store requires dynamic import
private store: any
constructor() {
this.store = new Store({
defaults: {
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
}
})
}
get<K extends keyof AppSettings>(key: K): AppSettings[K] {
return this.store.get(key)
}
set<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
this.store.set(key, value)
}
getAll(): AppSettings {
return this.store.store
}
setAll(settings: Partial<AppSettings>): void {
for (const [key, value] of Object.entries(settings)) {
this.store.set(key as keyof AppSettings, value as AppSettings[keyof AppSettings])
}
}
reset(): void {
this.store.clear()
this.store.set({
...defaultSettings,
downloadPath: path.join(os.homedir(), 'Downloads')
})
}
}
export const settingsManager = new SettingsManager()

138
src/main/tray.ts Normal file
View File

@@ -0,0 +1,138 @@
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
import appIcon from '../../resources/icon.png?asset'
import { settingsManager } from './settings'
let tray: Tray | null = null
/**
* Get translated text based on current language setting
*/
function t(key: 'showHome' | 'quit'): string {
const language = settingsManager.get('language') || 'en'
const translations = {
en: {
showHome: 'Show Home',
quit: 'Quit'
},
zh: {
showHome: '打开首页',
quit: '关闭应用'
}
}
const displayLang = language.startsWith('en') ? 'en' : 'zh'
return translations[displayLang][key]
}
/**
* Find the main window
*/
function findMainWindow(): BrowserWindow | null {
const windows = BrowserWindow.getAllWindows()
return windows.find((window) => !window.isDestroyed()) || null
}
/**
* Create context menu for tray
*/
function createContextMenu(): Menu {
return Menu.buildFromTemplate([
{
label: t('showHome'),
click: () => {
const mainWindow = findMainWindow()
if (mainWindow) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
}
},
{
type: 'separator'
},
{
label: t('quit'),
click: () => {
// Force close all windows before quitting
const windows = BrowserWindow.getAllWindows()
// Close all windows forcefully
for (const window of windows) {
window.destroy()
}
// Quit the application and exit the process
app.quit()
// Force exit if quit doesn't work
setTimeout(() => {
app.exit(0)
}, 1000)
}
}
])
}
/**
* Create system tray icon
*/
export function createTray(): void {
if (tray) {
return
}
const trayIcon = nativeImage.createFromPath(appIcon)
tray = new Tray(trayIcon)
// Set tooltip
tray.setToolTip('VidBee')
// Set context menu
tray.setContextMenu(createContextMenu())
// On Windows/Linux: click to show/hide main window
tray.on('click', async () => {
const mainWindow = findMainWindow()
if (mainWindow) {
if (mainWindow.isVisible()) {
// If window is visible, hide it
mainWindow.hide()
} else {
// If window is hidden or minimized, show it
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
} else {
// If no main window exists, create a new one
const { createWindow } = await import('./index')
createWindow()
}
})
}
/**
* Update tray menu (call this when language changes)
*/
export function updateTrayMenu(): void {
if (tray) {
tray.setContextMenu(createContextMenu())
}
}
/**
* Destroy tray icon
*/
export function destroyTray(): void {
if (tray) {
tray.destroy()
tray = null
}
}

13
src/preload/index.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import type { ElectronAPI } from '@electron-toolkit/preload'
import type { IpcServices } from '../main/ipc'
declare global {
interface Window {
electron: ElectronAPI
api: IpcServices & {
on: (channel: string, callback: (...args: unknown[]) => void) => void
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
send: (channel: string, ...args: unknown[]) => void
}
}
}

45
src/preload/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'
import { createIpcProxy } from 'electron-ipc-decorator/client'
import type { IpcServices } from '../main/ipc'
// Create type-safe IPC proxy using electron-ipc-decorator
const ipcServices = createIpcProxy<IpcServices>(ipcRenderer)
// Custom APIs for renderer
const api = {
// IPC Services (type-safe, using decorators)
...ipcServices,
// Event listening API
on: (channel: string, callback: (...args: unknown[]) => void) => {
const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) =>
callback(...args)
ipcRenderer.on(channel, subscription)
return subscription
},
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
ipcRenderer.removeListener(channel, callback)
},
// Send message to main process
send: (channel: string, ...args: unknown[]) => {
ipcRenderer.send(channel, ...args)
}
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error('Failed to expose APIs in context bridge:', error)
}
} else {
// @ts-expect-error (define in dts)
window.electron = electronAPI
// @ts-expect-error (define in dts)
window.api = api
}

16
src/renderer/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>VidBee</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

65
src/renderer/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
import { ThemeProvider } from 'next-themes'
import { useState } from 'react'
import { About } from './pages/About'
import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
import { SupportedSites } from './pages/SupportedSites'
type Page = 'home' | 'settings' | 'about' | 'sites'
function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const renderPage = () => {
switch (currentPage) {
case 'home':
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
case 'settings':
return <Settings />
case 'about':
return <About />
case 'sites':
return <SupportedSites />
default:
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
}
}
return (
<div className="flex flex-row h-screen">
{/* Sidebar Navigation */}
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
{/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
{/* Custom Title Bar */}
<TitleBar />
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
style={{ maxWidth: '100%' }}
>
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
{renderPage()}
</div>
</ScrollArea>
</main>
<Toaster />
</div>
)
}
function App() {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<AppContent />
</ThemeProvider>
)
}
export default App

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,12 @@
@import 'tailwindcss';
@import './theme.css';
@layer base {
* {
@apply border-border;
}
body {
@apply text-foreground;
}
}

View File

@@ -0,0 +1,23 @@
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
* {
box-sizing: border-box;
}
#root {
width: 100vw;
height: 100vh;
overflow: hidden;
}

View File

@@ -0,0 +1,160 @@
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1884 0.0128 248.5103);
--card: oklch(0.9881 0 0);
--card-foreground: oklch(0.1884 0.0128 248.5103);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1884 0.0128 248.5103);
--primary: oklch(0.8223 0.1704 79.8747);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.1884 0.0128 248.5103);
--secondary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.9227 0.0011 17.1793);
--muted-foreground: oklch(0.1884 0.0128 248.5103);
--accent: oklch(0.9485 0.0162 64.6689);
--accent-foreground: oklch(0.8223 0.1704 79.8747);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.8929 0.0133 82.4013);
--input: oklch(0.9823 0.0029 84.5589);
--ring: oklch(0.8223 0.1704 79.8747);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.9784 0.0011 197.1387);
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9485 0.0162 64.6689);
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
--sidebar-border: oklch(0.9330 0.0108 76.5962);
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: #1da1f2;
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: oklch(0 0 0);
--foreground: oklch(0.9328 0.0025 228.7857);
--card: oklch(0.2097 0.0080 274.5332);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
--shadow-x: 0px;
--shadow-y: 2px;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-opacity: 0;
--shadow-color: #1da1f2;
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
}

View File

@@ -0,0 +1,10 @@
/* Title bar drag region styles */
.drag-region {
-webkit-app-region: drag;
app-region: drag;
}
.no-drag {
-webkit-app-region: no-drag;
app-region: no-drag;
}

View File

@@ -0,0 +1,418 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Progress } from '@renderer/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useSetAtom } from 'jotai'
import {
AlertCircle,
CheckCircle2,
ExternalLink,
FolderOpen,
Loader2,
Play,
Trash2,
X
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ipcServices } from '../../lib/ipc'
import {
type DownloadRecord,
removeDownloadAtom,
removeHistoryRecordAtom
} from '../../store/downloads'
interface DownloadItemProps {
download: DownloadRecord
}
const formatFileSize = (bytes?: number) => {
if (!bytes) return ''
const sizes = ['B', 'KB', 'MB', 'GB']
const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), sizes.length - 1)
return `${(bytes / 1024 ** order).toFixed(1)} ${sizes[order]}`
}
const formatDuration = (seconds?: number) => {
if (!seconds) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return `${m}:${s.toString().padStart(2, '0')}`
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return ''
return new Date(timestamp).toLocaleString()
}
export function DownloadItem({ download }: DownloadItemProps) {
const { t } = useTranslation()
const removeDownload = useSetAtom(removeDownloadAtom)
const removeHistory = useSetAtom(removeHistoryRecordAtom)
const isHistory = download.entryType === 'history'
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const thumbnailSrc = useCachedThumbnail(download.thumbnail)
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
const actionsContainerClass = showActionsWithoutHover
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
const handleCancel = async () => {
if (isHistory) return
try {
await ipcServices.download.cancelDownload(download.id)
removeDownload(download.id)
} catch (error) {
console.error('Failed to cancel download:', error)
}
}
const handleOpenFileLocation = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFolderFailed'))
return
}
try {
const success = await ipcServices.fs.openFileLocation(download.outputPath)
if (!success) {
toast.error(t('notifications.openFolderFailed'))
}
} catch (error) {
console.error('Failed to open file location:', error)
toast.error(t('notifications.openFolderFailed'))
}
}
const handleOpenFile = async () => {
if (!download.outputPath) {
toast.error(t('notifications.openFileFailed'))
return
}
try {
await ipcServices.fs.openFileLocation(download.outputPath)
} catch (error) {
console.error('Failed to open file:', error)
toast.error(t('notifications.openFileFailed'))
}
}
const handleOpenFolder = async () => {
await handleOpenFileLocation()
}
const handleRemoveHistory = async () => {
if (!isHistory) return
try {
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
removeHistory(download.id)
toast.success(t('notifications.itemRemoved'))
} catch (error) {
console.error('Failed to remove item:', error)
toast.error(t('notifications.removeFailed'))
}
}
const getStatusIcon = () => {
switch (download.status) {
case 'completed':
return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'error':
return <AlertCircle className="h-4 w-4 text-destructive" />
case 'downloading':
case 'processing':
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
case 'pending':
return <Loader2 className="h-4 w-4 text-muted-foreground" />
case 'cancelled':
return <X className="h-4 w-4 text-muted-foreground" />
default:
return null
}
}
const getStatusText = () => {
switch (download.status) {
case 'completed':
return t('download.completed')
case 'error':
return t('download.error')
case 'downloading':
return t('download.downloading')
case 'processing':
return t('download.processing')
case 'pending':
return t('download.downloadPending')
case 'cancelled':
return t('download.cancelled')
default:
return ''
}
}
const statusIcon = getStatusIcon()
const statusText = getStatusText()
return (
<div className="group relative w-full max-w-full overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
{/* Thumbnail */}
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60">
<ImageWithPlaceholder
src={thumbnailSrc}
alt={download.title}
className="w-32 h-18 object-cover aspect-video"
fallbackIcon={<Play className="h-6 w-6" />}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs wrap-break-word">{download.title}</p>
</TooltipContent>
</Tooltip>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="bg-muted/50 capitalize text-[11px] font-medium">
{download.type}
</Badge>
{(statusIcon || statusText) && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{statusIcon}
<span>{statusText}</span>
</div>
)}
</div>
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
{timestamp ? (
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
) : null}
<span className="truncate max-w-[120px] text-left">
<Tooltip>
<TooltipTrigger asChild>
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors cursor-pointer"
>
{download.uploader &&
download.channel &&
download.uploader !== download.channel
? `${download.uploader}${download.channel}`
: download.uploader
? `${download.uploader}`
: download.channel
? `${download.channel}`
: ''}
</a>
</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-word">
<p>{download.url}</p>
</TooltipContent>
</Tooltip>
</span>
{download.duration ? <span>{formatDuration(download.duration)}</span> : null}
{download.selectedFormat ? (
<>
{download.selectedFormat.height ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.height}p
{download.selectedFormat.fps === 60 ? '60' : ''}
</Badge>
) : null}
{download.selectedFormat.ext ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.selectedFormat.ext.toUpperCase()}
</Badge>
) : null}
{download.selectedFormat.filesize || download.selectedFormat.filesize_approx ? (
<span className="text-[10px] opacity-75">
{formatFileSize(
download.selectedFormat.filesize ||
download.selectedFormat.filesize_approx
)}
</span>
) : null}
</>
) : (
<>
{download.quality ? (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5">
{download.quality}
</Badge>
) : null}
{download.format ? (
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5">
{download.format.toUpperCase()}
</Badge>
) : null}
{download.codec ? (
<span className="text-[10px] opacity-75">{download.codec}</span>
) : null}
</>
)}
</div>
</div>
<div className={actionsContainerClass}>
{isHistory ? (
<>
{download.outputPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFolder')}</p>
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleRemoveHistory}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.removeItem')}</p>
</TooltipContent>
</Tooltip>
</>
) : (
<>
{download.status === 'completed' && download.outputPath && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFile}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFile')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleOpenFolder}
>
<FolderOpen className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('history.openFolder')}</p>
</TooltipContent>
</Tooltip>
</>
)}
{(download.status === 'downloading' ||
download.status === 'pending' ||
download.status === 'processing') && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCancel}
>
<X className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
</div>
{/* Progress */}
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
<Progress value={download.progress.percent} className="h-1.5 w-full" />
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
<span className="font-medium shrink-0">
{download.progress.percent.toFixed(1)}%
</span>
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total}
</span>
)}
{download.progress.currentSpeed && (
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
)}
{download.progress.eta && (
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
)}
</div>
</div>
</div>
)}
{/* Error message */}
{download.status === 'error' && download.error && (
<p className="text-xs text-destructive line-clamp-2 w-full overflow-hidden">
{download.error}
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { useAtomValue, useSetAtom } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearCompleted = useSetAtom(clearCompletedAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
useHistorySync()
const filteredRecords = useMemo(() => {
return allRecords.filter((record) => {
switch (statusFilter) {
case 'all':
return true
case 'active':
return (
record.status === 'downloading' ||
record.status === 'processing' ||
record.status === 'pending'
)
case 'completed':
case 'error':
return record.status === statusFilter
default:
return true
}
})
}, [allRecords, statusFilter])
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
{ key: 'all', label: t('download.all'), count: downloadStats.total },
{ key: 'active', label: t('download.active'), count: downloadStats.active },
{ key: 'completed', label: t('download.completed'), count: downloadStats.completed },
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
return (
<Button
key={filter.key}
variant={isActive ? 'secondary' : 'ghost'}
size="sm"
className={
isActive
? 'h-8 rounded-full px-3 shadow-sm'
: 'h-8 rounded-full border border-border/60 px-3'
}
onClick={() => setStatusFilter(filter.key)}
>
<span>{filter.label}</span>
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
</Button>
)
})}
</div>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => (
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,61 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { cn } from '@renderer/lib/utils'
import { ChevronDownIcon } from 'lucide-react'
import type * as React from 'react'
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,36 @@
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,48 @@
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,54 @@
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
{...props}
/>
)
)
Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
)
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
)
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,26 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { cn } from '@renderer/lib/utils'
import { CheckIcon } from 'lucide-react'
import type * as React from 'react'
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,101 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@renderer/lib/utils'
import { X } from 'lucide-react'
import * as React from 'react'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View File

@@ -0,0 +1,225 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@renderer/lib/utils'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import type * as React from 'react'
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent
}

View File

@@ -0,0 +1,67 @@
import { cn } from '@renderer/lib/utils'
import { ImageIcon } from 'lucide-react'
import { useState } from 'react'
interface ImageWithPlaceholderProps {
src?: string
alt: string
className?: string
placeholderClassName?: string
fallbackIcon?: React.ReactNode
onError?: () => void
}
export function ImageWithPlaceholder({
src,
alt,
className,
placeholderClassName,
fallbackIcon,
onError
}: ImageWithPlaceholderProps) {
const [hasError, setHasError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const handleError = () => {
setHasError(true)
setIsLoading(false)
onError?.()
}
const handleLoad = () => {
setIsLoading(false)
}
// Show placeholder if no src, error occurred, or still loading
if (!src || hasError) {
return (
<div
className={cn('flex items-center justify-center bg-muted text-muted-foreground', className)}
>
{fallbackIcon || <ImageIcon className="h-6 w-6" />}
</div>
)
}
return (
<div className={cn('relative', className)}>
{isLoading && (
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-muted text-muted-foreground',
placeholderClassName
)}
>
{fallbackIcon || <ImageIcon className="h-6 w-6" />}
</div>
)}
<img
src={src}
alt={alt}
className={cn('w-full h-full object-cover', isLoading && 'opacity-0')}
onError={handleError}
onLoad={handleLoad}
/>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,182 @@
import { Slot } from '@radix-ui/react-slot'
import { Separator } from '@renderer/components/ui/separator'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import type * as React from 'react'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-group"
className={cn('group/item-group flex flex-col rounded-md overflow-hidden', className)}
{...props}
/>
)
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<div className="px-4 bg-muted/40">
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
</div>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/40'
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5'
},
rounded: {
default: 'rounded-none',
none: 'rounded-none',
top: 'rounded-t-md',
bottom: 'rounded-b-md',
both: 'rounded-md'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
rounded: 'default'
}
}
)
function Item({
className,
variant = 'default',
size = 'default',
rounded = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
data-rounded={rounded}
className={cn(itemVariants({ variant, size, rounded, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover'
}
},
defaultVariants: {
variant: 'default'
}
}
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter
}

View File

@@ -0,0 +1,18 @@
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@renderer/lib/utils'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,25 @@
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,53 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,149 @@
import * as SelectPrimitive from '@radix-ui/react-select'
import { cn } from '@renderer/lib/utils'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import * as React from 'react'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton
}

View File

@@ -0,0 +1,25 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,207 @@
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { saveSettingAtom } from '@renderer/store/settings'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
import MingcuteInformationFill from '~icons/mingcute/information-fill'
import MingcuteInformationLine from '~icons/mingcute/information-line'
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
type Page = 'home' | 'settings' | 'about' | 'sites'
interface NavigationItem {
id: Page
icon: {
active: React.ComponentType<{ className?: string }>
inactive: React.ComponentType<{ className?: string }>
}
label: string
}
interface SidebarProps {
currentPage: Page
onPageChange: (page: Page) => void
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' }
]
const navigationItems: NavigationItem[] = [
{
id: 'home',
icon: {
active: MingcuteDownload3Fill,
inactive: MingcuteDownload3Line
},
label: t('menu.download')
},
{
id: 'sites',
icon: {
active: MingcuteCheckCircleFill,
inactive: MingcuteCheckCircleLine
},
label: t('menu.supportedSites')
}
]
const bottomNavigationItems: NavigationItem[] = [
{
id: 'settings',
icon: {
active: MingcuteSettingsFill,
inactive: MingcuteSettingsLine
},
label: t('menu.preferences')
},
{
id: 'about',
icon: {
active: MingcuteInformationFill,
inactive: MingcuteInformationLine
},
label: t('menu.about')
}
]
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: string) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18n.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
return (
<div key={item.id} className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
{showLabel && (
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
{item.label}
</span>
)}
</div>
)
}
return (
<aside className="w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
{/* App Logo */}
<div className="flex flex-col items-center gap-1 py-4 mb-1">
<div className="w-12 h-12 flex items-center justify-center">
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
</div>
<span className="text-xs text-muted-foreground font-bold text-center leading-tight">
VidBee
</span>
</div>
{/* Navigation Items */}
{navigationItems.map((item) => renderNavigationItem(item))}
<div className="flex-1" />
{/* Language Selector */}
<div className="flex flex-col items-center gap-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t('settings.language')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="end">
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold' : undefined}
>
{option.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bottom Navigation Items */}
{bottomNavigationItems.map((item) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
return (
<div key={item.id} className="flex flex-col items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
</div>
)
})}
</aside>
)
}

View File

@@ -0,0 +1,27 @@
import { useTheme } from 'next-themes'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
}
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,26 @@
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@renderer/lib/utils'
import * as React from 'react'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,53 @@
'use client'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className
)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,17 @@
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,79 @@
import { Button } from '@renderer/components/ui/button'
import { useEffect, useState } from 'react'
import IconFluentDismiss20Regular from '~icons/fluent/dismiss-20-regular'
import IconFluentMaximize20Regular from '~icons/fluent/maximize-20-regular'
import IconFluentSquareMultiple20Regular from '~icons/fluent/square-multiple-20-regular'
import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
import { ipcEvents, ipcServices } from '../../lib/ipc'
import '../../assets/title-bar.css'
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
// 监听窗口最大化状态变化
const handleMaximized = () => {
setIsMaximized(true)
}
const handleUnmaximized = () => {
setIsMaximized(false)
}
ipcEvents.on('window-maximized', handleMaximized)
ipcEvents.on('window-unmaximized', handleUnmaximized)
return () => {
ipcEvents.removeListener('window-maximized', handleMaximized)
ipcEvents.removeListener('window-unmaximized', handleUnmaximized)
}
}, [])
const handleMinimize = () => {
ipcServices.window.minimize()
}
const handleMaximize = () => {
ipcServices.window.maximize()
}
const handleClose = () => {
ipcServices.window.close()
}
return (
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
{/* Window controls */}
<div className="flex items-center gap-1 no-drag">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
onClick={handleMinimize}
>
<IconFluentSubtract20Regular className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-muted"
onClick={handleMaximize}
>
{isMaximized ? (
<IconFluentSquareMultiple20Regular className="h-4 w-4" />
) : (
<IconFluentMaximize20Regular className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-red-500 hover:text-white"
onClick={handleClose}
>
<IconFluentDismiss20Regular className="h-4 w-4" />
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,100 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@renderer/components/ui/accordion'
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../../lib/ipc'
import { settingsAtom } from '../../store/settings'
interface AdvancedOptionsProps {
startTime: string
endTime: string
downloadSubs: boolean
onStartTimeChange: (value: string) => void
onEndTimeChange: (value: string) => void
onDownloadSubsChange: (value: boolean) => void
}
export function AdvancedOptions({
startTime,
endTime,
downloadSubs,
onStartTimeChange,
onEndTimeChange,
onDownloadSubsChange
}: AdvancedOptionsProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const handleSelectLocation = async () => {
try {
const path = await ipcServices.fs.selectDirectory()
if (path) {
await ipcServices.settings.set('downloadPath', path)
toast.success(t('notifications.settingsSaved'))
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
}
}
return (
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Time Range */}
<div className="space-y-2">
<Label>{t('advancedOptions.timeRange')}</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
placeholder={t('advancedOptions.startPlaceholder')}
value={startTime}
onChange={(e) => onStartTimeChange(e.target.value)}
title={t('advancedOptions.startHint')}
/>
</div>
<span className="text-muted-foreground">-</span>
<div className="flex-1">
<Input
placeholder={t('advancedOptions.endPlaceholder')}
value={endTime}
onChange={(e) => onEndTimeChange(e.target.value)}
title={t('advancedOptions.endHint')}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('advancedOptions.startHint')}</p>
</div>
{/* Subtitles */}
<div className="flex items-center justify-between">
<Label>{t('advancedOptions.downloadSubs')}</Label>
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
</div>
{/* Download Location */}
<div className="space-y-2">
<Label>{t('advancedOptions.downloadLocation')}</Label>
<div className="flex items-center gap-2">
<Input value={settings.downloadPath} readOnly className="flex-1" />
<Button onClick={handleSelectLocation} variant="outline">
{t('settings.selectPath')}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
)
}

View File

@@ -0,0 +1,87 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { VideoInfo } from '../../../../shared/types'
interface AudioExtractorProps {
videoInfo: VideoInfo
onExtract: (type: 'extract') => void
}
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
const { t } = useTranslation()
const [extractFormat, setExtractFormat] = useState('mp3')
const [extractQuality, setExtractQuality] = useState('5')
const audioFormats = [
{ value: 'mp3', label: 'MP3' },
{ value: 'm4a', label: 'M4A' },
{ value: 'opus', label: 'Opus' },
{ value: 'wav', label: 'WAV' },
{ value: 'flac', label: 'FLAC' },
{ value: 'alac', label: 'ALAC' },
{ value: 'vorbis', label: 'Vorbis (OGG)' }
]
const qualities = [
{ value: '0', label: t('audioExtract.best') },
{ value: '2', label: t('audioExtract.good') },
{ value: '5', label: t('audioExtract.normal') },
{ value: '8', label: t('audioExtract.bad') },
{ value: '10', label: t('audioExtract.worst') }
]
return (
<Card>
<CardHeader>
<CardTitle>{t('audioExtract.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => onExtract('extract')} className="w-full">
{t('audioExtract.extract')}
</Button>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,225 @@
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { useAtom } from 'jotai'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
import { settingsAtom } from '../../store/settings'
interface FormatSelectorProps {
formats: VideoFormat[]
type: 'video' | 'audio'
onVideoFormatChange?: (format: string) => void
onAudioFormatChange?: (format: string) => void
}
export function FormatSelector({
formats,
type,
onVideoFormatChange,
onAudioFormatChange
}: FormatSelectorProps) {
const { t } = useTranslation()
const [settings] = useAtom(settingsAtom)
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
const [selectedVideo, setSelectedVideo] = useState('')
const [selectedAudio, setSelectedAudio] = useState('')
const pickVideoFormatForPreset = useCallback(
(formats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
if (formats.length === 0) {
return null
}
const heightLimit = qualityPresetToVideoHeight[preset]
const byHeightDescending = (a: VideoFormat, b: VideoFormat) =>
(b.height ?? 0) - (a.height ?? 0)
const sorted = [...formats].sort(byHeightDescending)
if (preset === 'worst') {
return sorted[sorted.length - 1] ?? sorted[0]
}
if (!heightLimit) {
return sorted[0]
}
const matchingLimit = sorted.find((format) => {
if (!format.height) return false
return format.height <= heightLimit
})
return matchingLimit ?? sorted[0]
},
[]
)
useEffect(() => {
// Filter and sort formats
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
const audios = formats.filter(
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
)
// Apply showMoreFormats filter
const filteredVideos = settings.showMoreFormats
? videos
: videos.filter((f) => f.ext !== 'webm' && !f.vcodec?.startsWith('vp'))
const filteredAudios = settings.showMoreFormats
? audios
: audios.filter((f) => f.ext !== 'webm')
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
if (preferred) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
}, [
formats,
settings,
selectedVideo,
selectedAudio,
onAudioFormatChange,
onVideoFormatChange,
pickVideoFormatForPreset
])
const formatSize = (bytes?: number) => {
if (!bytes) return t('download.unknownSize')
const mb = bytes / 1000000
return `${mb.toFixed(2)} MB`
}
const formatVideoLabel = (format: VideoFormat) => {
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
const size = formatSize(format.filesize || format.filesize_approx)
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
}
const formatAudioLabel = (format: VideoFormat) => {
const quality = format.format_note || t('download.unknownQuality')
const ext = format.ext === 'webm' ? 'opus' : format.ext
const size = formatSize(format.filesize || format.filesize_approx)
return `${quality} | ${ext} | ${size}`
}
if (type === 'video') {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatVideoLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatAudioLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)
}
// Audio only
return (
<div className="space-y-2">
<Label>{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
>
{formatAudioLabel(format)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@@ -0,0 +1,231 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Separator } from '@renderer/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { useSetAtom } from 'jotai'
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { VideoInfo } from '../../../../shared/types'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
import { ipcServices } from '../../lib/ipc'
import { addDownloadAtom } from '../../store/downloads'
import { clearVideoInfoAtom } from '../../store/video'
import { AdvancedOptions } from './AdvancedOptions'
import { AudioExtractor } from './AudioExtractor'
import { FormatSelector } from './FormatSelector'
interface VideoInfoCardProps {
videoInfo: VideoInfo
}
function formatDuration(seconds?: number): string {
if (!seconds) return 'Unknown'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatViews(views?: number): string {
if (!views) return 'Unknown'
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M`
if (views >= 1000) return `${(views / 1000).toFixed(1)}K`
return views.toString()
}
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
const { t } = useTranslation()
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
const addDownload = useSetAtom(addDownloadAtom)
const titleId = useId()
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
const [title, setTitle] = useState(videoInfo.title)
const [selectedVideoFormat, setSelectedVideoFormat] = useState('')
const [selectedAudioForVideo, setSelectedAudioForVideo] = useState('')
const [selectedAudioFormat, setSelectedAudioFormat] = useState('')
const [startTime, setStartTime] = useState('')
const [endTime, setEndTime] = useState('')
const [downloadSubs, setDownloadSubs] = useState(false)
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
const downloadItem = {
id,
url: videoInfo.webpage_url || '',
title,
thumbnail: videoInfo.thumbnail,
type: type === 'extract' ? 'audio' : type,
status: 'pending' as const,
progress: { percent: 0 },
duration: videoInfo.duration,
description: videoInfo.description,
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now()
}
const options = {
url: videoInfo.webpage_url || '',
type,
format: type === 'video' ? selectedVideoFormat : selectedAudioFormat,
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
startTime: startTime || undefined,
endTime: endTime || undefined,
downloadSubs
}
addDownload(downloadItem)
try {
await ipcServices.download.startDownload(id, options)
// Update the download info in the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now()
})
toast.success(t('notifications.downloadStarted'))
clearVideoInfo()
} catch (error) {
console.error('Failed to start download:', error)
toast.error(t('notifications.downloadFailed'))
}
}
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
<ArrowLeft className="h-4 w-4" />
{t('download.back')}
</Button>
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row gap-6">
{/* Thumbnail */}
<div className="shrink-0">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full md:w-80 rounded-lg aspect-video"
fallbackIcon={<Play className="h-12 w-12" />}
/>
</div>
{/* Video Metadata */}
<div className="flex-1 space-y-3">
<div>
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
<CardDescription className="flex flex-wrap gap-2 items-center">
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
{formatDuration(videoInfo.duration)}
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1">
<Eye className="h-3 w-3" />
{formatViews(videoInfo.view_count)}
</Badge>
)}
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
</CardDescription>
</div>
<div className="space-y-2">
<Label htmlFor={titleId}>{t('download.title')}</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
/>
</div>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6 space-y-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-4 mt-4">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
onVideoFormatChange={setSelectedVideoFormat}
onAudioFormatChange={setSelectedAudioForVideo}
/>
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadVideo')}
</Button>
</TabsContent>
<TabsContent value="audio" className="space-y-4 mt-4">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
onAudioFormatChange={setSelectedAudioFormat}
/>
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
<AdvancedOptions
startTime={startTime}
endTime={endTime}
downloadSubs={downloadSubs}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadAudio')}
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,98 @@
export interface PopularSite {
id: string
label: string
description?: string
}
export const popularSites: PopularSite[] = [
{
id: 'youtube',
label: 'YouTube',
description: 'Long-form and livestream video from creators worldwide.'
},
{
id: 'youtubemusic',
label: 'YouTube Music',
description: 'Official music videos, albums, and live performances.'
},
{
id: 'tiktok',
label: 'TikTok',
description: 'Short-form mobile videos, effects, and live streams.'
},
{
id: 'facebook',
label: 'Facebook',
description: 'Feed, Watch, and Reels videos from public pages.'
},
{
id: 'instagram',
label: 'Instagram',
description: 'Feed, Stories, Reels, and Highlights content.'
},
{
id: 'twitter',
label: 'X (Twitter)',
description: 'Timeline posts, Spaces recordings, and broadcasts.'
},
{
id: 'soundcloud',
label: 'SoundCloud',
description: 'Music tracks, playlists, and DJ sets.'
},
{
id: 'reddit',
label: 'Reddit',
description: 'Embedded clips and hosted videos from communities.'
},
{
id: 'vimeo',
label: 'Vimeo',
description: 'High-quality creator and business video hosting.'
},
{
id: 'dailymotion',
label: 'Dailymotion',
description: 'Global news, sports, and entertainment clips.'
},
{
id: 'twitch',
label: 'Twitch',
description: 'Gaming, music, and IRL live streams and VODs.'
},
{
id: 'linkedin',
label: 'LinkedIn',
description: 'Professional talks, webinars, and learning videos.'
},
{
id: 'pinterest',
label: 'Pinterest',
description: 'Idea pins, how-to reels, and lifestyle inspiration videos.'
},
{
id: 'tumblr',
label: 'Tumblr',
description: 'Creative short-form media and fan edits.'
},
{
id: 'mixcloud',
label: 'Mixcloud',
description: 'DJ mixes, radio shows, and long-form audio.'
},
{
id: 'niconico',
label: 'Niconico',
description: 'Japanese animation, music, and live broadcast archive.'
},
{
id: 'kick',
label: 'Kick',
description: 'Creator live streams and replays on the Kick platform.'
},
{
id: 'bandcamp',
label: 'Bandcamp',
description: 'Independent artist albums and community releases.'
}
]

2
src/renderer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { ipcServices } from '../lib/ipc'
export const useCachedThumbnail = (url?: string | null): string | undefined => {
const [cachedUrl, setCachedUrl] = useState<string | undefined>()
useEffect(() => {
let isActive = true
const loadThumbnail = async () => {
if (!url) {
setCachedUrl(undefined)
return
}
if (url.startsWith('file://') || url.startsWith('data:')) {
setCachedUrl(url)
return
}
try {
const localUrl = await ipcServices.thumbnail.getThumbnailPath(url)
if (!isActive) return
setCachedUrl(localUrl ?? undefined)
} catch (error) {
console.error('Failed to load cached thumbnail:', error)
if (!isActive) return
setCachedUrl(undefined)
}
}
void loadThumbnail()
return () => {
isActive = false
}
}, [url])
return cachedUrl
}

View File

@@ -0,0 +1,36 @@
import { useSetAtom } from 'jotai'
import { useEffect } from 'react'
// import type { DownloadHistoryItem } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
import { addHistoryRecordAtom, clearHistoryRecordsAtom } from '../store/downloads'
export function useHistorySync() {
const addHistoryItem = useSetAtom(addHistoryRecordAtom)
const clearHistory = useSetAtom(clearHistoryRecordsAtom)
useEffect(() => {
// Load initial history from main process
const loadHistory = async () => {
try {
const historyData = await ipcServices.history.getHistory()
// Clear existing history and load from main process
clearHistory()
historyData.forEach((item) => {
addHistoryItem(item)
})
} catch (error) {
console.error('Failed to load history:', error)
}
}
loadHistory()
// Listen for new history items from main process
// const _handleHistoryAdded = (item: DownloadHistoryItem) => {
// addHistoryItem(item)
// }
// Note: We would need to add IPC events for real-time updates
// For now, we'll rely on manual refresh or page navigation
}, [addHistoryItem, clearHistory])
}

View File

@@ -0,0 +1,129 @@
import { ipcServices } from '@renderer/lib/ipc'
import { useState } from 'react'
import { toast } from 'sonner'
/**
* Example custom hook demonstrating IPC communication using electron-ipc-decorator
* This shows how to create reusable hooks for IPC calls with type safety
*
* 展示两种服务的使用:
* 1. AppService - 应用相关功能(版本、语言切换等)
* 2. ExampleService - 示例功能ping、问候等
*/
export function useIpcExample() {
const [loading, setLoading] = useState(false)
const [response, setResponse] = useState<string>('')
// Example Service Methods - These are commented out as the example service doesn't exist
const ping = async () => {
setLoading(true)
try {
// Example service not available
setResponse('Example service not available')
toast.info('Example service not available')
return 'Example service not available'
} catch (error) {
toast.error('Failed to ping')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const greet = async (name: string) => {
setLoading(true)
try {
// Example service not available
setResponse(`Hello ${name}!`)
toast.success(`Hello ${name}!`)
return `Hello ${name}!`
} catch (error) {
toast.error('Failed to greet')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const getSystemInfo = async () => {
setLoading(true)
try {
// Get platform info from app service
const platform = await ipcServices?.app.getPlatform()
toast.success(`Platform: ${platform}`)
return { platform }
} catch (error) {
toast.error('Failed to get system info')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
// App Service Methods - 展示应用服务的使用
const getAppVersion = async () => {
setLoading(true)
try {
const version = await ipcServices?.app.getVersion()
setResponse(`App Version: ${version}`)
toast.success(`应用版本: ${version}`)
return version
} catch (error) {
toast.error('获取应用版本失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const getAppInfo = async () => {
setLoading(true)
try {
const version = await ipcServices?.app.getVersion()
const platform = await ipcServices?.app.getPlatform()
const info = { name: 'VidBee', version, platform }
setResponse(`App: ${info.name} v${info.version} (${info.platform})`)
toast.success(`应用: ${info.name} v${info.version}`)
return info
} catch (error) {
toast.error('获取应用信息失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
const switchAppLocale = async (_locale: string) => {
setLoading(true)
try {
// Language switching not implemented in app service
setResponse(`Language switching not implemented`)
toast.info(`语言切换功能未实现`)
return true
} catch (error) {
toast.error('切换语言失败')
console.error(error)
throw error
} finally {
setLoading(false)
}
}
return {
loading,
response,
// Example Service
ping,
greet,
getSystemInfo,
// App Service
getAppVersion,
getAppInfo,
switchAppLocale
}
}

18
src/renderer/src/i18n.ts Normal file
View File

@@ -0,0 +1,18 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import zh from './locales/zh.json'
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
zh: { translation: zh }
},
lng: 'en',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
})
export default i18n

View File

@@ -0,0 +1,45 @@
/**
* IPC Services for Renderer Process
*
* This file provides a convenient way to access IPC services in the renderer process.
* All services are type-safe and automatically generated from the main process.
*
* Usage:
* import { ipcServices, ipcEvents } from '@renderer/lib/ipc'
*
* const version = await ipcServices.app.getAppVersion()
* const info = await ipcServices.app.getAppInfo()
* await ipcServices.app.switchAppLocale('zh-CN')
*
* // Event listening
* const unsubscribe = ipcEvents.on('download:started', (id: string) => {
* console.log('Download started:', id)
* })
* ipcEvents.removeListener('download:started', unsubscribe)
*/
import type { IpcServices } from '@shared/types/ipc'
import { createIpcProxy } from 'electron-ipc-decorator/client'
// ipcRenderer should be exposed through electron's context bridge
// Create type-safe IPC proxy for renderer process
export const ipcServices = createIpcProxy<IpcServices>(
window.electron.ipcRenderer as unknown as Electron.IpcRenderer
) as NonNullable<ReturnType<typeof createIpcProxy<IpcServices>>>
// Export event listening utilities
export const ipcEvents = {
on: (channel: string, callback: (...args: unknown[]) => void) => {
return window.api.on(channel, callback)
},
removeListener: (channel: string, callback: (...args: unknown[]) => void) => {
window.api.removeListener(channel, callback)
},
send: (channel: string, ...args: unknown[]) => {
window.api.send(channel, ...args)
}
}
// Export types for use in other files
export type { IpcServices }

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,293 @@
{
"about": {
"actions": {
"checkUpdates": "Check updates",
"email": "Email",
"feedback": "Feedback",
"openRepo": "Open GitHub repository",
"view": "View",
"visit": "Visit"
},
"appName": "VidBee",
"autoUpdateDescription": "Download and install new releases automatically in the background.",
"autoUpdateTitle": "Auto updates",
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
"here": "here",
"homepage": "Homepage",
"notifications": {
"checkingUpdates": "Looking for updates...",
"updateAvailable": "Update available: {{version}}",
"noUpdatesAvailable": "You're using the latest version",
"updateError": "Failed to check for updates: {{error}}",
"downloadUpdate": "Download and install update {{version}}?",
"downloadStarted": "Download started...",
"downloadError": "Failed to download update",
"updateDownloaded": "Update downloaded, restart to install",
"restartToUpdate": "Restart now to install update?"
},
"preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles",
"resources": {
"changelog": "Release notes",
"changelogDescription": "Catch up on what changed in each version.",
"contact": "Email support",
"contactDescription": "Reach out directly for help or collaboration.",
"documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.",
"license": "License",
"licenseDescription": "Review the open-source license terms.",
"website": "Official website",
"websiteDescription": "Product highlights, roadmap, and community news."
},
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
"resourcesTitle": "Resources",
"shareSupport": "Recommend VidBee to your friends to support our growth and updates.",
"shareTitle": "Spread the word",
"shareDescription": "Share VidBee with your community in one click.",
"shareActions": {
"twitter": "Share on X (Twitter)",
"facebook": "Share on Facebook",
"copy": "Copy link"
},
"sourceCode": "Source Code is available",
"tagline": "An AI-friendly download helper for every creator",
"title": "About",
"version": "Version",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "Close app when download finishes",
"currentLocation": "Current download location - ",
"downloadLocation": "Download location",
"downloadSubs": "Download subtitles if available",
"end": "End",
"endHint": "If kept empty, it will be downloaded to the end",
"endPlaceholder": "10:00",
"selectLocation": "Select Download Location",
"start": "Start",
"startHint": "If kept empty, it will start from the beginning",
"startPlaceholder": "00:00",
"subtitles": "Subtitles",
"timeRange": "Download particular time-range",
"title": "Advanced Options"
},
"app": {
"description": "Download videos and audios from hundreds of sites",
"title": "VidBee"
},
"audioExtract": {
"bad": "Bad",
"best": "Best",
"extract": "Extract",
"good": "Good",
"normal": "Normal",
"selectFormat": "Select Format",
"selectQuality": "Select Quality",
"title": "Extract Audio",
"worst": "Worst"
},
"download": {
"active": "active",
"all": "All",
"audio": "Audio",
"back": "Back",
"cancel": "Cancel",
"cancelled": "Cancelled",
"clearCompleted": "Clear Completed",
"clearDownloads": "Clear Downloads",
"completed": "Completed",
"downloadAudio": "Download Audio",
"downloadBtn": "Download",
"downloadPending": "Pending",
"downloadQueue": "Download Queue",
"downloadVideo": "Download Video",
"downloading": "Downloading...",
"enterUrl": "Enter Video URL",
"enterUrlDescription": "Paste or type a video URL. ",
"error": "Error",
"fetch": "Fetch",
"fetchingVideoInfo": "Fetching video info...",
"history": "History",
"imageLoadError": "Image failed to load",
"imagePlaceholder": "No image available",
"infoUnavailable": "One-Click Download (Info unavailable)",
"loading": "Loading",
"moreOptions": "More options",
"noActiveDownloads": "No active downloads",
"noAudio": "No Audio",
"noHistory": "No download history",
"noItems": "No items found",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
"oneClickDownloadNow": "Download Now",
"oneClickDownloadStarted": "Download started with default settings",
"paste": "Paste",
"pastePlaylistUrl": "Click to paste playlist link from clipboard [Ctrl + V]",
"pasteUrl": "Click to paste video URL or ID [Ctrl + V]",
"preparing": "Preparing...",
"processing": "Processing",
"progress": "Progress",
"selectAudioFormat": "Select Audio Format",
"selectFormat": "Select Format",
"selectVideoFormat": "Select Video Format",
"singleVideo": "Single Video",
"speed": "Speed",
"title": "Title",
"total": "Total",
"unknownQuality": "Unknown quality",
"unknownSize": "Unknown size",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "Video",
"videoInfo": "Video Information",
"videoInfoUpdated": "Video information updated"
},
"errors": {
"clickToCopy": "Click to copy details",
"clipboardEmpty": "Clipboard is empty",
"downloadFailed": "Download failed",
"downloadNecessaryFilesFailed": "Failed to download necessary files. Please check your network and try again",
"emptyUrl": "Please enter a URL",
"errorDetails": "Error Details",
"fetchInfoFailed": "Failed to fetch video information",
"networkError": "Some error has occurred. Check your network and use correct URL",
"pasteFromClipboard": "Failed to paste from clipboard"
},
"history": {
"clearCancelled": "Clear Cancelled",
"clearCompleted": "Clear Completed",
"clearErrors": "Clear Errors",
"copyUrl": "Copy URL",
"openInBrowser": "Click to open in browser",
"date": "Date",
"description": "View and manage your download history",
"duration": "Duration",
"fileSize": "File Size",
"filters": {
"all": "All",
"cancelled": "Cancelled",
"completed": "Completed",
"errors": "Errors"
},
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openFile": "Open File",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
"outputPath": "Output Path",
"removeItem": "Remove Item",
"stats": {
"cancelled": "Cancelled",
"completed": "Completed",
"errors": "Errors",
"total": "Total"
},
"status": {
"cancelled": "Cancelled",
"completed": "Completed",
"error": "Error"
},
"title": "Download History"
},
"menu": {
"about": "About",
"download": "Download",
"playlist": "Download Playlist",
"preferences": "Preferences",
"supportedSites": "Supported Sites",
"theme": "Theme:"
},
"notifications": {
"copyFailed": "Failed to copy to clipboard",
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",
"itemRemoved": "Item removed",
"openFileFailed": "Failed to open file",
"openFolderFailed": "Failed to open folder",
"removeFailed": "Failed to remove item",
"settingsSaved": "Settings saved",
"urlCopied": "URL copied to clipboard"
},
"playlist": {
"comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel",
"downloadFailed": "Failed to start playlist download",
"downloadPlaylist": "Download Playlist",
"downloadStarted": "Started downloading {{count}} videos from playlist",
"downloadType": "Download Type",
"downloading": "Downloading playlist:",
"endIndex": "End",
"enterPlaylistUrl": "Enter Playlist URL",
"fetchFailed": "Failed to fetch playlist information",
"filenameFormat": "Filename format for playlists",
"folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist",
"linkLabel": "Playlist URL",
"playlistUrlDescription": "Download all videos from a playlist in bulk",
"range": "Range (Optional)",
"resetToDefault": "Reset to default",
"startIndex": "Start (1)",
"title": "Download Playlist"
},
"settings": {
"aboutTab": "About",
"advanced": "Advanced",
"app": "App Settings",
"audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from",
"configFile": "Use configuration file",
"dark": "Dark",
"description": "Configure your download preferences and application settings",
"downloadPath": "Download location",
"general": "General",
"language": "Language",
"languageOptions": {
"chinese": "Chinese (Simplified)",
"english": "English"
},
"light": "Light",
"maxConcurrentDownloads": "Maximum number of active downloads",
"none": "None",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Enable one-click download with default settings",
"oneClickDownloadType": "Default download type",
"oneClickQuality": "Preferred quality",
"oneClickQualityOptions": {
"auto": "Auto",
"bad": "Bad",
"best": "Best",
"good": "Good",
"normal": "Normal",
"worst": "Worst"
},
"proxy": "Proxy",
"selectConfigFile": "Select config file",
"selectPath": "Select",
"showMoreFormats": "Show more format options",
"system": "System",
"theme": "Theme",
"title": "Settings",
"tray": {
"quit": "Quit",
"showHome": "Show Home"
},
"video": "Video Preferences"
},
"sites": {
"homeInlineDescription": "Supports {{sites}} and more.",
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
"moreTitle": "Need another site?",
"openFullList": "Open full supported sites list",
"pageDescription": "VidBee uses yt-dlp under the hood to reach hundreds of sources.",
"pageIntro": "Here are the mainstream services people download from most frequently.",
"pageTitle": "Supported Sites",
"popularSection": "Main platforms",
"viewAll": "View all supported sites"
}
}

View File

@@ -0,0 +1,282 @@
{
"about": {
"actions": {
"checkUpdates": "检查更新",
"email": "邮件",
"feedback": "反馈",
"openRepo": "打开 GitHub 仓库",
"view": "查看",
"visit": "访问"
},
"appName": "VidBee",
"autoUpdateDescription": "在后台自动下载并安装新版本。",
"autoUpdateTitle": "自动更新",
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
"betaProgramTitle": "预览通道",
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
"here": "此处",
"homepage": "主页",
"notifications": {
"checkingUpdates": "正在查找更新...",
"updateAvailable": "发现新版本: {{version}}",
"noUpdatesAvailable": "您正在使用最新版本",
"updateError": "检查更新失败: {{error}}",
"downloadUpdate": "下载并安装更新 {{version}}",
"downloadStarted": "开始下载...",
"downloadError": "下载更新失败",
"updateDownloaded": "更新已下载,重启以安装",
"restartToUpdate": "立即重启以安装更新?"
},
"preferencesDescription": "无需离开此页即可调整更新设置。",
"preferencesTitle": "快速切换",
"resources": {
"changelog": "发行说明",
"changelogDescription": "了解每个版本的变更内容。",
"contact": "邮件支持",
"contactDescription": "直接联系我们以获取帮助或开展合作。",
"documentation": "帮助中心",
"documentationDescription": "指南、常见问题和常见流程。",
"feedback": "反馈与问题",
"feedbackDescription": "在 GitHub 上分享想法或报告问题。",
"license": "许可证",
"licenseDescription": "查阅开源许可证条款。",
"website": "官方网站",
"websiteDescription": "产品亮点、路线图与社区动态。"
},
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"sourceCode": "源代码已开放",
"tagline": "面向每位创作者的 AI 友好下载助手",
"title": "关于",
"version": "版本",
"versionLabel": "v{{version}}"
},
"advancedOptions": {
"closeWhenDone": "下载完成后关闭应用",
"currentLocation": "当前下载位置 - ",
"downloadLocation": "下载位置",
"downloadSubs": "若有字幕则下载",
"end": "结束",
"endHint": "如果留空,将下载到结尾",
"endPlaceholder": "10:00",
"selectLocation": "选择下载位置",
"start": "开始",
"startHint": "如果留空,将从开头开始",
"startPlaceholder": "00:00",
"subtitles": "字幕",
"timeRange": "下载指定时间范围",
"title": "高级选项"
},
"app": {
"description": "从数百个网站下载视频和音频",
"title": "VidBee"
},
"audioExtract": {
"bad": "较差",
"best": "最佳",
"extract": "提取",
"good": "良好",
"normal": "标准",
"selectFormat": "选择格式",
"selectQuality": "选择质量",
"title": "提取音频",
"worst": "最差"
},
"download": {
"active": "进行中",
"all": "全部",
"audio": "音频",
"back": "返回",
"cancel": "取消",
"cancelled": "已取消",
"clearCompleted": "清除已完成",
"clearDownloads": "清除下载",
"completed": "已完成",
"downloadAudio": "下载音频",
"downloadBtn": "下载",
"downloadPending": "待处理",
"downloadQueue": "下载队列",
"downloadVideo": "下载视频",
"downloading": "正在下载...",
"enterUrl": "输入视频链接",
"enterUrlDescription": "粘贴或输入一个视频链接。",
"error": "错误",
"fetch": "获取",
"fetchingVideoInfo": "正在获取视频信息...",
"history": "历史",
"imageLoadError": "图像加载失败",
"imagePlaceholder": "暂无图像",
"infoUnavailable": "一键下载(信息不可用)",
"loading": "加载中",
"moreOptions": "更多选项",
"noActiveDownloads": "暂无进行中的下载",
"noAudio": "无音频",
"noHistory": "暂无下载历史",
"noItems": "未找到项目",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "使用默认设置直接下载,无需确认",
"oneClickDownloadNow": "立即下载",
"oneClickDownloadStarted": "已使用默认设置开始下载",
"paste": "粘贴",
"pastePlaylistUrl": "点击从剪贴板粘贴播放列表链接 [Ctrl + V]",
"pasteUrl": "点击粘贴视频链接或 ID [Ctrl + V]",
"preparing": "正在准备...",
"processing": "处理中",
"progress": "进度",
"selectAudioFormat": "选择音频格式",
"selectFormat": "选择格式",
"selectVideoFormat": "选择视频格式",
"singleVideo": "单个视频",
"speed": "速度",
"title": "标题",
"total": "总计",
"unknownQuality": "未知质量",
"unknownSize": "未知大小",
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
"video": "视频",
"videoInfo": "视频信息",
"videoInfoUpdated": "视频信息已更新"
},
"errors": {
"clickToCopy": "点击复制详情",
"clipboardEmpty": "剪贴板为空",
"downloadFailed": "下载失败",
"downloadNecessaryFilesFailed": "必要文件下载失败。请检查网络后再试",
"emptyUrl": "请输入链接",
"errorDetails": "错误详情",
"fetchInfoFailed": "获取视频信息失败",
"networkError": "发生错误。请检查网络并确认链接正确",
"pasteFromClipboard": "从剪贴板粘贴失败"
},
"history": {
"clearCancelled": "清除已取消",
"clearCompleted": "清除已完成",
"clearErrors": "清除错误",
"copyUrl": "复制链接",
"openInBrowser": "点击在浏览器中打开",
"date": "日期",
"description": "查看并管理下载历史",
"duration": "时长",
"fileSize": "文件大小",
"filters": {
"all": "全部",
"cancelled": "已取消",
"completed": "已完成",
"errors": "错误"
},
"noHistory": "暂无下载历史",
"noHistoryDescription": "完成的下载会显示在这里",
"openFile": "打开文件",
"openFileLocation": "打开文件位置",
"openFolder": "打开文件夹",
"openDownloadFolder": "打开下载文件夹",
"outputPath": "输出路径",
"removeItem": "移除项目",
"stats": {
"cancelled": "已取消",
"completed": "已完成",
"errors": "错误",
"total": "总计"
},
"status": {
"cancelled": "已取消",
"completed": "已完成",
"error": "错误"
},
"title": "下载历史"
},
"menu": {
"about": "关于",
"download": "下载",
"playlist": "下载播放列表",
"preferences": "偏好设置",
"supportedSites": "支持的网站",
"theme": "主题:"
},
"notifications": {
"copyFailed": "复制到剪贴板失败",
"downloadCompleted": "下载完成",
"downloadFailed": "下载失败",
"downloadStarted": "下载已开始",
"itemRemoved": "项目已移除",
"openFileFailed": "打开文件失败",
"openFolderFailed": "打开文件夹失败",
"removeFailed": "移除项目失败",
"settingsSaved": "设置已保存",
"urlCopied": "链接已复制到剪贴板"
},
"playlist": {
"comingSoon": "播放列表下载功能即将推出!",
"completed": "播放列表已下载",
"description": "下载 YouTube 播放列表或频道中的全部视频",
"downloadFailed": "启动播放列表下载失败",
"downloadPlaylist": "下载播放列表",
"downloadStarted": "已开始下载播放列表中的 {{count}} 个视频",
"downloadType": "下载类型",
"downloading": "正在下载播放列表:",
"endIndex": "结束",
"enterPlaylistUrl": "输入播放列表链接",
"fetchFailed": "获取播放列表信息失败",
"filenameFormat": "播放列表文件名格式",
"folderFormat": "播放列表文件夹命名格式",
"foundVideos": "在播放列表中找到 {{count}} 个视频",
"linkLabel": "播放列表链接",
"playlistUrlDescription": "批量下载播放列表中的所有视频",
"range": "范围(可选)",
"resetToDefault": "恢复默认",
"startIndex": "开始1",
"title": "下载播放列表"
},
"settings": {
"aboutTab": "关于",
"advanced": "高级",
"app": "应用设置",
"audio": "音频偏好",
"browserForCookies": "选择用于读取 Cookie 的浏览器",
"configFile": "使用配置文件",
"dark": "深色",
"description": "配置下载偏好和应用设置",
"downloadPath": "下载位置",
"general": "通用",
"language": "语言",
"languageOptions": {
"chinese": "简体中文",
"english": "英语"
},
"light": "浅色",
"maxConcurrentDownloads": "最大活动下载数",
"none": "无",
"oneClickAudioForVideo": "视频默认音频",
"oneClickAudioFormat": "默认音频格式",
"oneClickDownload": "一键下载",
"oneClickDownloadDescription": "启用使用默认设置的一键下载",
"oneClickDownloadType": "默认下载类型",
"oneClickVideoFormat": "默认视频格式",
"preferredAudioQuality": "首选音频质量",
"preferredVideoCodec": "首选视频编码",
"preferredVideoQuality": "首选视频质量",
"proxy": "代理",
"selectConfigFile": "选择配置文件",
"selectPath": "选择",
"showMoreFormats": "显示更多格式选项",
"system": "系统",
"theme": "主题",
"title": "设置",
"tray": {
"quit": "退出",
"showHome": "显示首页"
},
"video": "视频偏好"
},
"sites": {
"homeInlineDescription": "支持 {{sites}} 等更多网站。",
"moreDescription": "完整的 yt-dlp 列表由社区持续更新。",
"moreTitle": "需要其他网站?",
"openFullList": "打开全部支持网站列表",
"pageDescription": "VidBee 使用 yt-dlp 覆盖数百个资源。",
"pageIntro": "以下是大家最常下载的主流服务。",
"pageTitle": "支持的网站",
"popularSection": "主流平台",
"viewAll": "查看全部支持的网站"
}
}

17
src/renderer/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import './assets/main.css'
import './assets/global.css'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './i18n'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('Root element not found')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,285 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
import type { LucideIcon } from 'lucide-react'
import {
Facebook,
FileText,
Github,
Link as LinkIcon,
Mail,
MessageCircle,
RefreshCw,
ShieldCheck,
Twitter
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
interface AboutResource {
icon: LucideIcon
label: string
description?: string
actionLabel: string
href?: string
onClick?: () => void
}
export function About() {
const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const saveSetting = useSetAtom(saveSettingAtom)
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
useEffect(() => {
let isActive = true
const fetchAppVersion = async () => {
try {
const version = await ipcServices.app.getVersion()
if (isActive) {
setAppVersion(version)
}
} catch (error) {
console.error('Failed to get app version:', error)
}
}
void fetchAppVersion()
return () => {
isActive = false
}
}, [])
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
}
const handleCheckForUpdates = async () => {
try {
toast.info(t('about.notifications.checkingUpdates'))
const result = await ipcServices.update.checkForUpdates()
if (result.available) {
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
} else {
toast.success(t('about.notifications.noUpdatesAvailable'))
}
} catch (error) {
console.error('Failed to check for updates:', error)
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
}
}
const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.tagline'))
return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
twitter: `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedText}`
}
}, [t])
const openShareUrl = (url: string) => {
if (typeof window === 'undefined') {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
const handleShareTwitter = () => {
openShareUrl(shareLinks.twitter)
}
const handleShareFacebook = () => {
openShareUrl(shareLinks.facebook)
}
const handleCopyShareLink = async () => {
try {
await navigator.clipboard.writeText(shareTargetUrl)
toast.success(t('notifications.urlCopied'))
} catch (error) {
console.error('Failed to copy share link:', error)
toast.error(t('notifications.copyFailed'))
}
}
const aboutResources = useMemo<AboutResource[]>(
() => [
{
icon: FileText,
label: t('about.resources.changelog'),
description: t('about.resources.changelogDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/releases'
},
{
icon: MessageCircle,
label: t('about.resources.feedback'),
description: t('about.resources.feedbackDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: ShieldCheck,
label: t('about.resources.license'),
description: t('about.resources.licenseDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
},
{
icon: Mail,
label: t('about.resources.contact'),
description: t('about.resources.contactDescription'),
actionLabel: t('about.actions.email'),
href: 'mailto:nexmoex@gmail.com'
}
],
[t]
)
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
<p className="text-muted-foreground">{t('about.description')}</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.tagline')}</p>
</div>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-4 w-4" />
</a>
</Button>
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-4 pt-6">
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
<p className="text-sm text-muted-foreground">{t('about.autoUpdateDescription')}</p>
</div>
<Switch
checked={settings.autoUpdate}
onCheckedChange={(value) => handleSettingChange('autoUpdate', value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="flex flex-col divide-y">
{aboutResources.map((resource) => {
const Icon = resource.icon
return (
<div
key={resource.label}
className="flex items-center justify-between gap-4 px-6 py-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">{resource.label}</p>
{resource.description ? (
<p className="text-sm text-muted-foreground">{resource.description}</p>
) : null}
</div>
</div>
{resource.href ? (
<Button variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer">
{resource.actionLabel}
</a>
</Button>
) : (
<Button variant="outline" size="sm" onClick={resource.onClick}>
{resource.actionLabel}
</Button>
)}
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,662 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Tabs, TabsContent } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { VideoInfoCard } from '../components/video/VideoInfoCard'
import { ipcEvents, ipcServices } from '../lib/ipc'
import {
addDownloadAtom,
addHistoryRecordAtom,
removeDownloadAtom,
updateDownloadAtom
} from '../store/downloads'
import { loadSettingsAtom, settingsAtom } from '../store/settings'
import {
currentVideoInfoAtom,
fetchVideoInfoAtom,
videoInfoErrorAtom,
videoInfoLoadingAtom
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const dedupe = (candidates: Array<string | undefined>): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const candidate of candidates) {
if (!candidate) continue
if (seen.has(candidate)) continue
seen.add(candidate)
result.push(candidate)
}
return result
}
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
settings.oneClickQuality ?? 'auto'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio', 'worst']
}
const abrLimit = qualityPresetToAudioAbr[preset]
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
return 'worstvideo+worstaudio/worst'
}
const maxHeight = qualityPresetToVideoHeight[preset]
const videoCandidates = dedupe([
maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,
'bestvideo'
])
const audioSelectors = buildAudioSelectors(preset)
const combinations: string[] = []
for (const video of videoCandidates) {
for (const audio of audioSelectors) {
combinations.push(`${video}+${audio}`)
}
}
if (audioSelectors.includes('none')) {
for (const video of videoCandidates) {
combinations.push(video)
}
} else {
combinations.push('best')
}
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
}
interface HomeProps {
onOpenSupportedSites?: () => void
}
export function Home({ onOpenSupportedSites }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
const [error] = useAtom(videoInfoErrorAtom)
const [settings] = useAtom(settingsAtom)
const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const updateDownload = useSetAtom(updateDownloadAtom)
const addDownload = useSetAtom(addDownloadAtom)
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const [url, setUrl] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const inlinePreviewSites = popularSites
.slice(0, 3)
.map((site) => site.label)
.join(', ')
// Playlist states
const playlistUrlId = useId()
const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('')
const [playlistLoading, setPlaylistLoading] = useState(false)
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const syncHistoryItem = useCallback(
async (id: string) => {
try {
const historyItem = await ipcServices.history.getHistoryById(id)
if (historyItem) {
addHistoryRecord(historyItem)
removeDownload(id)
}
} catch (error) {
console.error('Failed to sync history item:', error)
}
},
[addHistoryRecord, removeDownload]
)
useEffect(() => {
// Load settings on mount
loadSettings()
// Listen for download events from main process
ipcEvents.on('download:started', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download started:', id)
updateDownload({ id, changes: { status: 'downloading' } })
})
ipcEvents.on('download:progress', (...args: unknown[]) => {
const data = args[0] as { id: string; progress: unknown }
console.log('Download progress:', data)
const progress = data.progress as {
percent: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
updateDownload({
id: data.id,
changes: {
progress: {
percent: progress.percent || 0,
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
},
speed: progress.currentSpeed || ''
}
})
})
ipcEvents.on('download:completed', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download completed:', id)
updateDownload({ id, changes: { status: 'completed' } })
toast.success(t('notifications.downloadCompleted'))
void syncHistoryItem(id)
})
ipcEvents.on('download:error', (...args: unknown[]) => {
const data = args[0] as { id: string; error: string }
console.error('Download error:', data)
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
toast.error(t('notifications.downloadFailed'))
void syncHistoryItem(data.id)
})
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download cancelled:', id)
updateDownload({ id, changes: { status: 'cancelled' } })
void syncHistoryItem(id)
})
return () => {
// Note: Event listeners are automatically cleaned up when the component unmounts
// The removeListener calls are not needed as the event system handles cleanup
}
}, [loadSettings, syncHistoryItem, t, updateDownload])
const handlePasteUrl = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setUrl(text.trim())
inputRef.current?.focus()
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
const handleFetchVideo = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
await fetchVideoInfo(url.trim())
}, [url, fetchVideoInfo, t])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleFetchVideo()
}
},
[handleFetchVideo]
)
const handleOneClickDownload = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
// Create initial download item with placeholder info
const trimmedUrl = url.trim()
const downloadItem = {
id,
url: trimmedUrl,
title: t('download.fetchingVideoInfo'),
type: settings.oneClickDownloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
const options = {
url: trimmedUrl,
type: settings.oneClickDownloadType,
format:
settings.oneClickDownloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
}
addDownload(downloadItem)
try {
// Start download immediately
await ipcServices.download.startDownload(id, options)
// Fetch video info in parallel to update the download item
try {
const videoInfo = await ipcServices.download.getVideoInfo(url.trim())
// Update the download item in the renderer state
updateDownload({
id,
changes: {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
// Extract additional metadata if available
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the download info in the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
})
// Show a subtle notification that video info was updated
toast.success(t('download.videoInfoUpdated'))
} catch (infoError) {
console.warn('Failed to fetch video info for one-click download:', infoError)
// Keep the placeholder title if video info fetch fails
// Update the title to indicate info fetch failed
updateDownload({
id,
changes: {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
})
}
toast.success(t('download.oneClickDownloadStarted'))
setUrl('') // Clear the URL after starting download
} catch (error) {
console.error('Failed to start one-click download:', error)
toast.error(t('notifications.downloadFailed'))
}
}, [url, settings, addDownload, updateDownload, t])
// Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setPlaylistUrl(text.trim())
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
const handleDownloadPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
setPlaylistLoading(true)
try {
// Get playlist info first to show user what will be downloaded
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount }))
// Build format preference based on settings
const format =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
// Start playlist download
const downloadIds = await ipcServices.download.startPlaylistDownload({
url: playlistUrl.trim(),
type: downloadType,
format,
startIndex: parseInt(startIndex, 10) || 1,
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
})
// Add all downloads to the renderer state
for (const id of downloadIds) {
const downloadItem = {
id,
url: playlistUrl.trim(),
title: t('download.fetchingVideoInfo'),
type: downloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
addDownload(downloadItem)
}
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
setPlaylistUrl('') // Clear the URL after starting download
} catch (error) {
console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed'))
} finally {
setPlaylistLoading(false)
}
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<div
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
style={{ maxWidth: '100%' }}
>
<Tabs defaultValue="single" className="w-full">
{/* <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList> */}
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6">
{/* URL Input Card */}
{!videoInfo && (
<Card>
<CardHeader>
<CardTitle>{t('download.enterUrl')}</CardTitle>
<CardDescription>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="px-0"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
placeholder={t('download.urlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
disabled={loading}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
{t('download.paste')}
</Button>
{settings.oneClickDownload ? (
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('download.oneClickDownloadNow')}
</>
)}
</Button>
) : (
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
{t('download.fetch')}
</>
)}
</Button>
)}
</div>
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('download.oneClickDownload')}
</p>
<p className="text-sm text-blue-700">
{t('download.oneClickDownloadDescription')}
</p>
</div>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => setPlaylistUrl(e.target.value)}
className="flex-1"
disabled={playlistLoading}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistLoading}
>
{t('download.paste')}
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistLoading}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistLoading}
/>
</div>
</div>
</div>
<Button
onClick={handleDownloadPlaylist}
className="w-full"
size="lg"
disabled={playlistLoading || !playlistUrl.trim()}
>
{playlistLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Unified Download History */}
<UnifiedDownloadHistory />
</div>
)
}

View File

@@ -0,0 +1,341 @@
import { Button } from '@renderer/components/ui/button'
import { Input } from '@renderer/components/ui/input'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemSeparator,
ItemTitle
} from '@renderer/components/ui/item'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
export function Settings() {
const { t } = useTranslation()
const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom)
useEffect(() => {
loadSettings()
}, [loadSettings])
const handleSettingChange = async (
key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings]
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
}
const handleSelectPath = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectDirectory()
if (path) {
await handleSettingChange('downloadPath', path)
}
} catch (error) {
console.error('Failed to select directory:', error)
toast.error('Failed to select directory')
}
}
const handleSelectConfigFile = async () => {
try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile()
if (path) {
await handleSettingChange('configPath', path)
}
} catch (error) {
console.error('Failed to select file:', error)
toast.error('Failed to select file')
}
}
const handleThemeChange = async (value: 'light' | 'dark' | 'system') => {
const currentTheme = (theme ?? settings.theme ?? 'system') as 'light' | 'dark' | 'system'
if (currentTheme === value) {
return
}
setTheme(value)
await handleSettingChange('theme', value)
}
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h1>
<p className="text-muted-foreground">{t('settings.description')}</p>
</div>
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 mt-2">
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.downloadPath')}</ItemTitle>
<ItemDescription>Choose where to save downloaded files</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.downloadPath} readOnly className="flex-1" />
<Button onClick={handleSelectPath}>{t('settings.selectPath')}</Button>
</div>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.theme')}</ItemTitle>
<ItemDescription>
Choose a light, dark, or system theme for VidBee
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={theme ?? settings.theme ?? 'system'}
onValueChange={(value) =>
void handleThemeChange(value as 'light' | 'dark' | 'system')
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">{t('settings.light')}</SelectItem>
<SelectItem value="dark">{t('settings.dark')}</SelectItem>
<SelectItem value="system">{t('settings.system')}</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickDownload')}</ItemTitle>
<ItemDescription>{t('settings.oneClickDownloadDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.oneClickDownload}
onCheckedChange={(value) => handleSettingChange('oneClickDownload', value)}
/>
</ItemActions>
</Item>
{settings.oneClickDownload && (
<>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>
<ItemDescription>
Choose the default download type for one-click downloads. Quality uses the
preset below.
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.oneClickDownloadType}
onValueChange={(value) =>
handleSettingChange('oneClickDownloadType', value)
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>
<ItemDescription>
Select the quality preset used for one-click downloads
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.oneClickQuality}
onValueChange={(value) =>
handleSettingChange('oneClickQuality', value as OneClickQualityPreset)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t('settings.oneClickQualityOptions.auto')}
</SelectItem>
<SelectItem value="best">
{t('settings.oneClickQualityOptions.best')}
</SelectItem>
<SelectItem value="good">
{t('settings.oneClickQualityOptions.good')}
</SelectItem>
<SelectItem value="normal">
{t('settings.oneClickQualityOptions.normal')}
</SelectItem>
<SelectItem value="bad">
{t('settings.oneClickQualityOptions.bad')}
</SelectItem>
<SelectItem value="worst">
{t('settings.oneClickQualityOptions.worst')}
</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
</>
)}
</ItemGroup>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
<ItemDescription>
Display additional format options in the interface
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.showMoreFormats}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
<TabsContent value="advanced" className="space-y-4 mt-2">
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
<ItemDescription>Maximum number of simultaneous downloads</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.maxConcurrentDownloads.toString()}
onValueChange={(value) =>
handleSettingChange('maxConcurrentDownloads', Number(value))
}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
<ItemDescription>
Browser to extract cookies from for authentication
</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={settings.browserForCookies}
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">Chrome</SelectItem>
<SelectItem value="firefox">Firefox</SelectItem>
<SelectItem value="edge">Edge</SelectItem>
<SelectItem value="safari">Safari</SelectItem>
<SelectItem value="brave">Brave</SelectItem>
</SelectContent>
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.proxy')}</ItemTitle>
<ItemDescription>Proxy server for network requests</ItemDescription>
</ItemContent>
<ItemActions>
<Input
placeholder="http://proxy:port"
value={settings.proxy}
onChange={(e) => handleSettingChange('proxy', e.target.value)}
className="w-64"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.configFile')}</ItemTitle>
<ItemDescription>Custom configuration file for yt-dlp</ItemDescription>
</ItemContent>
<ItemActions>
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.configPath} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
</div>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { popularSites } from '@renderer/data/popularSites'
import { useTranslation } from 'react-i18next'
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
export function SupportedSites() {
const { t } = useTranslation()
return (
<div className="container mx-auto max-w-5xl p-6 space-y-6" style={{ maxWidth: '100%' }}>
<Card>
<CardHeader>
<CardTitle>{t('sites.pageTitle')}</CardTitle>
<CardDescription>{t('sites.pageDescription')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('sites.pageIntro')}</p>
</CardContent>
</Card>
<section className="space-y-3">
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
<ul className="grid gap-3 sm:grid-cols-2">
{popularSites.map((site) => (
<li key={site.id} className="rounded-md border border-border px-6 py-5">
<p className="text-sm font-medium">{site.label}</p>
{site.description ? (
<p className="mt-1 text-xs text-muted-foreground">{site.description}</p>
) : null}
</li>
))}
</ul>
</section>
<Card>
<CardHeader>
<CardTitle>{t('sites.moreTitle')}</CardTitle>
<CardDescription>{t('sites.moreDescription')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<a href={referenceUrl} target="_blank" rel="noreferrer">
{t('sites.openFullList')}
</a>
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { atom } from 'jotai'
import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
export type DownloadRecord = DownloadItem & {
entryType: 'active' | 'history'
downloadedAt?: number
}
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
const toActiveRecord = (item: DownloadItem): DownloadRecord => ({
...item,
entryType: 'active'
})
const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
id: item.id,
url: item.url,
title: item.title,
thumbnail: item.thumbnail,
type: item.type,
status: item.status,
progress: undefined,
error: item.error,
outputPath: item.outputPath,
speed: undefined,
duration: item.duration,
fileSize: item.fileSize,
format: item.format,
quality: item.quality,
codec: item.codec,
createdAt: item.downloadedAt,
startedAt: item.downloadedAt,
completedAt: item.completedAt ?? item.downloadedAt,
description: item.description,
channel: item.channel,
uploader: item.uploader,
viewCount: item.viewCount,
tags: item.tags,
selectedFormat: item.selectedFormat,
entryType: 'history',
downloadedAt: item.downloadedAt
})
export const downloadRecordsAtom = atom<Map<string, DownloadRecord>>(new Map())
export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.set(recordKey('active', item.id), toActiveRecord(item))
set(downloadRecordsAtom, downloads)
})
export const updateDownloadAtom = atom(
null,
(get, set, update: { id: string; changes: Partial<DownloadItem> }) => {
const downloads = new Map(get(downloadRecordsAtom))
const key = recordKey('active', update.id)
const existing = downloads.get(key)
if (!existing) {
return
}
downloads.set(key, { ...existing, ...update.changes })
set(downloadRecordsAtom, downloads)
}
)
export const removeDownloadAtom = atom(null, (get, set, id: string) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.delete(recordKey('active', id))
set(downloadRecordsAtom, downloads)
})
export const clearCompletedAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'active' && item.status === 'completed') {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.set(recordKey('history', item.id), toHistoryRecord(item))
set(downloadRecordsAtom, downloads)
})
export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
const downloads = new Map(get(downloadRecordsAtom))
downloads.delete(recordKey('history', id))
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history') {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsByStatusAtom = atom(
null,
(get, set, status: DownloadHistoryItem['status']) => {
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history' && item.status === status) {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
}
)
export const downloadsArrayAtom = atom((get) => {
const downloads = get(downloadRecordsAtom)
return Array.from(downloads.values()).sort((a, b) => b.createdAt - a.createdAt)
})
export const activeDownloadsArrayAtom = atom((get) =>
get(downloadsArrayAtom).filter((item) => item.entryType === 'active')
)
export const historyDownloadsArrayAtom = atom((get) =>
get(downloadsArrayAtom).filter((item) => item.entryType === 'history')
)
export const historyStatsAtom = atom((get) => {
const history = get(historyDownloadsArrayAtom)
return history.reduce(
(acc, item) => {
acc.total += 1
if (item.status === 'completed') acc.completed += 1
if (item.status === 'error') acc.error += 1
if (item.status === 'cancelled') acc.cancelled += 1
return acc
},
{ total: 0, completed: 0, error: 0, cancelled: 0 }
)
})
export const downloadStatsAtom = atom((get) => {
const downloads = get(downloadsArrayAtom)
return downloads.reduce(
(acc, item) => {
acc.total += 1
if (
item.entryType === 'active' &&
(item.status === 'downloading' || item.status === 'processing' || item.status === 'pending')
) {
acc.active += 1
}
if (item.status === 'completed') acc.completed += 1
if (item.status === 'error') acc.error += 1
if (item.status === 'cancelled') acc.cancelled += 1
return acc
},
{ total: 0, active: 0, completed: 0, error: 0, cancelled: 0 }
)
})
export const activeDownloadsCountAtom = atom((get) => {
const downloads = get(downloadRecordsAtom)
let count = 0
for (const item of downloads.values()) {
if (
item.entryType === 'active' &&
(item.status === 'downloading' || item.status === 'processing')
) {
count++
}
}
return count
})

View File

@@ -0,0 +1,45 @@
import { atom } from 'jotai'
import type { AppSettings } from '../../../shared/types'
import { defaultSettings } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
// Settings atom
export const settingsAtom = atom<AppSettings>(defaultSettings)
// Load settings from main process
export const loadSettingsAtom = atom(null, async (_get, set) => {
try {
const settings = await ipcServices.settings.getAll()
set(settingsAtom, settings)
} catch (error) {
console.error('Failed to load settings:', error)
}
})
// Save a specific setting
export const saveSettingAtom = atom(
null,
async (get, set, update: { key: keyof AppSettings; value: AppSettings[keyof AppSettings] }) => {
try {
await ipcServices.settings.set(update.key, update.value)
const settings = get(settingsAtom)
set(settingsAtom, { ...settings, [update.key]: update.value })
} catch (error) {
console.error('Failed to save setting:', error)
}
}
)
// Save all settings
export const saveAllSettingsAtom = atom(
null,
async (get, set, newSettings: Partial<AppSettings>) => {
try {
await ipcServices.settings.setAll(newSettings)
const settings = get(settingsAtom)
set(settingsAtom, { ...settings, ...newSettings })
} catch (error) {
console.error('Failed to save settings:', error)
}
}
)

View File

@@ -0,0 +1,35 @@
import { atom } from 'jotai'
import type { VideoInfo } from '../../../shared/types'
import { ipcServices } from '../lib/ipc'
// Current video info being prepared for download
export const currentVideoInfoAtom = atom<VideoInfo | null>(null)
// Loading state for video info
export const videoInfoLoadingAtom = atom<boolean>(false)
// Error state for video info
export const videoInfoErrorAtom = atom<string | null>(null)
// Fetch video info
export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
set(videoInfoLoadingAtom, true)
set(videoInfoErrorAtom, null)
set(currentVideoInfoAtom, null)
try {
const info = await ipcServices.download.getVideoInfo(url)
set(currentVideoInfoAtom, info)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'
set(videoInfoErrorAtom, errorMessage)
} finally {
set(videoInfoLoadingAtom, false)
}
})
// Clear video info
export const clearVideoInfoAtom = atom(null, (_get, set) => {
set(currentVideoInfoAtom, null)
set(videoInfoErrorAtom, null)
})

175
src/shared/types/index.ts Normal file
View File

@@ -0,0 +1,175 @@
// Download related types
export interface VideoFormat {
format_id: string
ext: string
height?: number
width?: number
fps?: number
vcodec?: string
acodec?: string
filesize?: number
filesize_approx?: number
format_note?: string
video_ext?: string
audio_ext?: string
tbr?: number
quality?: number
}
export interface VideoInfo {
id: string
title: string
thumbnail?: string
duration?: number
formats: VideoFormat[]
extractor_key?: string
webpage_url?: string
description?: string
view_count?: number
uploader?: string
}
export interface DownloadProgress {
percent: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
export type DownloadStatus =
| 'pending'
| 'downloading'
| 'processing'
| 'completed'
| 'error'
| 'cancelled'
export interface DownloadItem {
id: string
url: string
title: string
thumbnail?: string
type: 'video' | 'audio' | 'extract'
status: DownloadStatus
progress?: DownloadProgress
error?: string
outputPath?: string
speed?: string
// Enhanced video information
duration?: number
fileSize?: number
format?: string
quality?: string
codec?: string
// Timestamps
createdAt: number
startedAt?: number
completedAt?: number
// Additional metadata
description?: string
channel?: string
uploader?: string
viewCount?: number
tags?: string[]
// Download-specific format info
selectedFormat?: VideoFormat
}
export interface DownloadHistoryItem {
id: string
url: string
title: string
thumbnail?: string
type: 'video' | 'audio' | 'extract'
status: DownloadStatus
outputPath?: string
fileSize?: number
duration?: number
downloadedAt: number
completedAt?: number
error?: string
// Enhanced video information
format?: string
quality?: string
codec?: string
// Additional metadata
description?: string
channel?: string
uploader?: string
viewCount?: number
tags?: string[]
// Download-specific format info
selectedFormat?: VideoFormat
}
export interface DownloadOptions {
url: string
type: 'video' | 'audio' | 'extract'
format?: string
audioFormat?: string
extractFormat?: string
extractQuality?: string
startTime?: string
endTime?: string
downloadSubs?: boolean
outputPath?: string
}
export interface PlaylistInfo {
id: string
title: string
entries: Array<{
id: string
title: string
url: string
}>
entryCount: number
}
export interface PlaylistDownloadOptions {
url: string
type: 'video' | 'audio'
format?: string
startIndex?: number
endIndex?: number
filenameFormat?: string
folderFormat?: string
}
// Settings types
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
export interface AppSettings {
downloadPath: string
showMoreFormats: boolean
maxConcurrentDownloads: number
browserForCookies: string
proxy: string
configPath: string
betaProgram: boolean
language: string
theme: string
oneClickDownload: boolean
oneClickDownloadType: 'video' | 'audio'
oneClickQuality: OneClickQualityPreset
closeToTray: boolean
autoUpdate: boolean
}
export const defaultSettings: AppSettings = {
downloadPath: '',
showMoreFormats: false,
maxConcurrentDownloads: 5,
browserForCookies: 'none',
proxy: '',
configPath: '',
betaProgram: false,
language: 'en',
theme: 'system',
oneClickDownload: false,
oneClickDownloadType: 'video',
oneClickQuality: 'auto',
closeToTray: false,
autoUpdate: true
}

2
src/shared/types/ipc.ts Normal file
View File

@@ -0,0 +1,2 @@
// Re-export the IpcServices type from main process
export type { IpcServices } from '../../main/ipc'

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"@renderer/*": ["src/renderer/src/*"],
"@shared/*": ["src/shared/*"]
}
}
}

Some files were not shown because too many files have changed in this diff Show More