chore: add initial project configuration and structure
This commit is contained in:
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
6
.gitattributes
vendored
@@ -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
37
.github/workflows/ci.yml
vendored
Normal 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
91
.github/workflows/release.yml
vendored
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
|
||||
4
.npmrc
Normal file
4
.npmrc
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"]
|
||||
}
|
||||
29
.vscode/settings.json
vendored
Normal file
29
.vscode/settings.json
vendored
Normal 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
5
AGENTS.md
Normal 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
49
biome.json
Normal 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
21
components.json
Normal 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
3
dev-app-update.yml
Normal 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
40
electron-builder.yml
Normal 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
40
electron.vite.config.ts
Normal 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
12
entitlements.mac.plist
Normal 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>
|
||||
87
package.json
Normal file
87
package.json
Normal 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
6621
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
resources/.gitignore
vendored
Normal file
9
resources/.gitignore
vendored
Normal 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
55
resources/README.md
Normal 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
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
36
src/main/assets.d.ts
vendored
Normal 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
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
230
src/main/index.ts
Normal 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
24
src/main/ipc/index.ts
Normal 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>
|
||||
37
src/main/ipc/services/app-service.ts
Normal file
37
src/main/ipc/services/app-service.ts
Normal 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 }
|
||||
53
src/main/ipc/services/download-service.ts
Normal file
53
src/main/ipc/services/download-service.ts
Normal 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 }
|
||||
176
src/main/ipc/services/file-system-service.ts
Normal file
176
src/main/ipc/services/file-system-service.ts
Normal 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 }
|
||||
315
src/main/ipc/services/history-service.ts
Normal file
315
src/main/ipc/services/history-service.ts
Normal 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 }
|
||||
43
src/main/ipc/services/settings-service.ts
Normal file
43
src/main/ipc/services/settings-service.ts
Normal 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 }
|
||||
20
src/main/ipc/services/thumbnail-service.ts
Normal file
20
src/main/ipc/services/thumbnail-service.ts
Normal 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 }
|
||||
57
src/main/ipc/services/update-service.ts
Normal file
57
src/main/ipc/services/update-service.ts
Normal 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 }
|
||||
42
src/main/ipc/services/window-service.ts
Normal file
42
src/main/ipc/services/window-service.ts
Normal 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 }
|
||||
145
src/main/lib/download-queue.ts
Normal file
145
src/main/lib/download-queue.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
116
src/main/lib/history-manager.ts
Normal file
116
src/main/lib/history-manager.ts
Normal 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()
|
||||
137
src/main/lib/thumbnail-cache.ts
Normal file
137
src/main/lib/thumbnail-cache.ts
Normal 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()
|
||||
121
src/main/lib/ytdlp-manager.ts
Normal file
121
src/main/lib/ytdlp-manager.ts
Normal 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
51
src/main/settings.ts
Normal 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
138
src/main/tray.ts
Normal 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
13
src/preload/index.d.ts
vendored
Normal 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
45
src/preload/index.ts
Normal 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
16
src/renderer/index.html
Normal 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>
|
||||
BIN
src/renderer/public/app-icon.png
Normal file
BIN
src/renderer/public/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
65
src/renderer/src/App.tsx
Normal file
65
src/renderer/src/App.tsx
Normal 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
|
||||
BIN
src/renderer/src/assets/app-icon.png
Normal file
BIN
src/renderer/src/assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
12
src/renderer/src/assets/global.css
Normal file
12
src/renderer/src/assets/global.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css';
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
23
src/renderer/src/assets/main.css
Normal file
23
src/renderer/src/assets/main.css
Normal 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;
|
||||
}
|
||||
|
||||
160
src/renderer/src/assets/theme.css
Normal file
160
src/renderer/src/assets/theme.css
Normal 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);
|
||||
}
|
||||
10
src/renderer/src/assets/title-bar.css
Normal file
10
src/renderer/src/assets/title-bar.css
Normal 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;
|
||||
}
|
||||
418
src/renderer/src/components/download/DownloadItem.tsx
Normal file
418
src/renderer/src/components/download/DownloadItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
src/renderer/src/components/download/UnifiedDownloadHistory.tsx
Normal file
114
src/renderer/src/components/download/UnifiedDownloadHistory.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/renderer/src/components/ui/accordion.tsx
Normal file
61
src/renderer/src/components/ui/accordion.tsx
Normal 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 }
|
||||
36
src/renderer/src/components/ui/badge.tsx
Normal file
36
src/renderer/src/components/ui/badge.tsx
Normal 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 }
|
||||
48
src/renderer/src/components/ui/button.tsx
Normal file
48
src/renderer/src/components/ui/button.tsx
Normal 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 }
|
||||
54
src/renderer/src/components/ui/card.tsx
Normal file
54
src/renderer/src/components/ui/card.tsx
Normal 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 }
|
||||
26
src/renderer/src/components/ui/checkbox.tsx
Normal file
26
src/renderer/src/components/ui/checkbox.tsx
Normal 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 }
|
||||
101
src/renderer/src/components/ui/dialog.tsx
Normal file
101
src/renderer/src/components/ui/dialog.tsx
Normal 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
|
||||
}
|
||||
225
src/renderer/src/components/ui/dropdown-menu.tsx
Normal file
225
src/renderer/src/components/ui/dropdown-menu.tsx
Normal 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
|
||||
}
|
||||
67
src/renderer/src/components/ui/image-with-placeholder.tsx
Normal file
67
src/renderer/src/components/ui/image-with-placeholder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/renderer/src/components/ui/input.tsx
Normal file
21
src/renderer/src/components/ui/input.tsx
Normal 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 }
|
||||
182
src/renderer/src/components/ui/item.tsx
Normal file
182
src/renderer/src/components/ui/item.tsx
Normal 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
|
||||
}
|
||||
18
src/renderer/src/components/ui/label.tsx
Normal file
18
src/renderer/src/components/ui/label.tsx
Normal 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 }
|
||||
25
src/renderer/src/components/ui/progress.tsx
Normal file
25
src/renderer/src/components/ui/progress.tsx
Normal 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 }
|
||||
53
src/renderer/src/components/ui/scroll-area.tsx
Normal file
53
src/renderer/src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
149
src/renderer/src/components/ui/select.tsx
Normal file
149
src/renderer/src/components/ui/select.tsx
Normal 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
|
||||
}
|
||||
25
src/renderer/src/components/ui/separator.tsx
Normal file
25
src/renderer/src/components/ui/separator.tsx
Normal 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 }
|
||||
207
src/renderer/src/components/ui/sidebar.tsx
Normal file
207
src/renderer/src/components/ui/sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
src/renderer/src/components/ui/sonner.tsx
Normal file
27
src/renderer/src/components/ui/sonner.tsx
Normal 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 }
|
||||
26
src/renderer/src/components/ui/switch.tsx
Normal file
26
src/renderer/src/components/ui/switch.tsx
Normal 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 }
|
||||
53
src/renderer/src/components/ui/tabs.tsx
Normal file
53
src/renderer/src/components/ui/tabs.tsx
Normal 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 }
|
||||
17
src/renderer/src/components/ui/textarea.tsx
Normal file
17
src/renderer/src/components/ui/textarea.tsx
Normal 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 }
|
||||
79
src/renderer/src/components/ui/title-bar.tsx
Normal file
79
src/renderer/src/components/ui/title-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/renderer/src/components/ui/tooltip.tsx
Normal file
56
src/renderer/src/components/ui/tooltip.tsx
Normal 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 }
|
||||
100
src/renderer/src/components/video/AdvancedOptions.tsx
Normal file
100
src/renderer/src/components/video/AdvancedOptions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
src/renderer/src/components/video/AudioExtractor.tsx
Normal file
87
src/renderer/src/components/video/AudioExtractor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
225
src/renderer/src/components/video/FormatSelector.tsx
Normal file
225
src/renderer/src/components/video/FormatSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
src/renderer/src/components/video/VideoInfoCard.tsx
Normal file
231
src/renderer/src/components/video/VideoInfoCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
src/renderer/src/data/popularSites.ts
Normal file
98
src/renderer/src/data/popularSites.ts
Normal 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
2
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
41
src/renderer/src/hooks/use-cached-thumbnail.ts
Normal file
41
src/renderer/src/hooks/use-cached-thumbnail.ts
Normal 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
|
||||
}
|
||||
36
src/renderer/src/hooks/use-history-sync.ts
Normal file
36
src/renderer/src/hooks/use-history-sync.ts
Normal 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])
|
||||
}
|
||||
129
src/renderer/src/hooks/use-ipc-example.ts
Normal file
129
src/renderer/src/hooks/use-ipc-example.ts
Normal 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
18
src/renderer/src/i18n.ts
Normal 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
|
||||
45
src/renderer/src/lib/ipc.ts
Normal file
45
src/renderer/src/lib/ipc.ts
Normal 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 }
|
||||
6
src/renderer/src/lib/utils.ts
Normal file
6
src/renderer/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
293
src/renderer/src/locales/en.json
Normal file
293
src/renderer/src/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
282
src/renderer/src/locales/zh.json
Normal file
282
src/renderer/src/locales/zh.json
Normal 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
17
src/renderer/src/main.tsx
Normal 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>
|
||||
)
|
||||
285
src/renderer/src/pages/About.tsx
Normal file
285
src/renderer/src/pages/About.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
662
src/renderer/src/pages/Home.tsx
Normal file
662
src/renderer/src/pages/Home.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
341
src/renderer/src/pages/Settings.tsx
Normal file
341
src/renderer/src/pages/Settings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/renderer/src/pages/SupportedSites.tsx
Normal file
58
src/renderer/src/pages/SupportedSites.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
src/renderer/src/store/downloads.ts
Normal file
177
src/renderer/src/store/downloads.ts
Normal 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
|
||||
})
|
||||
45
src/renderer/src/store/settings.ts
Normal file
45
src/renderer/src/store/settings.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
35
src/renderer/src/store/video.ts
Normal file
35
src/renderer/src/store/video.ts
Normal 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
175
src/shared/types/index.ts
Normal 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
2
src/shared/types/ipc.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export the IpcServices type from main process
|
||||
export type { IpcServices } from '../../main/ipc'
|
||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal 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
Reference in New Issue
Block a user