Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11eaaf0f88 | ||
|
|
512b9d1175 | ||
|
|
a1307bdd0b | ||
|
|
06132a0704 | ||
|
|
a7e4eb865b | ||
|
|
5246ab8369 | ||
|
|
baa887da2e | ||
|
|
be40f087ad | ||
|
|
ad62bd07aa | ||
|
|
365b9c0660 | ||
|
|
a1f5e0480c | ||
|
|
00a7950e6a | ||
|
|
598ed9c964 | ||
|
|
2b3edab4eb | ||
|
|
d0bdfce803 | ||
|
|
17a4568492 | ||
|
|
840a432b7f | ||
|
|
cc17e29fbe | ||
|
|
dd5f0a729b | ||
|
|
9d72caee44 | ||
|
|
661165bd65 | ||
|
|
e8cd89d7e4 | ||
|
|
4caf8efa72 | ||
|
|
ed707df837 | ||
|
|
0821aea72b | ||
|
|
4dfac6bb34 | ||
|
|
8ad41eebe2 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
@@ -25,11 +25,13 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
- name: Download yt-dlp binaries
|
||||
run: |
|
||||
# Download yt-dlp.exe for Windows
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
|
||||
- name: Build check
|
||||
run: pnpm run build
|
||||
- name: build-win
|
||||
run: pnpm run build:win
|
||||
|
||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
os: [windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
@@ -36,15 +36,12 @@ jobs:
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
|
||||
|
||||
# Download yt-dlp for macOS (for cross-platform builds)
|
||||
# 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
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o resources/yt-dlp_macos
|
||||
chmod +x resources/yt-dlp_macos
|
||||
|
||||
# Download yt-dlp for Linux (for cross-platform builds)
|
||||
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
||||
chmod +x resources/yt-dlp_linux
|
||||
|
||||
- name: Type check
|
||||
run: pnpm run typecheck
|
||||
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
|
||||
# chmod +x resources/yt-dlp_linux
|
||||
|
||||
- name: Lint and format check
|
||||
run: pnpm run check
|
||||
@@ -53,9 +50,9 @@ jobs:
|
||||
# if: matrix.os == 'ubuntu-latest'
|
||||
# run: pnpm run build:linux
|
||||
|
||||
# - name: build-mac
|
||||
# if: matrix.os == 'macos-latest'
|
||||
# run: pnpm run build:mac
|
||||
- name: build-mac
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: pnpm run build:mac
|
||||
|
||||
- name: build-win
|
||||
if: matrix.os == 'windows-latest'
|
||||
@@ -64,7 +61,7 @@ jobs:
|
||||
- name: release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
|
||||
77
CONTRIBUTING.md
Normal file
77
CONTRIBUTING.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Contributing to VidBee
|
||||
|
||||
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
|
||||
|
||||
## Getting Ready
|
||||
- Use Node.js 18+ and pnpm 8+.
|
||||
- Install dependencies with `pnpm install`.
|
||||
- Run `pnpm dev` to test changes locally.
|
||||
|
||||
## Tech Stack
|
||||
- Runtime: Electron 38, electron-vite, electron-builder.
|
||||
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
|
||||
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
|
||||
|
||||
## Local Development
|
||||
- Use `pnpm install` to pull dependencies after cloning.
|
||||
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
|
||||
- Preview the production build locally with `pnpm start`.
|
||||
|
||||
## Useful Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
| --- | --- |
|
||||
| `pnpm run typecheck` | Type-check the main and renderer projects. |
|
||||
| `pnpm build` | Run type checks and produce production bundles. |
|
||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
|
||||
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
|
||||
| `pnpm run check` | Format and lint the codebase with Biome. |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
|-- main/ # Electron main process, IPC services, configuration
|
||||
|-- preload/ # Context bridge and preload helpers
|
||||
`-- renderer/
|
||||
|-- src/
|
||||
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
|
||||
| |-- components/ # UI components, download views, shared controls
|
||||
| |-- data/ # Static datasets such as popularSites.ts
|
||||
| |-- hooks/ # Custom hooks and global atoms
|
||||
| |-- lib/ # Utilities shared across the renderer
|
||||
| `-- assets/ # Global styles and icons
|
||||
`-- index.html
|
||||
```
|
||||
|
||||
## Internationalization
|
||||
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
|
||||
- Only update strings in `src/renderer/src/locales/en.json`; maintainers handle the other locales.
|
||||
- Keep copy edits focused and avoid removing translation keys without discussion.
|
||||
|
||||
## Configuration and Storage
|
||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
|
||||
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
|
||||
- Logs are recorded with `electron-log` to simplify troubleshooting.
|
||||
|
||||
## Packaging
|
||||
- Build production bundles with `pnpm build`.
|
||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
||||
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
|
||||
|
||||
## Working on Changes
|
||||
- Keep each pull request focused on a single problem or feature.
|
||||
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
|
||||
- Write comments and console messages in English only.
|
||||
- When updating copy in the app, adjust strings in `src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
|
||||
|
||||
## Opening Issues
|
||||
- Search existing issues to avoid duplicates.
|
||||
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
|
||||
|
||||
## Submitting Pull Requests
|
||||
- Explain the motivation and impact of the change in the description.
|
||||
- Mention any user facing updates or migrations.
|
||||
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
|
||||
|
||||
We appreciate every contribution that keeps VidBee simple and reliable.
|
||||
133
README.md
133
README.md
@@ -1,97 +1,90 @@
|
||||
# VidBee
|
||||
# 🐝 VidBee
|
||||
|
||||
<div align="center">
|
||||
<img src="build/icon.png" alt="VidBee icon" width="120" />
|
||||
<h3>A minimal Electron downloader for video and audio</h3>
|
||||
<h3>Download videos from almost any website worldwide</h3>
|
||||
<p>Best-in-class UI interface - Clean, intuitive, and powerful</p>
|
||||
<p>Built with Electron, React, TypeScript, Tailwind CSS, and shadcn/ui.</p>
|
||||
</div>
|
||||
|
||||
## Features
|
||||
- Download single videos, audio-only tracks, or entire playlists through a unified flow.
|
||||
- Queue multiple jobs with progress tracking, pause, resume, retry, and download history.
|
||||
- Detect platform-friendly formats automatically and store files in custom locations.
|
||||
- Quick actions for popular sites plus manual URL entry for anything yt-dlp supports.
|
||||
- Theme toggle (system, light, dark) and localized interface in English and Simplified Chinese.
|
||||
- Desktop-native touches such as tray integration, update checks, and persistent settings.
|
||||
## ✨ Core Features
|
||||
|
||||
## Tech Stack
|
||||
- **Runtime:** Electron 38, electron-vite, electron-builder.
|
||||
- **Frontend:** React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
|
||||
- **Tooling:** TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
|
||||
### 🌍 Global Video Download Support
|
||||
|
||||
## Getting Started
|
||||
### Prerequisites
|
||||
- Node.js 18 or newer
|
||||
- pnpm 8 or newer
|
||||
- **1000+ Sites Supported** - Download videos from almost any website worldwide through yt-dlp engine
|
||||
- **Smart Platform Detection** - Automatically detect video platforms and optimize download parameters
|
||||
- **Multi-format Support** - Videos, audio tracks, playlists to meet all download needs
|
||||
- Localized interface support in many languages
|
||||
|
||||
### 🎨 Best-in-class UI Experience
|
||||
|
||||
- **Modern Design** - Clean and beautiful interface
|
||||
- **Intuitive Operations** - One-click pause/resume/retry
|
||||
- **Real-time Progress** - Detailed download progress tracking and status management
|
||||
- **Theme Switching** - Support for system/light/dark themes for comfortable viewing
|
||||
|
||||
## 📥 Download & Install
|
||||
|
||||
1. **Download the latest release** from [GitHub Releases](https://github.com/nexmoe/VidBee/releases)
|
||||
2. **Choose your platform**:
|
||||
- **Windows**: Download `vidbee-x.x.x-setup.exe`
|
||||
- **macOS**: Download `vidbee-x.x.x.dmg`
|
||||
- **Linux**: Download `vidbee-x.x.x.AppImage`
|
||||
3. **Install and run** the application
|
||||
|
||||
### 🍎 macOS Installation Notes
|
||||
|
||||
After downloading and installing VidBee on macOS, you may encounter a "file is damaged" error when trying to run the application. This is due to macOS security restrictions on applications downloaded from the internet.
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
pnpm install
|
||||
xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
||||
```
|
||||
|
||||
### Run the app in development
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||
|
||||
The Electron app and Vite dev server launch together with hot module replacement.
|
||||
## 📸 Screenshots
|
||||
|
||||
## Useful Scripts
|
||||
| Command | Description |
|
||||

|
||||
*Clean and intuitive interface with download queue management*
|
||||
|
||||

|
||||
*Comprehensive download queue with progress tracking and status management*
|
||||
|
||||
## 🌐 Supported Sites
|
||||
|
||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||
|
||||
| Video Platforms | Audio & Other Platforms |
|
||||
| --- | --- |
|
||||
| `pnpm dev` | Run the Electron and Vite development environment. |
|
||||
| `pnpm start` | Preview the production build locally. |
|
||||
| `pnpm run typecheck` | Type-check the main and renderer projects. |
|
||||
| `pnpm build` | Run type checks and produce production bundles. |
|
||||
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
|
||||
| `pnpm build:unpack` | Produce unpacked output directories. |
|
||||
| `pnpm run check` | Format and lint the codebase with Biome. |
|
||||
| YouTube | YouTube Music |
|
||||
| TikTok | SoundCloud |
|
||||
| Facebook | Mixcloud |
|
||||
| Instagram | Bandcamp |
|
||||
| X (Twitter) | Reddit |
|
||||
| Vimeo | |
|
||||
| Dailymotion | |
|
||||
| Twitch | |
|
||||
| LinkedIn | |
|
||||
| Pinterest | |
|
||||
| Tumblr | |
|
||||
| Niconico | |
|
||||
| Kick | |
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
├─ main/ # Electron main process, IPC services, configuration
|
||||
├─ preload/ # Context bridge and preload helpers
|
||||
└─ renderer/
|
||||
├─ src/
|
||||
│ ├─ pages/ # Application routes (Home, Settings, Playlist, etc.)
|
||||
│ ├─ components/ # UI components, download views, shared controls
|
||||
│ ├─ data/ # Static datasets such as popularSites.ts
|
||||
│ ├─ hooks/ # Custom hooks and global atoms
|
||||
│ ├─ lib/ # Utilities shared across the renderer
|
||||
│ └─ assets/ # Global styles and icons
|
||||
└─ index.html
|
||||
```
|
||||
> **💡 Note:** VidBee uses [yt-dlp](https://github.com/yt-dlp/yt-dlp) under the hood, which supports 1000+ sites. For the complete list, visit the [yt-dlp supported sites documentation](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md).
|
||||
|
||||
## Internationalization
|
||||
The renderer uses i18next with English (`en`) and Simplified Chinese (`zh-CN`) namespaces. Update strings in `src/renderer/src/locales/en.json`; other locales are maintained separately.
|
||||
## ?? Contributing
|
||||
|
||||
## Configuration and Storage
|
||||
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
|
||||
- User-facing preferences such as download paths and themes live in `src/main/settings.ts` and related services.
|
||||
- Logs are recorded with `electron-log` to simplify troubleshooting.
|
||||
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines on reporting issues, proposing features, and opening pull requests. It also covers the tech stack, development workflow, scripts, internationalization notes, configuration guidance, and packaging steps.
|
||||
|
||||
## Packaging
|
||||
Run one of the following commands after a successful build:
|
||||
## 📄 License
|
||||
|
||||
```bash
|
||||
pnpm build:win
|
||||
pnpm build:mac
|
||||
pnpm build:linux
|
||||
```
|
||||
|
||||
Artifacts are generated under `dist/`. Use `pnpm build:unpack` to create unpacked directories for manual inspection.
|
||||
|
||||
## Contributing
|
||||
Issues and pull requests are welcome. Keep changes focused, document user facing updates, and run `pnpm run check` before opening a PR.
|
||||
|
||||
## License
|
||||
This project is distributed under the MIT License. See `LICENSE` for details.
|
||||
|
||||
## Thanks
|
||||
## 🙏 Thanks
|
||||
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
- [Electron](https://www.electronjs.org/)
|
||||
- [React](https://react.dev/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [shadcn/ui](https://ui.shadcn.com/)
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
||||
|
||||
BIN
build/icon.icns
BIN
build/icon.icns
Binary file not shown.
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "0.1.6",
|
||||
"version": "0.3.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
@@ -11,13 +11,13 @@
|
||||
"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",
|
||||
"dev": "node scripts/set-console-encoding.js && 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",
|
||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac",
|
||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||
"release": "pnpm run check && bumpp"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.5.3",
|
||||
"jotai": "^2.15.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -86,6 +86,9 @@ importers:
|
||||
electron-updater:
|
||||
specifier: ^6.3.9
|
||||
version: 6.6.2
|
||||
flag-icons:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
i18next:
|
||||
specifier: ^25.5.3
|
||||
version: 25.6.0(typescript@5.9.3)
|
||||
@@ -2056,6 +2059,9 @@ packages:
|
||||
filelist@1.0.4:
|
||||
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
|
||||
|
||||
flag-icons@7.5.0:
|
||||
resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -5350,6 +5356,8 @@ snapshots:
|
||||
dependencies:
|
||||
minimatch: 5.1.6
|
||||
|
||||
flag-icons@7.5.0: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
BIN
resources/tray-icon.png
Normal file
BIN
resources/tray-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 924 B |
BIN
screenshots/download-queue.png
Normal file
BIN
screenshots/download-queue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
screenshots/main-interface.png
Normal file
BIN
screenshots/main-interface.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
48
scripts/check-ytdlp.js
Normal file
48
scripts/check-ytdlp.js
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
/**
|
||||
* Check if yt-dlp binary exists in resources directory
|
||||
* Usage: node scripts/check-ytdlp.js [platform]
|
||||
* Platform options: win, mac, linux
|
||||
* Exit with error code 1 if not found
|
||||
*/
|
||||
function checkYtDlpExists(platform) {
|
||||
const platformMap = {
|
||||
win: 'yt-dlp.exe',
|
||||
mac: 'yt-dlp_macos',
|
||||
linux: 'yt-dlp_linux'
|
||||
}
|
||||
|
||||
const filename = platformMap[platform]
|
||||
if (!filename) {
|
||||
console.error('❌ Error: Invalid platform specified!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ytdlpPath = path.join(__dirname, '..', 'resources', filename)
|
||||
|
||||
if (!fs.existsSync(ytdlpPath)) {
|
||||
console.error(`❌ Error: resources/${filename} not found!`)
|
||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
||||
console.error('You can download it from: https://github.com/yt-dlp/yt-dlp/releases/latest')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`✅ ${filename} found in resources/ directory`)
|
||||
}
|
||||
|
||||
// Get platform from command line arguments
|
||||
const platform = process.argv[2]
|
||||
|
||||
if (!platform) {
|
||||
console.error('❌ Error: Platform argument is required!')
|
||||
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkYtDlpExists(platform)
|
||||
28
scripts/set-console-encoding.js
Normal file
28
scripts/set-console-encoding.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 设置控制台编码为 UTF-8,解决中文乱码问题
|
||||
* 这个脚本在 Windows 上设置控制台代码页为 UTF-8
|
||||
*/
|
||||
|
||||
const { exec } = require('node:child_process')
|
||||
const os = require('node:os')
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
console.log('Setting console encoding to UTF-8...')
|
||||
|
||||
// 设置控制台代码页为 UTF-8 (65001)
|
||||
exec('chcp 65001', (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.warn('Failed to set console code page:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.warn('Console code page setting warning:', stderr)
|
||||
}
|
||||
|
||||
console.log('Console encoding set to UTF-8')
|
||||
console.log('Output:', stdout)
|
||||
})
|
||||
} else {
|
||||
console.log('Not on Windows, no console encoding change needed')
|
||||
}
|
||||
44
src/main/config/logger-config.ts
Normal file
44
src/main/config/logger-config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import log from 'electron-log/main'
|
||||
|
||||
/**
|
||||
* Configure electron-log
|
||||
* Set log format, file path, transport methods, etc.
|
||||
*/
|
||||
export function configureLogger() {
|
||||
// Configure console output format - support colors and scope, time in gray
|
||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
||||
|
||||
// Enable console colors
|
||||
log.transports.console.useStyles = true
|
||||
|
||||
// Configure file output format - include scope information
|
||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
||||
|
||||
// Set log levels
|
||||
// Development: show all logs
|
||||
// Production: show info level and above only
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
log.transports.console.level = isDev ? 'silly' : 'info'
|
||||
log.transports.file.level = isDev ? 'silly' : 'info'
|
||||
|
||||
// Enable IPC transport in development environment to show renderer process logs in main process console
|
||||
if (isDev) {
|
||||
log.transports.ipc.level = 'silly'
|
||||
} else {
|
||||
log.transports.ipc.level = false
|
||||
}
|
||||
|
||||
// Set maximum log file size (10MB)
|
||||
log.transports.file.maxSize = 10 * 1024 * 1024
|
||||
|
||||
// Enable error catching - catch unhandled errors and rejected promises
|
||||
log.errorHandler.startCatching({
|
||||
showDialog: false, // Don't show error dialog, only log to file
|
||||
onError: (options) => {
|
||||
log.error('Unhandled error caught by electron-log:', options.error)
|
||||
log.error('App versions:', options.versions)
|
||||
}
|
||||
})
|
||||
|
||||
log.info('Log file location:', log.transports.file.getFile().path)
|
||||
}
|
||||
109
src/main/download-engine/args-builder.ts
Normal file
109
src/main/download-engine/args-builder.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
|
||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||
return format
|
||||
}
|
||||
|
||||
if (!format || format === 'best') {
|
||||
if (audioFormat === 'none') {
|
||||
return 'bestvideo+none'
|
||||
}
|
||||
if (!audioFormat || audioFormat === 'best') {
|
||||
return 'best'
|
||||
}
|
||||
return `bestvideo+${audioFormat}`
|
||||
}
|
||||
|
||||
if (audioFormat === 'none') {
|
||||
return `${format}+none`
|
||||
}
|
||||
|
||||
const audio = audioFormat && audioFormat !== 'best' ? audioFormat : 'bestaudio'
|
||||
return `${format}+${audio}`
|
||||
}
|
||||
|
||||
export const resolveAudioFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
|
||||
if (!format) {
|
||||
return 'bestaudio'
|
||||
}
|
||||
|
||||
if (format.includes('/') || format.includes('+') || format.includes('[')) {
|
||||
return format
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
export const buildDownloadArgs = (
|
||||
options: DownloadOptions,
|
||||
downloadPath: string,
|
||||
settings: AppSettings
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Format selection
|
||||
if (options.type === 'video') {
|
||||
args.push('-f', resolveVideoFormatSelector(options))
|
||||
} else if (options.type === 'audio') {
|
||||
args.push('-f', resolveAudioFormatSelector(options))
|
||||
} else if (options.type === 'extract') {
|
||||
args.push('-x')
|
||||
args.push('--audio-format', options.extractFormat || 'mp3')
|
||||
args.push('--audio-quality', options.extractQuality || '5')
|
||||
}
|
||||
|
||||
// Time range
|
||||
if (options.startTime || options.endTime) {
|
||||
const start = options.startTime || '0'
|
||||
const end = options.endTime || ''
|
||||
args.push('--download-sections', `*${start}-${end || ''}`)
|
||||
}
|
||||
|
||||
// Subtitles
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs', '--sub-langs', 'all')
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s.%(ext)s')
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
args.push('--no-part')
|
||||
args.push('--no-playlist-reverse')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
args.push('--windows-filenames')
|
||||
}
|
||||
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
if (settings.configPath) {
|
||||
args.push('--config-location', settings.configPath)
|
||||
}
|
||||
|
||||
args.push(options.url)
|
||||
|
||||
return args
|
||||
}
|
||||
210
src/main/download-engine/format-utils.ts
Normal file
210
src/main/download-engine/format-utils.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
DownloadOptions,
|
||||
OneClickQualityPreset,
|
||||
VideoFormat
|
||||
} from '../../shared/types'
|
||||
|
||||
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 selectVideoFormatForPreset = (
|
||||
formats: VideoFormat[],
|
||||
preset: OneClickQualityPreset
|
||||
): VideoFormat | undefined => {
|
||||
if (formats.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...formats].sort((a, b) => {
|
||||
const heightDiff = (b.height ?? 0) - (a.height ?? 0)
|
||||
if (heightDiff !== 0) return heightDiff
|
||||
const fpsDiff = (b.fps ?? 0) - (a.fps ?? 0)
|
||||
if (fpsDiff !== 0) return fpsDiff
|
||||
return (b.tbr ?? 0) - (a.tbr ?? 0)
|
||||
})
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
const heightLimit = qualityPresetToVideoHeight[preset]
|
||||
if (!heightLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const withinLimit = sorted.find((format) => {
|
||||
const height = format.height ?? 0
|
||||
return height > 0 && height <= heightLimit
|
||||
})
|
||||
|
||||
return withinLimit ?? sorted[0]
|
||||
}
|
||||
|
||||
const selectAudioFormatForPreset = (
|
||||
formats: VideoFormat[],
|
||||
preset: OneClickQualityPreset
|
||||
): VideoFormat | undefined => {
|
||||
if (formats.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sorted = [...formats].sort((a, b) => {
|
||||
const bitrateDiff = (b.tbr ?? 0) - (a.tbr ?? 0)
|
||||
if (bitrateDiff !== 0) return bitrateDiff
|
||||
const sizeA = a.filesize ?? a.filesize_approx ?? 0
|
||||
const sizeB = b.filesize ?? b.filesize_approx ?? 0
|
||||
if (sizeB !== sizeA) return sizeB - sizeA
|
||||
return 0
|
||||
})
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||
if (!abrLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const withinLimit = sorted.find((format) => {
|
||||
const bitrate = format.tbr ?? 0
|
||||
return bitrate > 0 && bitrate <= abrLimit
|
||||
})
|
||||
|
||||
return withinLimit ?? sorted[0]
|
||||
}
|
||||
|
||||
const findFormatBySelector = (
|
||||
formats: VideoFormat[],
|
||||
selector?: string
|
||||
): VideoFormat | undefined => {
|
||||
if (!selector) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const candidateIds = selector
|
||||
.split('/')
|
||||
.map((option) => option.split('+')[0].trim())
|
||||
.filter((option) => option.length > 0)
|
||||
|
||||
for (const candidateId of candidateIds) {
|
||||
const match = formats.find((format) => format.format_id === candidateId)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const findFormatByIdCandidates = (
|
||||
formats: VideoFormat[],
|
||||
rawFormatId: string | undefined
|
||||
): VideoFormat | undefined => {
|
||||
if (!rawFormatId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parts = rawFormatId
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
|
||||
for (const part of parts) {
|
||||
const match = formats.find((format) => format.format_id === part)
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const parseSizeToBytes = (value?: string): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cleaned = value.trim().replace(/^~\s*/, '')
|
||||
if (!cleaned) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const match = cleaned.match(/^([\d.,]+)\s*([KMGTP]?i?B)$/i)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const amount = Number(match[1].replace(/,/g, ''))
|
||||
if (Number.isNaN(amount)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const unit = match[2].toUpperCase()
|
||||
const multipliers: Record<string, number> = {
|
||||
B: 1,
|
||||
KB: 1_000,
|
||||
KIB: 1_024,
|
||||
MB: 1_000_000,
|
||||
MIB: 1_048_576,
|
||||
GB: 1_000_000_000,
|
||||
GIB: 1_073_741_824,
|
||||
TB: 1_000_000_000_000,
|
||||
TIB: 1_099_511_627_776
|
||||
}
|
||||
|
||||
const multiplier = multipliers[unit]
|
||||
if (!multiplier) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Math.round(amount * multiplier)
|
||||
}
|
||||
|
||||
export const resolveSelectedFormat = (
|
||||
formats: VideoFormat[],
|
||||
options: DownloadOptions,
|
||||
settings: AppSettings
|
||||
): VideoFormat | undefined => {
|
||||
const directMatch = findFormatBySelector(formats, options.format)
|
||||
if (directMatch) {
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const preset = settings.oneClickQuality ?? 'auto'
|
||||
|
||||
if (options.type === 'video') {
|
||||
const videoFormats = formats.filter(
|
||||
(format) => format.video_ext !== 'none' && !!format.vcodec && format.vcodec !== 'none'
|
||||
)
|
||||
return selectVideoFormatForPreset(videoFormats, preset)
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
const audioFormats = formats.filter(
|
||||
(format) =>
|
||||
!!format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(!format.video_ext || format.video_ext === 'none')
|
||||
)
|
||||
return selectAudioFormatForPreset(audioFormats, preset)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { join } from 'node:path'
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { applyDockVisibility } from './utils/dock'
|
||||
|
||||
// Initialize electron-log for main process
|
||||
log.initialize()
|
||||
|
||||
// Configure logger settings
|
||||
configureLogger()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
@@ -20,6 +28,7 @@ export function createWindow(): void {
|
||||
height: 800,
|
||||
show: false,
|
||||
titleBarStyle: 'hidden', // Hide title bar on macOS
|
||||
trafficLightPosition: { x: 12.5, y: 10 },
|
||||
autoHideMenuBar: true,
|
||||
icon: appIcon, // Set application icon
|
||||
frame: false,
|
||||
@@ -85,6 +94,64 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
function initAutoUpdater(): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
log.info('Skipping auto-updater initialization in development mode')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('Initializing auto-updater...')
|
||||
|
||||
log.transports.file.level = 'info'
|
||||
autoUpdater.logger = log
|
||||
autoUpdater.autoDownload = true
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log.info('Update available:', info.version)
|
||||
mainWindow?.webContents.send('update:available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log.info('Update not available:', info.version)
|
||||
mainWindow?.webContents.send('update:not-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Update error:', err)
|
||||
mainWindow?.webContents.send('update:error', err.message)
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progressObj) => {
|
||||
log.info('Download progress:', progressObj.percent)
|
||||
mainWindow?.webContents.send('update:download-progress', progressObj)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log.info('Update downloaded:', info.version)
|
||||
mainWindow?.webContents.send('update:downloaded', info)
|
||||
|
||||
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'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (settingsManager.get('autoUpdate')) {
|
||||
log.info('Auto-update is enabled, checking for updates...')
|
||||
void autoUpdater.checkForUpdatesAndNotify()
|
||||
}
|
||||
|
||||
log.info('Auto-updater initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize auto-updater:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -99,95 +166,23 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
||||
console.log('IPC services available:', Object.keys(services))
|
||||
log.info('IPC services available:', Object.keys(services))
|
||||
|
||||
// Initialize yt-dlp
|
||||
try {
|
||||
console.log('Initializing yt-dlp...')
|
||||
log.info('Initializing yt-dlp...')
|
||||
await ytdlpManager.initialize()
|
||||
console.log('yt-dlp initialized successfully')
|
||||
log.info('yt-dlp initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize yt-dlp:', error)
|
||||
log.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')
|
||||
}
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
|
||||
createWindow()
|
||||
|
||||
initAutoUpdater()
|
||||
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import type {
|
||||
DownloadItem,
|
||||
DownloadOptions,
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
} from '../../../shared/types'
|
||||
import { downloadEngine } from '../../download-engine'
|
||||
import { downloadEngine } from '../../lib/download-engine'
|
||||
|
||||
class DownloadService extends IpcService {
|
||||
static readonly groupName = 'download'
|
||||
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
|
||||
async startPlaylistDownload(
|
||||
_context: IpcContext,
|
||||
options: PlaylistDownloadOptions
|
||||
): Promise<string[]> {
|
||||
): Promise<PlaylistDownloadResult> {
|
||||
return downloadEngine.startPlaylistDownload(options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { dialog, shell } from 'electron'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { clipboard, dialog, shell } from 'electron'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
class FileSystemService extends IpcService {
|
||||
static readonly groupName = 'fs'
|
||||
|
||||
@@ -56,12 +60,6 @@ class FileSystemService extends IpcService {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -70,94 +68,66 @@ class FileSystemService extends IpcService {
|
||||
return true
|
||||
}
|
||||
|
||||
const fallbackDirectory = path.dirname(normalizedPath)
|
||||
const fallbackCandidate = await this.findLikelyFile(fallbackDirectory, normalizedPath)
|
||||
if (fallbackCandidate) {
|
||||
shell.showItemInFolder(fallbackCandidate)
|
||||
// If the exact path doesn't exist, try to open the parent directory
|
||||
const parentDirectory = path.dirname(normalizedPath)
|
||||
const parentStats = await fs.stat(parentDirectory).catch(() => null)
|
||||
|
||||
if (parentStats?.isDirectory()) {
|
||||
const result = await shell.openPath(parentDirectory)
|
||||
if (result) {
|
||||
console.error('Failed to open parent directory:', result)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await shell.openPath(fallbackDirectory)
|
||||
if (!result) {
|
||||
return true
|
||||
}
|
||||
console.error('Failed to open directory:', result)
|
||||
console.error('File or directory does not exist:', normalizedPath)
|
||||
return false
|
||||
} 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
|
||||
@IpcMethod()
|
||||
async copyFileToClipboard(_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)
|
||||
if (!stats.isFile()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(normalizedPath)
|
||||
|
||||
await this.copyFileToClipboardByPlatform(resolvedPath)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
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
|
||||
private async copyFileToClipboardByPlatform(resolvedPath: string): Promise<void> {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
await this.copyFileToClipboardWindows(resolvedPath)
|
||||
return
|
||||
case 'darwin':
|
||||
await this.copyFileToClipboardMac(resolvedPath)
|
||||
return
|
||||
default:
|
||||
await this.copyFileToClipboardLinux(resolvedPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +141,132 @@ class FileSystemService extends IpcService {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async copyFileToClipboardWindows(resolvedPath: string): Promise<void> {
|
||||
const escaped = resolvedPath.replace(/'/g, "''")
|
||||
try {
|
||||
await execFileAsync('powershell.exe', [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Set-Clipboard -Path '${escaped}'`
|
||||
])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
|
||||
}
|
||||
|
||||
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||
const fileList = `${winPath}\u0000\u0000`
|
||||
const encodedList = Buffer.from(fileList, 'ucs2')
|
||||
|
||||
const dropFilesStructSize = 20
|
||||
const buffer = Buffer.alloc(dropFilesStructSize + encodedList.length)
|
||||
buffer.writeUInt32LE(dropFilesStructSize, 0)
|
||||
buffer.writeInt32LE(0, 4)
|
||||
buffer.writeInt32LE(0, 8)
|
||||
buffer.writeUInt32LE(0, 12)
|
||||
buffer.writeUInt32LE(1, 16)
|
||||
encodedList.copy(buffer, dropFilesStructSize)
|
||||
|
||||
clipboard.writeBuffer('CF_HDROP', buffer)
|
||||
clipboard.writeBuffer('Preferred DropEffect', Buffer.from([1, 0, 0, 0]))
|
||||
clipboard.writeBuffer('FileNameW', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ucs2'))
|
||||
clipboard.writeBuffer('FileName', Buffer.from(`${path.basename(resolvedPath)}\u0000`, 'ascii'))
|
||||
}
|
||||
|
||||
private async copyFileToClipboardMac(resolvedPath: string): Promise<void> {
|
||||
const escaped = resolvedPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
try {
|
||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
|
||||
}
|
||||
|
||||
const entries = [
|
||||
'<?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">',
|
||||
'<array>',
|
||||
` <string>${this.escapeForPlist(resolvedPath)}</string>`,
|
||||
'</array>',
|
||||
'</plist>'
|
||||
]
|
||||
const plist = Buffer.from(entries.join('\n'), 'utf8')
|
||||
clipboard.writeBuffer('NSFilenamesPboardType', plist)
|
||||
|
||||
const fileUrl = pathToFileURL(resolvedPath).toString()
|
||||
clipboard.writeBuffer('public.file-url', Buffer.from(`${fileUrl}\n`, 'utf8'))
|
||||
}
|
||||
|
||||
private async copyFileToClipboardLinux(resolvedPath: string): Promise<void> {
|
||||
const fileUrl = pathToFileURL(resolvedPath).toString()
|
||||
const content = `copy\n${fileUrl}`
|
||||
clipboard.writeBuffer('x-special/gnome-copied-files', Buffer.from(content, 'utf8'))
|
||||
clipboard.writeBuffer('text/uri-list', Buffer.from(`${fileUrl}\n`, 'utf8'))
|
||||
}
|
||||
|
||||
private escapeForPlist(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async deleteFile(_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((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
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FileSystemService }
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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'
|
||||
@@ -24,282 +21,10 @@ class HistoryService extends IpcService {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async removeHistoryItem(_context: IpcContext, id: string, outputPath?: string): Promise<boolean> {
|
||||
const record = historyManager.getHistoryById(id)
|
||||
|
||||
await this.deleteOutputResource(record, outputPath)
|
||||
|
||||
removeHistoryItem(_context: IpcContext, id: string): boolean {
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
|
||||
class SettingsService extends IpcService {
|
||||
static readonly groupName = 'settings'
|
||||
@@ -18,6 +19,10 @@ class SettingsService extends IpcService {
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
@@ -32,11 +37,16 @@ class SettingsService extends IpcService {
|
||||
if (settings.language) {
|
||||
updateTrayMenu()
|
||||
}
|
||||
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,33 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
const isNewerVersion = (latest: string, current: string): boolean => {
|
||||
const toSegments = (version: string) =>
|
||||
version.split(/[.-]/).map((segment) => {
|
||||
const parsed = Number.parseInt(segment, 10)
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
})
|
||||
|
||||
const latestSegments = toSegments(latest)
|
||||
const currentSegments = toSegments(current)
|
||||
const maxLength = Math.max(latestSegments.length, currentSegments.length)
|
||||
|
||||
for (let index = 0; index < maxLength; index += 1) {
|
||||
const latestValue = latestSegments[index] ?? 0
|
||||
const currentValue = currentSegments[index] ?? 0
|
||||
|
||||
if (latestValue > currentValue) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (latestValue < currentValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class UpdateService extends IpcService {
|
||||
static readonly groupName = 'update'
|
||||
|
||||
@@ -11,11 +38,20 @@ class UpdateService extends IpcService {
|
||||
_context: IpcContext
|
||||
): Promise<{ available: boolean; version?: string; error?: string }> {
|
||||
try {
|
||||
// In production, use checkForUpdatesAndNotify for automatic notifications
|
||||
const result = await autoUpdater.checkForUpdatesAndNotify()
|
||||
const currentVersion = app.getVersion()
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const latestVersion = result?.updateInfo?.version
|
||||
|
||||
if (latestVersion && isNewerVersion(latestVersion, currentVersion)) {
|
||||
return {
|
||||
available: true,
|
||||
version: latestVersion
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: result !== null,
|
||||
version: result?.updateInfo?.version
|
||||
available: false,
|
||||
version: latestVersion ?? currentVersion
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import { normalizeLanguageCode } from '@shared/languages'
|
||||
import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'
|
||||
import appIcon from '../../resources/icon.png?asset'
|
||||
import trayIcon from '../../resources/tray-icon.png?asset'
|
||||
import { settingsManager } from './settings'
|
||||
|
||||
let tray: Tray | null = null
|
||||
@@ -8,7 +10,7 @@ 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 language = normalizeLanguageCode(settingsManager.get('language'))
|
||||
|
||||
const translations = {
|
||||
en: {
|
||||
@@ -16,12 +18,12 @@ function t(key: 'showHome' | 'quit'): string {
|
||||
quit: 'Quit'
|
||||
},
|
||||
zh: {
|
||||
showHome: '打开首页',
|
||||
quit: '关闭应用'
|
||||
showHome: '显示主页',
|
||||
quit: '退出应用'
|
||||
}
|
||||
}
|
||||
|
||||
const displayLang = language.startsWith('en') ? 'en' : 'zh'
|
||||
const displayLang = language.startsWith('zh') ? 'zh' : 'en'
|
||||
return translations[displayLang][key]
|
||||
}
|
||||
|
||||
@@ -85,9 +87,17 @@ export function createTray(): void {
|
||||
return
|
||||
}
|
||||
|
||||
const trayIcon = nativeImage.createFromPath(appIcon)
|
||||
// Use 16x16 icon for macOS tray, fallback to app icon for other platforms
|
||||
const iconPath = process.platform === 'darwin' ? trayIcon : appIcon
|
||||
const trayIconImage = nativeImage.createFromPath(iconPath)
|
||||
|
||||
tray = new Tray(trayIcon)
|
||||
// For macOS, ensure the icon is properly sized
|
||||
if (process.platform === 'darwin') {
|
||||
// Resize to 16x16 for macOS tray
|
||||
trayIconImage.setTemplateImage(true)
|
||||
}
|
||||
|
||||
tray = new Tray(trayIconImage)
|
||||
|
||||
// Set tooltip
|
||||
tray.setToolTip('VidBee')
|
||||
|
||||
16
src/main/utils/dock.ts
Normal file
16
src/main/utils/dock.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { app } from 'electron'
|
||||
|
||||
/**
|
||||
* Apply Dock visibility preference on macOS.
|
||||
*/
|
||||
export function applyDockVisibility(hideDockIcon: boolean): void {
|
||||
if (process.platform !== 'darwin' || !app.dock) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hideDockIcon) {
|
||||
app.dock.hide()
|
||||
} else {
|
||||
app.dock.show()
|
||||
}
|
||||
}
|
||||
25
src/main/utils/logger.ts
Normal file
25
src/main/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Main process logger utility
|
||||
* Directly use electron-log/main
|
||||
*/
|
||||
|
||||
import log from 'electron-log/main'
|
||||
|
||||
// Export electron-log instance
|
||||
export default log
|
||||
|
||||
// Export commonly used logging methods
|
||||
export const logger = log
|
||||
|
||||
// Predefined scoped loggers
|
||||
export const scopedLoggers = {
|
||||
main: log.scope('main'),
|
||||
ipc: log.scope('ipc'),
|
||||
window: log.scope('window'),
|
||||
download: log.scope('download'),
|
||||
engine: log.scope('engine'),
|
||||
system: log.scope('system'),
|
||||
storage: log.scope('storage'),
|
||||
thumbnail: log.scope('thumbnail'),
|
||||
history: log.scope('history')
|
||||
}
|
||||
@@ -3,7 +3,8 @@ 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 { useEffect, useState } from 'react'
|
||||
import { ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
@@ -13,6 +14,22 @@ type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
|
||||
function AppContent() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Get platform info to determine if we should show title bar
|
||||
const getPlatform = async () => {
|
||||
try {
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
// Default to showing title bar if platform detection fails
|
||||
setPlatform('unknown')
|
||||
}
|
||||
}
|
||||
getPlatform()
|
||||
}, [])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
@@ -37,7 +54,7 @@ function AppContent() {
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
{/* Custom Title Bar */}
|
||||
<TitleBar />
|
||||
<TitleBar platform={platform} />
|
||||
|
||||
<ScrollArea
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
@@ -49,7 +66,7 @@ function AppContent() {
|
||||
</ScrollArea>
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
<Toaster richColors={true} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,8 @@ 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 { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
@@ -23,6 +14,16 @@ import {
|
||||
removeDownloadAtom,
|
||||
removeHistoryRecordAtom
|
||||
} from '../../store/downloads'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
// Helper function to generate file path with proper path separators
|
||||
const generateFilePath = (downloadPath: string, title: string, format: string): string => {
|
||||
const fileName = `${title}.${format}`
|
||||
// Use proper path joining for cross-platform compatibility
|
||||
// Handle both forward and backward slashes for cross-platform compatibility
|
||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||
return `${normalizedDownloadPath}/${fileName}`
|
||||
}
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
@@ -53,6 +54,7 @@ const formatDate = (timestamp?: number) => {
|
||||
|
||||
export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
const isHistory = download.entryType === 'history'
|
||||
@@ -75,14 +77,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFileLocation = async () => {
|
||||
if (!download.outputPath) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
const success = await ipcServices.fs.openFileLocation(download.outputPath)
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
|
||||
const success = await ipcServices.fs.openFileLocation(filePath)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
@@ -91,29 +93,46 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
toast.error(t('notifications.openFolderFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFile = async () => {
|
||||
if (!download.outputPath) {
|
||||
toast.error(t('notifications.openFileFailed'))
|
||||
// need title, downloadPath, format
|
||||
const handleCopyToClipboard = async () => {
|
||||
if (!download.title || !download.downloadPath || !download.format) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ipcServices.fs.openFileLocation(download.outputPath)
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath
|
||||
const format = download.format
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
|
||||
const success = await ipcServices.fs.copyFileToClipboard(filePath)
|
||||
if (!success) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
toast.success(t('notifications.videoCopied'))
|
||||
} catch (error) {
|
||||
console.error('Failed to open file:', error)
|
||||
toast.error(t('notifications.openFileFailed'))
|
||||
console.error('Failed to copy file to clipboard:', error)
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
await handleOpenFileLocation()
|
||||
}
|
||||
|
||||
// need id
|
||||
const handleRemoveHistory = async () => {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
await ipcServices.history.removeHistoryItem(download.id, download.outputPath)
|
||||
// Generate file path using downloadPath + title + ext
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const filePath = generateFilePath(downloadPath, download.title, format)
|
||||
|
||||
// Remove from history first
|
||||
await ipcServices.history.removeHistoryItem(download.id)
|
||||
|
||||
// Then try to delete the file
|
||||
await ipcServices.fs.deleteFile(filePath)
|
||||
|
||||
removeHistory(download.id)
|
||||
toast.success(t('notifications.itemRemoved'))
|
||||
} catch (error) {
|
||||
@@ -132,7 +151,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
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" />
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
case 'cancelled':
|
||||
return <X className="h-4 w-4 text-muted-foreground" />
|
||||
default:
|
||||
@@ -166,11 +185,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<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">
|
||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
||||
<ImageWithPlaceholder
|
||||
src={thumbnailSrc}
|
||||
alt={download.title}
|
||||
className="w-32 h-18 object-cover aspect-video"
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -179,18 +198,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<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="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>
|
||||
<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}
|
||||
@@ -202,6 +214,28 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{download.playlistId && (
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/10 text-blue-700 dark:text-blue-200"
|
||||
>
|
||||
{t('playlist.badgeLabel')}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{download.playlistTitle || t('playlist.untitled')}
|
||||
{download.playlistIndex !== undefined &&
|
||||
download.playlistSize !== undefined && (
|
||||
<span className="ml-1 text-muted-foreground/80">
|
||||
{t('playlist.positionLabel', {
|
||||
index: download.playlistIndex,
|
||||
total: download.playlistSize
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{timestamp ? (
|
||||
<span className="truncate max-w-[120px]">{formatDate(timestamp)}</span>
|
||||
@@ -277,7 +311,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<div className={actionsContainerClass}>
|
||||
{isHistory ? (
|
||||
<>
|
||||
{download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -285,13 +319,14 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFile}
|
||||
onClick={handleCopyToClipboard}
|
||||
disabled={!download.title || !download.downloadPath || !download.format}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('history.openFile')}</p>
|
||||
<p>{t('history.copyToClipboard')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -329,7 +364,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{download.status === 'completed' && download.outputPath && (
|
||||
{download.status === 'completed' && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -337,13 +372,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFile}
|
||||
onClick={handleCopyToClipboard}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('history.openFile')}</p>
|
||||
<p>{t('history.copyToClipboard')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
|
||||
interface PlaylistDownloadGroupProps {
|
||||
groupId: string
|
||||
title: string
|
||||
records: DownloadRecord[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function PlaylistDownloadGroup({
|
||||
groupId,
|
||||
title,
|
||||
records,
|
||||
totalCount
|
||||
}: PlaylistDownloadGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const completedCount = records.filter((record) => record.status === 'completed').length
|
||||
const errorCount = records.filter((record) => record.status === 'error').length
|
||||
const activeCount = records.filter((record) =>
|
||||
['downloading', 'processing', 'pending'].includes(record.status)
|
||||
).length
|
||||
|
||||
const displayTitle = title || t('playlist.untitled')
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">
|
||||
{t('playlist.groupErrors', { count: errorCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{records.map((record) => (
|
||||
<div
|
||||
key={`${groupId}:${record.entryType}:${record.id}`}
|
||||
className="border-l border-border/50 pl-3"
|
||||
>
|
||||
<DownloadItem download={record} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
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 type { DownloadRecord } from '../../store/downloads'
|
||||
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||
|
||||
@@ -46,6 +49,56 @@ export function UnifiedDownloadHistory() {
|
||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||
]
|
||||
|
||||
const groupedView = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ id: string; title: string; totalCount: number; records: DownloadRecord[] }
|
||||
>()
|
||||
const order: Array<{ type: 'group'; id: string } | { type: 'single'; record: DownloadRecord }> =
|
||||
[]
|
||||
|
||||
for (const record of filteredRecords) {
|
||||
if (record.playlistId) {
|
||||
let group = groups.get(record.playlistId)
|
||||
if (!group) {
|
||||
group = {
|
||||
id: record.playlistId,
|
||||
title: record.playlistTitle || record.title,
|
||||
totalCount: record.playlistSize || 0,
|
||||
records: []
|
||||
}
|
||||
groups.set(record.playlistId, group)
|
||||
order.push({ type: 'group', id: record.playlistId })
|
||||
}
|
||||
group.records.push(record)
|
||||
if (!group.title && record.playlistTitle) {
|
||||
group.title = record.playlistTitle
|
||||
}
|
||||
if (!group.totalCount && record.playlistSize) {
|
||||
group.totalCount = record.playlistSize
|
||||
}
|
||||
} else {
|
||||
order.push({ type: 'single', record })
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups.values()) {
|
||||
group.records.sort((a, b) => {
|
||||
const aIndex = a.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = b.playlistIndex ?? Number.MAX_SAFE_INTEGER
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex
|
||||
}
|
||||
return b.createdAt - a.createdAt
|
||||
})
|
||||
if (!group.totalCount) {
|
||||
group.totalCount = group.records.length
|
||||
}
|
||||
}
|
||||
|
||||
return { order, groups }
|
||||
}, [filteredRecords])
|
||||
|
||||
const hasCompletedActive = allRecords.some(
|
||||
(item) => item.entryType === 'active' && item.status === 'completed'
|
||||
)
|
||||
@@ -89,7 +142,14 @@ export function UnifiedDownloadHistory() {
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
<span>{filter.label}</span>
|
||||
<span className="ml-1 text-xs opacity-70">({filter.count})</span>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
|
||||
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
|
||||
)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
@@ -102,10 +162,32 @@ export function UnifiedDownloadHistory() {
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
|
||||
{filteredRecords.map((record) => (
|
||||
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
|
||||
))}
|
||||
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
|
||||
{groupedView.order.map((item) => {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={`${item.record.entryType}:${item.record.id}`}
|
||||
download={item.record}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const group = groupedView.groups.get(item.id)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistDownloadGroup
|
||||
key={`group:${group.id}`}
|
||||
groupId={group.id}
|
||||
title={group.title}
|
||||
totalCount={group.totalCount}
|
||||
records={group.records}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
94
src/renderer/src/components/playlist/PlaylistPreviewCard.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import type { PlaylistEntry, PlaylistInfo } from '@shared/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PlaylistPreviewCardProps {
|
||||
playlist: PlaylistInfo
|
||||
entries: PlaylistEntry[]
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const totalCount = playlist.entryCount
|
||||
const selectedCount = entries.length
|
||||
const firstIndex = entries[0]?.index ?? null
|
||||
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
|
||||
|
||||
return (
|
||||
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<CardTitle
|
||||
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
|
||||
title={playlist.title}
|
||||
>
|
||||
{playlist.title || t('playlist.untitled')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
|
||||
{firstIndex !== null && lastIndex !== null ? (
|
||||
<span className="truncate">
|
||||
{t('playlist.selectedRange', {
|
||||
start: firstIndex,
|
||||
end: lastIndex
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{t('playlist.noRangeSelected')}</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{t('playlist.showingCount', { count: selectedCount })}
|
||||
</span>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</div>
|
||||
{onClear && (
|
||||
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
|
||||
{t('playlist.clearPreview')}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md border border-border/60 bg-muted/20">
|
||||
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
|
||||
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
|
||||
{entries.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
|
||||
{t('playlist.noEntriesInRange')}
|
||||
</li>
|
||||
) : (
|
||||
entries.map((entry) => (
|
||||
<li
|
||||
key={`${entry.index}-${entry.id}`}
|
||||
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
|
||||
>
|
||||
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
|
||||
#{entry.index}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
|
||||
title={entry.title}
|
||||
>
|
||||
{entry.title}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ol>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function ImageWithPlaceholder({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div className={cn('relative w-full h-full', className)}>
|
||||
{isLoading && (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@renderer/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { saveSettingAtom } from '@renderer/store/settings'
|
||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -40,10 +41,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文' }
|
||||
]
|
||||
const languageOptions = languageList
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
@@ -83,11 +81,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
]
|
||||
|
||||
const activeLanguageCode = (i18n.language ?? 'en').split('-')[0]
|
||||
const activeLanguageCode = normalizeLanguageCode(i18n.language)
|
||||
const currentLanguage =
|
||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
const handleLanguageChange = async (value: LanguageCode) => {
|
||||
if (activeLanguageCode === value) {
|
||||
return
|
||||
}
|
||||
@@ -167,9 +165,13 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => void handleLanguageChange(option.value)}
|
||||
className={isActive ? 'font-semibold' : undefined}
|
||||
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
|
||||
aria-current={isActive}
|
||||
>
|
||||
{option.label}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
|
||||
<span lang={option.hreflang}>{option.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -7,11 +7,15 @@ import IconFluentSubtract20Regular from '~icons/fluent/subtract-20-regular'
|
||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||
import '../../assets/title-bar.css'
|
||||
|
||||
export function TitleBar() {
|
||||
interface TitleBarProps {
|
||||
platform?: string
|
||||
}
|
||||
|
||||
export function TitleBar({ platform }: TitleBarProps) {
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听窗口最大化状态变化
|
||||
// Listen for window maximize state changes
|
||||
const handleMaximized = () => {
|
||||
setIsMaximized(true)
|
||||
}
|
||||
@@ -41,8 +45,17 @@ export function TitleBar() {
|
||||
ipcServices.window.close()
|
||||
}
|
||||
|
||||
const isMac = platform === 'darwin'
|
||||
const containerClass = `flex drag-region bg-background select-none ${
|
||||
isMac ? 'h-10 items-center px-4' : 'justify-end pt-4 px-5'
|
||||
}`
|
||||
|
||||
if (isMac) {
|
||||
return <div className={containerClass} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex drag-region justify-end bg-background pt-4 px-5 select-none">
|
||||
<div className={containerClass}>
|
||||
{/* Window controls */}
|
||||
<div className="flex items-center gap-1 no-drag">
|
||||
<Button
|
||||
|
||||
@@ -1,98 +1,24 @@
|
||||
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.'
|
||||
}
|
||||
{ id: 'youtube' },
|
||||
{ id: 'youtubemusic' },
|
||||
{ id: 'tiktok' },
|
||||
{ id: 'facebook' },
|
||||
{ id: 'instagram' },
|
||||
{ id: 'twitter' },
|
||||
{ id: 'soundcloud' },
|
||||
{ id: 'reddit' },
|
||||
{ id: 'vimeo' },
|
||||
{ id: 'dailymotion' },
|
||||
{ id: 'twitch' },
|
||||
{ id: 'linkedin' },
|
||||
{ id: 'pinterest' },
|
||||
{ id: 'tumblr' },
|
||||
{ id: 'mixcloud' },
|
||||
{ id: 'niconico' },
|
||||
{ id: 'kick' },
|
||||
{ id: 'bandcamp' }
|
||||
]
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { defaultLanguageCode, supportedLanguageCodes } from '@shared/languages'
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
type TranslationDictionary = typeof en
|
||||
|
||||
const localeModules = import.meta.glob<{ default: TranslationDictionary }>('./locales/*.json', {
|
||||
eager: true
|
||||
})
|
||||
|
||||
const translations = Object.fromEntries(
|
||||
Object.entries(localeModules).map(([path, module]) => {
|
||||
const code = path.replace('./locales/', '').replace('.json', '')
|
||||
return [code, module.default]
|
||||
})
|
||||
) as Record<string, TranslationDictionary>
|
||||
|
||||
const resources = Object.fromEntries(
|
||||
supportedLanguageCodes.map((code) => [
|
||||
code,
|
||||
{
|
||||
translation: translations[code] ?? en
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh }
|
||||
},
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
resources,
|
||||
lng: defaultLanguageCode,
|
||||
fallbackLng: defaultLanguageCode,
|
||||
supportedLngs: supportedLanguageCodes,
|
||||
interpolation: {
|
||||
escapeValue: false
|
||||
}
|
||||
|
||||
@@ -14,18 +14,24 @@
|
||||
"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.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Follow @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Stay updated with the latest VidBee news and updates.",
|
||||
"followAuthorSupport": "Follow the developer on X (Twitter) to get the latest updates and news about VidBee.",
|
||||
"followAuthorTitle": "Follow the Developer",
|
||||
"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",
|
||||
"downloadStarted": "Download started...",
|
||||
"downloadUpdate": "Download and install update {{version}}?",
|
||||
"noUpdatesAvailable": "You're using the latest version",
|
||||
"restartToUpdate": "Restart now to install update?",
|
||||
"updateAvailable": "Update available: {{version}}",
|
||||
"updateDownloaded": "Update downloaded, restart to install",
|
||||
"restartToUpdate": "Restart now to install update?"
|
||||
"updateError": "Failed to check for updates: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Tune update settings without leaving this page.",
|
||||
"preferencesTitle": "Quick Toggles",
|
||||
@@ -45,19 +51,24 @@
|
||||
},
|
||||
"resourcesDescription": "Useful links to learn more about VidBee and stay connected.",
|
||||
"resourcesTitle": "Resources",
|
||||
"shareActions": {
|
||||
"copy": "Copy link",
|
||||
"facebook": "Share on Facebook",
|
||||
"twitter": "Share on X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Share VidBee with your community in one click.",
|
||||
"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}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Latest: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "New version available",
|
||||
"uptodate": "You're up to date",
|
||||
"error": "Unable to fetch the latest version"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Close app when download finishes",
|
||||
@@ -91,7 +102,7 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"download": {
|
||||
"active": "active",
|
||||
"active": "Active",
|
||||
"all": "All",
|
||||
"audio": "Audio",
|
||||
"back": "Back",
|
||||
@@ -160,8 +171,8 @@
|
||||
"clearCancelled": "Clear Cancelled",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"clearErrors": "Clear Errors",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyUrl": "Copy URL",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"date": "Date",
|
||||
"description": "View and manage your download history",
|
||||
"duration": "Duration",
|
||||
@@ -174,11 +185,11 @@
|
||||
},
|
||||
"noHistory": "No download history yet",
|
||||
"noHistoryDescription": "Your completed downloads will appear here",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"openFile": "Open File",
|
||||
"openFileLocation": "Open File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"openDownloadFolder": "Open Download Folder",
|
||||
"outputPath": "Output Path",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeItem": "Remove Item",
|
||||
"stats": {
|
||||
"cancelled": "Cancelled",
|
||||
@@ -211,9 +222,12 @@
|
||||
"openFolderFailed": "Failed to open folder",
|
||||
"removeFailed": "Failed to remove item",
|
||||
"settingsSaved": "Settings saved",
|
||||
"urlCopied": "URL copied to clipboard"
|
||||
"urlCopied": "URL copied to clipboard",
|
||||
"videoCopied": "Video copied to clipboard"
|
||||
},
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -228,12 +242,27 @@
|
||||
"filenameFormat": "Filename format for playlists",
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"linkLabel": "Playlist URL",
|
||||
"noEntries": "No videos were found in this playlist",
|
||||
"noEntriesInRange": "No videos in the selected range",
|
||||
"noRangeSelected": "No end set - full playlist selected",
|
||||
"playlistUrlDescription": "Download all videos from a playlist in bulk",
|
||||
"positionLabel": "Item {{index}} of {{total}}",
|
||||
"previewButton": "Preview playlist",
|
||||
"previewFailed": "Failed to preview playlist",
|
||||
"previewSummary": "Preview playlist items before downloading.",
|
||||
"previewRequired": "Preview the playlist before downloading.",
|
||||
"range": "Range (Optional)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist"
|
||||
"title": "Download Playlist",
|
||||
"totalVideos": "Total videos: {{count}}",
|
||||
"untitled": "Untitled playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "About",
|
||||
@@ -241,23 +270,44 @@
|
||||
"app": "App Settings",
|
||||
"audio": "Audio Preferences",
|
||||
"browserForCookies": "Select browser to use cookies from",
|
||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
|
||||
"cookiesFile": "Cookies file",
|
||||
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
||||
"clearCookiesFile": "Clear",
|
||||
"cookiesHelpTitle": "Using cookies",
|
||||
"cookiesHelpBrowser": "Pick your browser above to reuse its signed-in session automatically.",
|
||||
"cookiesHelpFile": "Export a Netscape cookies file (see the yt-dlp FAQ) and select it here when needed.",
|
||||
"cookiesHelpFaq": "Open yt-dlp cookies FAQ",
|
||||
"openLinkError": "Failed to open link",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Use configuration file",
|
||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||
"dark": "Dark",
|
||||
"description": "Configure your download preferences and application settings",
|
||||
"directorySelectError": "Failed to select directory",
|
||||
"downloadPath": "Download location",
|
||||
"downloadPathDescription": "Choose where to save downloaded files",
|
||||
"fileSelectError": "Failed to select file",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"languageOptions": {
|
||||
"chinese": "Chinese (Simplified)",
|
||||
"english": "English"
|
||||
},
|
||||
"light": "Light",
|
||||
"hideDockIcon": "Hide Dock icon",
|
||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
"oneClickDownload": "One-Click Download",
|
||||
"oneClickDownloadDescription": "Enable one-click download with default settings",
|
||||
"oneClickDownloadType": "Default download type",
|
||||
"oneClickDownloadTypeDescription": "Choose the default download type for one-click downloads. Quality uses the preset below.",
|
||||
"oneClickQuality": "Preferred quality",
|
||||
"oneClickQualityDescription": "Select the quality preset used for one-click downloads",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Bad",
|
||||
@@ -267,11 +317,15 @@
|
||||
"worst": "Worst"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Proxy server for network requests",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Select config file",
|
||||
"selectPath": "Select",
|
||||
"showMoreFormats": "Show more format options",
|
||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||
"system": "System",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||
"title": "Settings",
|
||||
"tray": {
|
||||
"quit": "Quit",
|
||||
@@ -287,6 +341,80 @@
|
||||
"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",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Independent artist albums and community releases.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Global news, sports, and entertainment clips.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Feed, Watch, and Reels videos from public pages.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Feed, Stories, Reels, and Highlights content.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Creator live streams and replays on the Kick platform.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Professional talks, webinars, and learning videos.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ mixes, radio shows, and long-form audio.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Japanese animation, music, and live broadcast archive.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Idea pins, how-to reels, and lifestyle inspiration videos.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Embedded clips and hosted videos from communities.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Music tracks, playlists, and DJ sets.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Short-form mobile videos, effects, and live streams.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Creative short-form media and fan edits.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Gaming, music, and IRL live streams and VODs.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Timeline posts, Spaces recordings, and broadcasts.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "High-quality creator and business video hosting.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Long-form and livestream video from creators worldwide.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Official music videos, albums, and live performances.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Main platforms",
|
||||
"viewAll": "View all supported sites"
|
||||
}
|
||||
|
||||
394
src/renderer/src/locales/fr.json
Normal file
394
src/renderer/src/locales/fr.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Vérifier les mises à jour",
|
||||
"email": "Email",
|
||||
"feedback": "Commentaires",
|
||||
"openRepo": "Ouvrir le dépôt GitHub",
|
||||
"view": "Voir",
|
||||
"visit": "Visiter"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Télécharger et installer automatiquement les nouvelles versions en arrière-plan.",
|
||||
"autoUpdateTitle": "Mises à jour automatiques",
|
||||
"betaProgramDescription": "Recevez les versions préliminaires et les prochaines fonctionnalités avant tout le monde.",
|
||||
"betaProgramTitle": "Canal de prévisualisation",
|
||||
"description": "VidBee est un téléchargeur gratuit et open-source construit avec Electron et alimenté par yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Suivre @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Restez à jour avec les dernières nouvelles et mises à jour de VidBee.",
|
||||
"followAuthorSupport": "Suivez le développeur sur X (Twitter) pour obtenir les dernières mises à jour et nouvelles sur VidBee.",
|
||||
"followAuthorTitle": "Suivre le Développeur",
|
||||
"here": "ici",
|
||||
"homepage": "Page d'accueil",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Recherche de mises à jour...",
|
||||
"downloadError": "Échec du téléchargement de la mise à jour",
|
||||
"downloadStarted": "Téléchargement démarré...",
|
||||
"downloadUpdate": "Télécharger et installer la mise à jour {{version}} ?",
|
||||
"noUpdatesAvailable": "Vous utilisez la dernière version",
|
||||
"restartToUpdate": "Redémarrer maintenant pour installer la mise à jour ?",
|
||||
"updateAvailable": "Mise à jour disponible : {{version}}",
|
||||
"updateDownloaded": "Mise à jour téléchargée, redémarrez pour installer",
|
||||
"updateError": "Échec de la vérification des mises à jour : {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Ajustez les paramètres de mise à jour sans quitter cette page.",
|
||||
"preferencesTitle": "Basculements Rapides",
|
||||
"resources": {
|
||||
"changelog": "Notes de version",
|
||||
"changelogDescription": "Suivez ce qui a changé dans chaque version.",
|
||||
"contact": "Support par email",
|
||||
"contactDescription": "Contactez directement pour de l'aide ou collaboration.",
|
||||
"documentation": "Centre d'aide",
|
||||
"documentationDescription": "Guides, FAQ et flux de travail courants.",
|
||||
"feedback": "Commentaires et problèmes",
|
||||
"feedbackDescription": "Partagez des idées ou signalez des problèmes sur GitHub.",
|
||||
"license": "Licence",
|
||||
"licenseDescription": "Consultez les termes de la licence open-source.",
|
||||
"website": "Site web officiel",
|
||||
"websiteDescription": "Points forts du produit, feuille de route et actualités de la communauté."
|
||||
},
|
||||
"resourcesDescription": "Liens utiles pour en savoir plus sur VidBee et rester connecté.",
|
||||
"resourcesTitle": "Ressources",
|
||||
"shareActions": {
|
||||
"copy": "Copier le lien",
|
||||
"facebook": "Partager sur Facebook",
|
||||
"twitter": "Partager sur X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Partagez VidBee avec votre communauté en un clic.",
|
||||
"shareSupport": "Recommandez VidBee à vos amis pour soutenir notre croissance et nos mises à jour.",
|
||||
"shareTitle": "Faites passer le mot",
|
||||
"sourceCode": "Le code source est disponible",
|
||||
"title": "À propos",
|
||||
"version": "Version",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Dernière : v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nouvelle version disponible",
|
||||
"uptodate": "Vous êtes à jour",
|
||||
"error": "Impossible de récupérer la dernière version"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fermer l'application quand le téléchargement se termine",
|
||||
"currentLocation": "Emplacement de téléchargement actuel - ",
|
||||
"downloadLocation": "Emplacement de téléchargement",
|
||||
"downloadSubs": "Télécharger les sous-titres si disponibles",
|
||||
"end": "Fin",
|
||||
"endHint": "Si laissé vide, sera téléchargé jusqu'à la fin",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Sélectionner l'Emplacement de Téléchargement",
|
||||
"start": "Début",
|
||||
"startHint": "Si laissé vide, commencera du début",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Sous-titres",
|
||||
"timeRange": "Télécharger une plage de temps spécifique",
|
||||
"title": "Options Avancées"
|
||||
},
|
||||
"app": {
|
||||
"description": "Télécharger des vidéos et audios depuis des centaines de sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Mauvais",
|
||||
"best": "Meilleur",
|
||||
"extract": "Extraire",
|
||||
"good": "Bon",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectQuality": "Sélectionner la Qualité",
|
||||
"title": "Extraire l'Audio",
|
||||
"worst": "Pire"
|
||||
},
|
||||
"download": {
|
||||
"active": "Actif",
|
||||
"all": "Tout",
|
||||
"audio": "Audio",
|
||||
"back": "Retour",
|
||||
"cancel": "Annuler",
|
||||
"cancelled": "Annulé",
|
||||
"clearCompleted": "Effacer les Terminés",
|
||||
"clearDownloads": "Effacer les Téléchargements",
|
||||
"completed": "Terminé",
|
||||
"downloadAudio": "Télécharger l'Audio",
|
||||
"downloadBtn": "Télécharger",
|
||||
"downloadPending": "En attente",
|
||||
"downloadQueue": "File de Téléchargement",
|
||||
"downloadVideo": "Télécharger la Vidéo",
|
||||
"downloading": "Téléchargement en cours...",
|
||||
"enterUrl": "Entrer l'URL de la Vidéo",
|
||||
"enterUrlDescription": "Collez ou tapez une URL de vidéo. ",
|
||||
"error": "Erreur",
|
||||
"fetch": "Récupérer",
|
||||
"fetchingVideoInfo": "Récupération des informations vidéo...",
|
||||
"history": "Historique",
|
||||
"imageLoadError": "Échec du chargement de l'image",
|
||||
"imagePlaceholder": "Aucune image disponible",
|
||||
"infoUnavailable": "Téléchargement en Un Clic (Info indisponible)",
|
||||
"loading": "Chargement",
|
||||
"moreOptions": "Plus d'options",
|
||||
"noActiveDownloads": "Aucun téléchargement actif",
|
||||
"noAudio": "Pas d'Audio",
|
||||
"noHistory": "Aucun historique de téléchargement",
|
||||
"noItems": "Aucun élément trouvé",
|
||||
"oneClickDownload": "Téléchargement en Un Clic",
|
||||
"oneClickDownloadDescription": "Télécharger directement avec les paramètres par défaut sans confirmation",
|
||||
"oneClickDownloadNow": "Télécharger Maintenant",
|
||||
"oneClickDownloadStarted": "Téléchargement démarré avec les paramètres par défaut",
|
||||
"paste": "Coller",
|
||||
"pastePlaylistUrl": "Cliquez pour coller le lien de la playlist depuis le presse-papiers [Ctrl + V]",
|
||||
"pasteUrl": "Cliquez pour coller l'URL de la vidéo ou l'ID [Ctrl + V]",
|
||||
"preparing": "Préparation...",
|
||||
"processing": "Traitement",
|
||||
"progress": "Progrès",
|
||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectVideoFormat": "Sélectionner le Format Vidéo",
|
||||
"singleVideo": "Vidéo Unique",
|
||||
"speed": "Vitesse",
|
||||
"title": "Titre",
|
||||
"total": "Total",
|
||||
"unknownQuality": "Qualité inconnue",
|
||||
"unknownSize": "Taille inconnue",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Vidéo",
|
||||
"videoInfo": "Informations Vidéo",
|
||||
"videoInfoUpdated": "Informations vidéo mises à jour"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Cliquez pour copier les détails",
|
||||
"clipboardEmpty": "Le presse-papiers est vide",
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadNecessaryFilesFailed": "Échec du téléchargement des fichiers nécessaires. Veuillez vérifier votre réseau et réessayer",
|
||||
"emptyUrl": "Veuillez entrer une URL",
|
||||
"errorDetails": "Détails de l'Erreur",
|
||||
"fetchInfoFailed": "Échec de la récupération des informations vidéo",
|
||||
"networkError": "Une erreur s'est produite. Vérifiez votre réseau et utilisez une URL correcte",
|
||||
"pasteFromClipboard": "Échec du collage depuis le presse-papiers"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Effacer les Annulés",
|
||||
"clearCompleted": "Effacer les Terminés",
|
||||
"clearErrors": "Effacer les Erreurs",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"copyUrl": "Copier l'URL",
|
||||
"date": "Date",
|
||||
"description": "Voir et gérer votre historique de téléchargements",
|
||||
"duration": "Durée",
|
||||
"fileSize": "Taille du Fichier",
|
||||
"filters": {
|
||||
"all": "Tout",
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"errors": "Erreurs"
|
||||
},
|
||||
"noHistory": "Aucun historique de téléchargement encore",
|
||||
"noHistoryDescription": "Vos téléchargements terminés apparaîtront ici",
|
||||
"openDownloadFolder": "Ouvrir le Dossier de Téléchargement",
|
||||
"openFile": "Ouvrir le Fichier",
|
||||
"openFileLocation": "Ouvrir l'Emplacement du Fichier",
|
||||
"openFolder": "Ouvrir le Dossier",
|
||||
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
|
||||
"removeItem": "Supprimer l'Élément",
|
||||
"stats": {
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"errors": "Erreurs",
|
||||
"total": "Total"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Annulé",
|
||||
"completed": "Terminé",
|
||||
"error": "Erreur"
|
||||
},
|
||||
"title": "Historique de Téléchargement"
|
||||
},
|
||||
"menu": {
|
||||
"about": "À propos",
|
||||
"download": "Télécharger",
|
||||
"playlist": "Télécharger la Playlist",
|
||||
"preferences": "Préférences",
|
||||
"supportedSites": "Sites Supportés",
|
||||
"theme": "Thème :"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Échec de la copie dans le presse-papiers",
|
||||
"downloadCompleted": "Téléchargement terminé",
|
||||
"downloadFailed": "Échec du téléchargement",
|
||||
"downloadStarted": "Téléchargement démarré",
|
||||
"itemRemoved": "Élément supprimé",
|
||||
"openFileFailed": "Échec de l'ouverture du fichier",
|
||||
"openFolderFailed": "Échec de l'ouverture du dossier",
|
||||
"removeFailed": "Échec de la suppression de l'élément",
|
||||
"settingsSaved": "Paramètres sauvegardés",
|
||||
"urlCopied": "URL copiée dans le presse-papiers",
|
||||
"videoCopied": "Vidéo copiée dans le presse-papiers"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "La fonctionnalité de téléchargement de playlist arrive bientôt !",
|
||||
"completed": "Playlist téléchargée",
|
||||
"description": "Télécharger toutes les vidéos d'une playlist ou chaîne YouTube",
|
||||
"downloadFailed": "Échec du démarrage du téléchargement de playlist",
|
||||
"downloadPlaylist": "Télécharger la Playlist",
|
||||
"downloadStarted": "Démarré le téléchargement de {{count}} vidéos de la playlist",
|
||||
"downloadType": "Type de Téléchargement",
|
||||
"downloading": "Téléchargement de la playlist :",
|
||||
"endIndex": "Fin",
|
||||
"enterPlaylistUrl": "Entrer l'URL de la Playlist",
|
||||
"fetchFailed": "Échec de la récupération des informations de playlist",
|
||||
"filenameFormat": "Format de nom de fichier pour les playlists",
|
||||
"folderFormat": "Format de nom de dossier pour les playlists",
|
||||
"foundVideos": "Trouvé {{count}} vidéos dans la playlist",
|
||||
"linkLabel": "URL de la Playlist",
|
||||
"playlistUrlDescription": "Télécharger toutes les vidéos d'une playlist en lot",
|
||||
"range": "Plage (Optionnel)",
|
||||
"resetToDefault": "Réinitialiser par défaut",
|
||||
"startIndex": "Début (1)",
|
||||
"title": "Télécharger la Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "À propos",
|
||||
"advanced": "Avancé",
|
||||
"app": "Paramètres de l'App",
|
||||
"audio": "Préférences Audio",
|
||||
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
||||
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Utiliser le fichier de configuration",
|
||||
"configFileDescription": "Fichier de configuration personnalisé pour yt-dlp",
|
||||
"dark": "Sombre",
|
||||
"description": "Configurez vos préférences de téléchargement et paramètres de l'application",
|
||||
"directorySelectError": "Échec de la sélection du répertoire",
|
||||
"downloadPath": "Emplacement de téléchargement",
|
||||
"downloadPathDescription": "Choisissez où sauvegarder les fichiers téléchargés",
|
||||
"fileSelectError": "Échec de la sélection du fichier",
|
||||
"general": "Général",
|
||||
"language": "Langue",
|
||||
"light": "Clair",
|
||||
"maxConcurrentDownloads": "Nombre maximum de téléchargements actifs",
|
||||
"maxConcurrentDownloadsDescription": "Nombre maximum de téléchargements simultanés",
|
||||
"none": "Aucun",
|
||||
"oneClickDownload": "Téléchargement en Un Clic",
|
||||
"oneClickDownloadDescription": "Activer le téléchargement en un clic avec les paramètres par défaut",
|
||||
"oneClickDownloadType": "Type de téléchargement par défaut",
|
||||
"oneClickDownloadTypeDescription": "Choisissez le type de téléchargement par défaut pour les téléchargements en un clic. La qualité utilise le préréglage ci-dessous.",
|
||||
"oneClickQuality": "Qualité préférée",
|
||||
"oneClickQualityDescription": "Sélectionnez le préréglage de qualité utilisé pour les téléchargements en un clic",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Mauvais",
|
||||
"best": "Meilleur",
|
||||
"good": "Bon",
|
||||
"normal": "Normal",
|
||||
"worst": "Pire"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Serveur proxy pour les requêtes réseau",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Sélectionner le fichier de configuration",
|
||||
"selectPath": "Sélectionner",
|
||||
"showMoreFormats": "Afficher plus d'options de format",
|
||||
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
||||
"system": "Système",
|
||||
"theme": "Thème",
|
||||
"themeDescription": "Choisissez un thème clair, sombre ou système pour VidBee",
|
||||
"title": "Paramètres",
|
||||
"tray": {
|
||||
"quit": "Quitter",
|
||||
"showHome": "Afficher l'Accueil"
|
||||
},
|
||||
"video": "Préférences Vidéo"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supporte {{sites}} et plus.",
|
||||
"moreDescription": "La liste complète yt-dlp est mise à jour constamment par la communauté.",
|
||||
"moreTitle": "Besoin d'un autre site ?",
|
||||
"openFullList": "Ouvrir la liste complète des sites supportés",
|
||||
"pageDescription": "VidBee utilise yt-dlp en arrière-plan pour atteindre des centaines de sources.",
|
||||
"pageIntro": "Voici les services principaux que les gens téléchargent le plus souvent.",
|
||||
"pageTitle": "Sites Supportés",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Albums d'artistes indépendants et sorties communautaires.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Actualités mondiales, sports et clips de divertissement.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Vidéos de flux, Watch et Reels des pages publiques.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Contenu de flux, Stories, Reels et Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Streams en direct et replays de créateurs sur la plateforme Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Conférences professionnelles, webinaires et vidéos d'apprentissage.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mixes DJ, émissions radio et audio long format.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animation japonaise, musique et archives de diffusion en direct.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Épingles d'idées, Reels de tutoriels et vidéos d'inspiration lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clips intégrés et vidéos hébergées des communautés.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Pistes musicales, playlists et sets DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Vidéos courtes mobiles, effets et streams en direct.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Médias courts créatifs et montages de fans.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Streams en direct de gaming, musique et IRL et VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Publications de timeline, enregistrements Spaces et diffusions.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hébergement vidéo de haute qualité pour créateurs et entreprises.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Vidéo long format et livestream de créateurs du monde entier.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Vidéos musicales officielles, albums et performances live.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Plateformes principales",
|
||||
"viewAll": "Voir tous les sites supportés"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/it.json
Normal file
394
src/renderer/src/locales/it.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Controlla aggiornamenti",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"openRepo": "Apri repository GitHub",
|
||||
"view": "Visualizza",
|
||||
"visit": "Visita"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Scarica e installa automaticamente le nuove versioni in background.",
|
||||
"autoUpdateTitle": "Aggiornamenti automatici",
|
||||
"betaProgramDescription": "Ricevi build anticipate e prossime funzionalità prima di tutti.",
|
||||
"betaProgramTitle": "Canale anteprima",
|
||||
"description": "VidBee è un downloader gratuito e open-source costruito con Electron e alimentato da yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Segui @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Rimani aggiornato con le ultime notizie e aggiornamenti di VidBee.",
|
||||
"followAuthorSupport": "Segui lo sviluppatore su X (Twitter) per ottenere gli ultimi aggiornamenti e notizie su VidBee.",
|
||||
"followAuthorTitle": "Segui lo Sviluppatore",
|
||||
"here": "qui",
|
||||
"homepage": "Homepage",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Ricerca aggiornamenti...",
|
||||
"downloadError": "Errore nel download dell'aggiornamento",
|
||||
"downloadStarted": "Download iniziato...",
|
||||
"downloadUpdate": "Scarica e installa l'aggiornamento {{version}}?",
|
||||
"noUpdatesAvailable": "Stai usando l'ultima versione",
|
||||
"restartToUpdate": "Riavvia ora per installare l'aggiornamento?",
|
||||
"updateAvailable": "Aggiornamento disponibile: {{version}}",
|
||||
"updateDownloaded": "Aggiornamento scaricato, riavvia per installare",
|
||||
"updateError": "Errore nel controllo degli aggiornamenti: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Regola le impostazioni di aggiornamento senza lasciare questa pagina.",
|
||||
"preferencesTitle": "Toggle Rapidi",
|
||||
"resources": {
|
||||
"changelog": "Note di rilascio",
|
||||
"changelogDescription": "Tieni traccia di cosa è cambiato in ogni versione.",
|
||||
"contact": "Supporto email",
|
||||
"contactDescription": "Contatta direttamente per aiuto o collaborazione.",
|
||||
"documentation": "Centro assistenza",
|
||||
"documentationDescription": "Guide, FAQ e flussi di lavoro comuni.",
|
||||
"feedback": "Feedback e problemi",
|
||||
"feedbackDescription": "Condividi idee o segnala problemi su GitHub.",
|
||||
"license": "Licenza",
|
||||
"licenseDescription": "Rivedi i termini della licenza open-source.",
|
||||
"website": "Sito web ufficiale",
|
||||
"websiteDescription": "Punti salienti del prodotto, roadmap e notizie della comunità."
|
||||
},
|
||||
"resourcesDescription": "Link utili per saperne di più su VidBee e rimanere connessi.",
|
||||
"resourcesTitle": "Risorse",
|
||||
"shareActions": {
|
||||
"copy": "Copia link",
|
||||
"facebook": "Condividi su Facebook",
|
||||
"twitter": "Condividi su X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Condividi VidBee con la tua comunità in un clic.",
|
||||
"shareSupport": "Raccomanda VidBee ai tuoi amici per sostenere la nostra crescita e aggiornamenti.",
|
||||
"shareTitle": "Passa parola",
|
||||
"sourceCode": "Il codice sorgente è disponibile",
|
||||
"title": "Informazioni",
|
||||
"version": "Versione",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Ultima: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nuova versione disponibile",
|
||||
"uptodate": "Sei aggiornato",
|
||||
"error": "Impossibile recuperare l'ultima versione"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Chiudi app quando il download finisce",
|
||||
"currentLocation": "Posizione download attuale - ",
|
||||
"downloadLocation": "Posizione download",
|
||||
"downloadSubs": "Scarica sottotitoli se disponibili",
|
||||
"end": "Fine",
|
||||
"endHint": "Se lasciato vuoto, verrà scaricato fino alla fine",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Seleziona Posizione Download",
|
||||
"start": "Inizio",
|
||||
"startHint": "Se lasciato vuoto, inizierà dall'inizio",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Sottotitoli",
|
||||
"timeRange": "Scarica intervallo di tempo specifico",
|
||||
"title": "Opzioni Avanzate"
|
||||
},
|
||||
"app": {
|
||||
"description": "Scarica video e audio da centinaia di siti",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Cattivo",
|
||||
"best": "Migliore",
|
||||
"extract": "Estrai",
|
||||
"good": "Buono",
|
||||
"normal": "Normale",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectQuality": "Seleziona Qualità",
|
||||
"title": "Estrai Audio",
|
||||
"worst": "Peggiore"
|
||||
},
|
||||
"download": {
|
||||
"active": "Attivo",
|
||||
"all": "Tutto",
|
||||
"audio": "Audio",
|
||||
"back": "Indietro",
|
||||
"cancel": "Annulla",
|
||||
"cancelled": "Annullato",
|
||||
"clearCompleted": "Cancella Completati",
|
||||
"clearDownloads": "Cancella Download",
|
||||
"completed": "Completato",
|
||||
"downloadAudio": "Scarica Audio",
|
||||
"downloadBtn": "Scarica",
|
||||
"downloadPending": "In attesa",
|
||||
"downloadQueue": "Coda Download",
|
||||
"downloadVideo": "Scarica Video",
|
||||
"downloading": "Scaricando...",
|
||||
"enterUrl": "Inserisci URL Video",
|
||||
"enterUrlDescription": "Incolla o digita un URL video. ",
|
||||
"error": "Errore",
|
||||
"fetch": "Recupera",
|
||||
"fetchingVideoInfo": "Recupero informazioni video...",
|
||||
"history": "Cronologia",
|
||||
"imageLoadError": "Errore nel caricamento dell'immagine",
|
||||
"imagePlaceholder": "Nessuna immagine disponibile",
|
||||
"infoUnavailable": "Download con Un Clic (Info non disponibile)",
|
||||
"loading": "Caricamento",
|
||||
"moreOptions": "Più opzioni",
|
||||
"noActiveDownloads": "Nessun download attivo",
|
||||
"noAudio": "Nessun Audio",
|
||||
"noHistory": "Nessuna cronologia download",
|
||||
"noItems": "Nessun elemento trovato",
|
||||
"oneClickDownload": "Download con Un Clic",
|
||||
"oneClickDownloadDescription": "Scarica direttamente con impostazioni predefinite senza conferma",
|
||||
"oneClickDownloadNow": "Scarica Ora",
|
||||
"oneClickDownloadStarted": "Download iniziato con impostazioni predefinite",
|
||||
"paste": "Incolla",
|
||||
"pastePlaylistUrl": "Clicca per incollare link playlist dagli appunti [Ctrl + V]",
|
||||
"pasteUrl": "Clicca per incollare URL video o ID [Ctrl + V]",
|
||||
"preparing": "Preparazione...",
|
||||
"processing": "Elaborazione",
|
||||
"progress": "Progresso",
|
||||
"selectAudioFormat": "Seleziona Formato Audio",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectVideoFormat": "Seleziona Formato Video",
|
||||
"singleVideo": "Video Singolo",
|
||||
"speed": "Velocità",
|
||||
"title": "Titolo",
|
||||
"total": "Totale",
|
||||
"unknownQuality": "Qualità sconosciuta",
|
||||
"unknownSize": "Dimensione sconosciuta",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Video",
|
||||
"videoInfo": "Informazioni Video",
|
||||
"videoInfoUpdated": "Informazioni video aggiornate"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Clicca per copiare i dettagli",
|
||||
"clipboardEmpty": "Gli appunti sono vuoti",
|
||||
"downloadFailed": "Download fallito",
|
||||
"downloadNecessaryFilesFailed": "Errore nel download dei file necessari. Controlla la tua rete e riprova",
|
||||
"emptyUrl": "Inserisci un URL",
|
||||
"errorDetails": "Dettagli Errore",
|
||||
"fetchInfoFailed": "Errore nel recupero delle informazioni video",
|
||||
"networkError": "Si è verificato un errore. Controlla la tua rete e usa un URL corretto",
|
||||
"pasteFromClipboard": "Errore nell'incollare dagli appunti"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Cancella Annullati",
|
||||
"clearCompleted": "Cancella Completati",
|
||||
"clearErrors": "Cancella Errori",
|
||||
"copyToClipboard": "Copia negli appunti",
|
||||
"copyUrl": "Copia URL",
|
||||
"date": "Data",
|
||||
"description": "Visualizza e gestisci la tua cronologia download",
|
||||
"duration": "Durata",
|
||||
"fileSize": "Dimensione File",
|
||||
"filters": {
|
||||
"all": "Tutto",
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"errors": "Errori"
|
||||
},
|
||||
"noHistory": "Nessuna cronologia download ancora",
|
||||
"noHistoryDescription": "I tuoi download completati appariranno qui",
|
||||
"openDownloadFolder": "Apri Cartella Download",
|
||||
"openFile": "Apri File",
|
||||
"openFileLocation": "Apri Posizione File",
|
||||
"openFolder": "Apri Cartella",
|
||||
"openInBrowser": "Clicca per aprire nel browser",
|
||||
"removeItem": "Rimuovi Elemento",
|
||||
"stats": {
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"errors": "Errori",
|
||||
"total": "Totale"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Annullato",
|
||||
"completed": "Completato",
|
||||
"error": "Errore"
|
||||
},
|
||||
"title": "Cronologia Download"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Informazioni",
|
||||
"download": "Scarica",
|
||||
"playlist": "Scarica Playlist",
|
||||
"preferences": "Preferenze",
|
||||
"supportedSites": "Siti Supportati",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Errore nella copia negli appunti",
|
||||
"downloadCompleted": "Download completato",
|
||||
"downloadFailed": "Download fallito",
|
||||
"downloadStarted": "Download iniziato",
|
||||
"itemRemoved": "Elemento rimosso",
|
||||
"openFileFailed": "Errore nell'apertura del file",
|
||||
"openFolderFailed": "Errore nell'apertura della cartella",
|
||||
"removeFailed": "Errore nella rimozione dell'elemento",
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"urlCopied": "URL copiato negli appunti",
|
||||
"videoCopied": "Video copiato negli appunti"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "La funzionalità di download playlist arriverà presto!",
|
||||
"completed": "Playlist scaricata",
|
||||
"description": "Scarica tutti i video da una playlist o canale YouTube",
|
||||
"downloadFailed": "Errore nell'avvio del download playlist",
|
||||
"downloadPlaylist": "Scarica Playlist",
|
||||
"downloadStarted": "Iniziato download di {{count}} video dalla playlist",
|
||||
"downloadType": "Tipo Download",
|
||||
"downloading": "Scaricando playlist:",
|
||||
"endIndex": "Fine",
|
||||
"enterPlaylistUrl": "Inserisci URL Playlist",
|
||||
"fetchFailed": "Errore nel recupero delle informazioni playlist",
|
||||
"filenameFormat": "Formato nome file per playlist",
|
||||
"folderFormat": "Formato nome cartella per playlist",
|
||||
"foundVideos": "Trovati {{count}} video nella playlist",
|
||||
"linkLabel": "URL Playlist",
|
||||
"playlistUrlDescription": "Scarica tutti i video da una playlist in blocco",
|
||||
"range": "Intervallo (Opzionale)",
|
||||
"resetToDefault": "Ripristina predefinito",
|
||||
"startIndex": "Inizio (1)",
|
||||
"title": "Scarica Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Informazioni",
|
||||
"advanced": "Avanzato",
|
||||
"app": "Impostazioni App",
|
||||
"audio": "Preferenze Audio",
|
||||
"browserForCookies": "Seleziona browser per usare i cookie",
|
||||
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Usa file di configurazione",
|
||||
"configFileDescription": "File di configurazione personalizzato per yt-dlp",
|
||||
"dark": "Scuro",
|
||||
"description": "Configura le tue preferenze di download e impostazioni dell'app",
|
||||
"directorySelectError": "Errore nella selezione della directory",
|
||||
"downloadPath": "Posizione download",
|
||||
"downloadPathDescription": "Scegli dove salvare i file scaricati",
|
||||
"fileSelectError": "Errore nella selezione del file",
|
||||
"general": "Generale",
|
||||
"language": "Lingua",
|
||||
"light": "Chiaro",
|
||||
"maxConcurrentDownloads": "Numero massimo di download attivi",
|
||||
"maxConcurrentDownloadsDescription": "Numero massimo di download simultanei",
|
||||
"none": "Nessuno",
|
||||
"oneClickDownload": "Download con Un Clic",
|
||||
"oneClickDownloadDescription": "Abilita download con un clic con impostazioni predefinite",
|
||||
"oneClickDownloadType": "Tipo download predefinito",
|
||||
"oneClickDownloadTypeDescription": "Scegli il tipo di download predefinito per i download con un clic. La qualità usa il preset qui sotto.",
|
||||
"oneClickQuality": "Qualità preferita",
|
||||
"oneClickQualityDescription": "Seleziona il preset di qualità utilizzato per i download con un clic",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Auto",
|
||||
"bad": "Cattivo",
|
||||
"best": "Migliore",
|
||||
"good": "Buono",
|
||||
"normal": "Normale",
|
||||
"worst": "Peggiore"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Server proxy per le richieste di rete",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Seleziona file di configurazione",
|
||||
"selectPath": "Seleziona",
|
||||
"showMoreFormats": "Mostra più opzioni formato",
|
||||
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
"themeDescription": "Scegli un tema chiaro, scuro o sistema per VidBee",
|
||||
"title": "Impostazioni",
|
||||
"tray": {
|
||||
"quit": "Esci",
|
||||
"showHome": "Mostra Home"
|
||||
},
|
||||
"video": "Preferenze Video"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Supporta {{sites}} e altro.",
|
||||
"moreDescription": "La lista completa yt-dlp viene aggiornata costantemente dalla comunità.",
|
||||
"moreTitle": "Hai bisogno di un altro sito?",
|
||||
"openFullList": "Apri lista completa siti supportati",
|
||||
"pageDescription": "VidBee usa yt-dlp sotto il cofano per raggiungere centinaia di fonti.",
|
||||
"pageIntro": "Ecco i servizi principali da cui le persone scaricano più spesso.",
|
||||
"pageTitle": "Siti Supportati",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Album di artisti indipendenti e uscite della comunità.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Notizie globali, sport e clip di intrattenimento.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Video di feed, Watch e Reels da pagine pubbliche.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Contenuto di feed, Stories, Reels e Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Stream live e replay di creatori sulla piattaforma Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Talk professionali, webinar e video di apprendimento.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mix DJ, programmi radio e audio long-form.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animazione giapponese, musica e archivio di trasmissioni live.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Pin di idee, Reels tutorial e video di ispirazione lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clip incorporati e video ospitati dalle comunità.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Traccia musicali, playlist e set DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Video brevi mobili, effetti e stream live.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Media brevi creativi e montaggi di fan.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Stream live di gaming, musica e IRL e VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Post di timeline, registrazioni Spaces e trasmissioni.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hosting video di alta qualità per creatori e aziende.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Video long-form e livestream da creatori di tutto il mondo.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Video musicali ufficiali, album e performance live.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Piattaforme principali",
|
||||
"viewAll": "Visualizza tutti i siti supportati"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/ja.json
Normal file
394
src/renderer/src/locales/ja.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "アップデートを確認",
|
||||
"email": "メール",
|
||||
"feedback": "フィードバック",
|
||||
"openRepo": "GitHubリポジトリを開く",
|
||||
"view": "表示",
|
||||
"visit": "訪問"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "バックグラウンドで新しいリリースを自動的にダウンロードしてインストールします。",
|
||||
"autoUpdateTitle": "自動アップデート",
|
||||
"betaProgramDescription": "他の人より先に早期ビルドと今後の機能を受け取ります。",
|
||||
"betaProgramTitle": "プレビューチャンネル",
|
||||
"description": "VidBeeはElectronで構築され、yt-dlpによって動力を得る無料のオープンソースダウンローダーです。",
|
||||
"followAuthorActions": {
|
||||
"follow": "@nexmoexをフォロー"
|
||||
},
|
||||
"followAuthorDescription": "VidBeeの最新ニュースとアップデートを入手してください。",
|
||||
"followAuthorSupport": "X (Twitter)で開発者をフォローして、VidBeeの最新アップデートとニュースを入手してください。",
|
||||
"followAuthorTitle": "開発者をフォロー",
|
||||
"here": "ここ",
|
||||
"homepage": "ホームページ",
|
||||
"notifications": {
|
||||
"checkingUpdates": "アップデートを検索中...",
|
||||
"downloadError": "アップデートのダウンロードに失敗",
|
||||
"downloadStarted": "ダウンロード開始...",
|
||||
"downloadUpdate": "アップデート{{version}}をダウンロードしてインストールしますか?",
|
||||
"noUpdatesAvailable": "最新バージョンを使用しています",
|
||||
"restartToUpdate": "今すぐ再起動してアップデートをインストールしますか?",
|
||||
"updateAvailable": "利用可能なアップデート:{{version}}",
|
||||
"updateDownloaded": "アップデートがダウンロードされました。再起動してインストールしてください",
|
||||
"updateError": "アップデートの確認に失敗:{{error}}"
|
||||
},
|
||||
"preferencesDescription": "このページを離れることなくアップデート設定を調整します。",
|
||||
"preferencesTitle": "クイックトグル",
|
||||
"resources": {
|
||||
"changelog": "リリースノート",
|
||||
"changelogDescription": "各バージョンで何が変更されたかを追跡します。",
|
||||
"contact": "メールサポート",
|
||||
"contactDescription": "ヘルプやコラボレーションのために直接連絡してください。",
|
||||
"documentation": "ヘルプセンター",
|
||||
"documentationDescription": "ガイド、FAQ、一般的なワークフロー。",
|
||||
"feedback": "フィードバックと問題",
|
||||
"feedbackDescription": "GitHubでアイデアを共有したり問題を報告したりしてください。",
|
||||
"license": "ライセンス",
|
||||
"licenseDescription": "オープンソースライセンス条項を確認してください。",
|
||||
"website": "公式ウェブサイト",
|
||||
"websiteDescription": "製品のハイライト、ロードマップ、コミュニティニュース。"
|
||||
},
|
||||
"resourcesDescription": "VidBeeについてもっと学び、つながりを保つための有用なリンク。",
|
||||
"resourcesTitle": "リソース",
|
||||
"shareActions": {
|
||||
"copy": "リンクをコピー",
|
||||
"facebook": "Facebookで共有",
|
||||
"twitter": "X (Twitter)で共有"
|
||||
},
|
||||
"shareDescription": "ワンクリックでコミュニティとVidBeeを共有してください。",
|
||||
"shareSupport": "私たちの成長とアップデートをサポートするために、友達にVidBeeを推奨してください。",
|
||||
"shareTitle": "口コミを広める",
|
||||
"sourceCode": "ソースコードが利用可能",
|
||||
"title": "について",
|
||||
"version": "バージョン",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "新しいバージョンが利用可能",
|
||||
"uptodate": "最新バージョンを使用中",
|
||||
"error": "最新バージョンを取得できません"
|
||||
}
|
||||
},
|
||||
"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": "ビデオURLを入力",
|
||||
"enterUrlDescription": "ビデオURLを貼り付けまたは入力してください。 ",
|
||||
"error": "エラー",
|
||||
"fetch": "取得",
|
||||
"fetchingVideoInfo": "ビデオ情報を取得中...",
|
||||
"history": "履歴",
|
||||
"imageLoadError": "画像の読み込みに失敗",
|
||||
"imagePlaceholder": "利用可能な画像なし",
|
||||
"infoUnavailable": "ワンクリックダウンロード(情報利用不可)",
|
||||
"loading": "読み込み中",
|
||||
"moreOptions": "その他のオプション",
|
||||
"noActiveDownloads": "アクティブなダウンロードなし",
|
||||
"noAudio": "オーディオなし",
|
||||
"noHistory": "ダウンロード履歴なし",
|
||||
"noItems": "アイテムが見つかりません",
|
||||
"oneClickDownload": "ワンクリックダウンロード",
|
||||
"oneClickDownloadDescription": "確認なしでデフォルト設定で直接ダウンロード",
|
||||
"oneClickDownloadNow": "今すぐダウンロード",
|
||||
"oneClickDownloadStarted": "デフォルト設定でダウンロード開始",
|
||||
"paste": "貼り付け",
|
||||
"pastePlaylistUrl": "クリップボードからプレイリストリンクを貼り付け [Ctrl + V]",
|
||||
"pasteUrl": "ビデオURLまたは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": "URLを入力してください",
|
||||
"errorDetails": "エラーの詳細",
|
||||
"fetchInfoFailed": "ビデオ情報の取得に失敗",
|
||||
"networkError": "エラーが発生しました。ネットワークを確認し、正しいURLを使用してください",
|
||||
"pasteFromClipboard": "クリップボードからの貼り付けに失敗"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "キャンセル済みをクリア",
|
||||
"clearCompleted": "完了をクリア",
|
||||
"clearErrors": "エラーをクリア",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyUrl": "URLをコピー",
|
||||
"date": "日付",
|
||||
"description": "ダウンロード履歴を表示および管理",
|
||||
"duration": "期間",
|
||||
"fileSize": "ファイルサイズ",
|
||||
"filters": {
|
||||
"all": "すべて",
|
||||
"cancelled": "キャンセル済み",
|
||||
"completed": "完了",
|
||||
"errors": "エラー"
|
||||
},
|
||||
"noHistory": "ダウンロード履歴はまだありません",
|
||||
"noHistoryDescription": "完了したダウンロードがここに表示されます",
|
||||
"openDownloadFolder": "ダウンロードフォルダを開く",
|
||||
"openFile": "ファイルを開く",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"openFolder": "フォルダを開く",
|
||||
"openInBrowser": "ブラウザで開くにはクリック",
|
||||
"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": "URLがクリップボードにコピーされました",
|
||||
"videoCopied": "ビデオがクリップボードにコピーされました"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "プレイリストダウンロード機能がまもなく登場します!",
|
||||
"completed": "プレイリストがダウンロードされました",
|
||||
"description": "YouTubeプレイリストまたはチャンネルからすべてのビデオをダウンロード",
|
||||
"downloadFailed": "プレイリストダウンロードの開始に失敗",
|
||||
"downloadPlaylist": "プレイリストをダウンロード",
|
||||
"downloadStarted": "プレイリストから{{count}}個のビデオのダウンロードを開始",
|
||||
"downloadType": "ダウンロードタイプ",
|
||||
"downloading": "プレイリストをダウンロード中:",
|
||||
"endIndex": "終了",
|
||||
"enterPlaylistUrl": "プレイリストURLを入力",
|
||||
"fetchFailed": "プレイリスト情報の取得に失敗",
|
||||
"filenameFormat": "プレイリスト用ファイル名フォーマット",
|
||||
"folderFormat": "プレイリスト用フォルダ名フォーマット",
|
||||
"foundVideos": "プレイリストで{{count}}個のビデオを発見",
|
||||
"linkLabel": "プレイリストURL",
|
||||
"playlistUrlDescription": "プレイリストからすべてのビデオを一括ダウンロード",
|
||||
"range": "範囲(オプション)",
|
||||
"resetToDefault": "デフォルトにリセット",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "プレイリストをダウンロード"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "について",
|
||||
"advanced": "高度",
|
||||
"app": "アプリ設定",
|
||||
"audio": "オーディオ設定",
|
||||
"browserForCookies": "Cookieに使用するブラウザを選択",
|
||||
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "設定ファイルを使用",
|
||||
"configFileDescription": "yt-dlp用のカスタム設定ファイル",
|
||||
"dark": "ダーク",
|
||||
"description": "ダウンロード設定とアプリ設定を構成",
|
||||
"directorySelectError": "ディレクトリの選択に失敗",
|
||||
"downloadPath": "ダウンロード場所",
|
||||
"downloadPathDescription": "ダウンロードファイルの保存場所を選択",
|
||||
"fileSelectError": "ファイルの選択に失敗",
|
||||
"general": "一般",
|
||||
"language": "言語",
|
||||
"light": "ライト",
|
||||
"maxConcurrentDownloads": "最大アクティブダウンロード数",
|
||||
"maxConcurrentDownloadsDescription": "最大同時ダウンロード数",
|
||||
"none": "なし",
|
||||
"oneClickDownload": "ワンクリックダウンロード",
|
||||
"oneClickDownloadDescription": "デフォルト設定でワンクリックダウンロードを有効化",
|
||||
"oneClickDownloadType": "デフォルトダウンロードタイプ",
|
||||
"oneClickDownloadTypeDescription": "ワンクリックダウンロードのデフォルトダウンロードタイプを選択。品質は下のプリセットを使用。",
|
||||
"oneClickQuality": "優先品質",
|
||||
"oneClickQualityDescription": "ワンクリックダウンロードに使用される品質プリセットを選択",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "自動",
|
||||
"bad": "悪い",
|
||||
"best": "最高",
|
||||
"good": "良い",
|
||||
"normal": "通常",
|
||||
"worst": "最悪"
|
||||
},
|
||||
"proxy": "プロキシ",
|
||||
"proxyDescription": "ネットワークリクエスト用のプロキシサーバー",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "設定ファイルを選択",
|
||||
"selectPath": "選択",
|
||||
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
||||
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
||||
"system": "システム",
|
||||
"theme": "テーマ",
|
||||
"themeDescription": "VidBeeのライト、ダーク、またはシステムテーマを選択",
|
||||
"title": "設定",
|
||||
"tray": {
|
||||
"quit": "終了",
|
||||
"showHome": "ホームを表示"
|
||||
},
|
||||
"video": "ビデオ設定"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "{{sites}}およびその他のサイトをサポートしています。",
|
||||
"moreDescription": "完全なyt-dlpリストはコミュニティによって継続的に更新されています。",
|
||||
"moreTitle": "他のサイトが必要ですか?",
|
||||
"openFullList": "サポートされているすべてのサイトリストを開く",
|
||||
"pageDescription": "VidBeeは数百のソースに到達するためにyt-dlpをバックグラウンドで使用します。",
|
||||
"pageIntro": "人々が最も頻繁にダウンロードする主要サービスです。",
|
||||
"pageTitle": "サポートされているサイト",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "インディペンデントアーティストのアルバムとコミュニティリリース。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "グローバルニュース、スポーツ、エンターテイメントクリップ。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "パブリックページからのフィード、Watch、Reels動画。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "フィード、Stories、Reels、ハイライトコンテンツ。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kickプラットフォームでのクリエイターライブストリームとリプレイ。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "プロフェッショナルトーク、ウェビナー、学習動画。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJミックス、ラジオ番組、ロングフォームオーディオ。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本のアニメーション、音楽、ライブ放送アーカイブ。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "アイデアピン、ハウツーReels、ライフスタイルインスピレーション動画。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "コミュニティからの埋め込みクリップとホスト動画。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音楽トラック、プレイリスト、DJセット。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "ショートフォームモバイル動画、エフェクト、ライブストリーム。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "クリエイティブショートフォームメディアとファン編集。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "ゲーミング、音楽、IRLライブストリームとVOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "タイムラインポスト、Spaces録音、放送。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "クリエイターとビジネス向け高品質動画ホスティング。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "世界中のクリエイターからのロングフォームとライブストリーム動画。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "公式ミュージックビデオ、アルバム、ライブパフォーマンス。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主要プラットフォーム",
|
||||
"viewAll": "サポートされているすべてのサイトを表示"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/ko.json
Normal file
394
src/renderer/src/locales/ko.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"email": "이메일",
|
||||
"feedback": "피드백",
|
||||
"openRepo": "GitHub 저장소 열기",
|
||||
"view": "보기",
|
||||
"visit": "방문"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "백그라운드에서 새 릴리스를 자동으로 다운로드하고 설치합니다.",
|
||||
"autoUpdateTitle": "자동 업데이트",
|
||||
"betaProgramDescription": "다른 사람들보다 먼저 초기 빌드와 다가오는 기능을 받으세요.",
|
||||
"betaProgramTitle": "미리보기 채널",
|
||||
"description": "VidBee는 Electron으로 구축되고 yt-dlp로 구동되는 무료 오픈소스 다운로더입니다.",
|
||||
"followAuthorActions": {
|
||||
"follow": "@nexmoex 팔로우"
|
||||
},
|
||||
"followAuthorDescription": "VidBee의 최신 뉴스와 업데이트를 받아보세요.",
|
||||
"followAuthorSupport": "X (Twitter)에서 개발자를 팔로우하여 VidBee의 최신 업데이트와 뉴스를 받아보세요.",
|
||||
"followAuthorTitle": "개발자 팔로우",
|
||||
"here": "여기",
|
||||
"homepage": "홈페이지",
|
||||
"notifications": {
|
||||
"checkingUpdates": "업데이트 검색 중...",
|
||||
"downloadError": "업데이트 다운로드 실패",
|
||||
"downloadStarted": "다운로드 시작...",
|
||||
"downloadUpdate": "업데이트 {{version}}을(를) 다운로드하고 설치하시겠습니까?",
|
||||
"noUpdatesAvailable": "최신 버전을 사용 중입니다",
|
||||
"restartToUpdate": "지금 재시작하여 업데이트를 설치하시겠습니까?",
|
||||
"updateAvailable": "사용 가능한 업데이트: {{version}}",
|
||||
"updateDownloaded": "업데이트가 다운로드되었습니다. 재시작하여 설치하세요",
|
||||
"updateError": "업데이트 확인 실패: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "이 페이지를 떠나지 않고 업데이트 설정을 조정하세요.",
|
||||
"preferencesTitle": "빠른 토글",
|
||||
"resources": {
|
||||
"changelog": "릴리스 노트",
|
||||
"changelogDescription": "각 버전에서 변경된 내용을 확인하세요.",
|
||||
"contact": "이메일 지원",
|
||||
"contactDescription": "도움이나 협업을 위해 직접 연락하세요.",
|
||||
"documentation": "도움말 센터",
|
||||
"documentationDescription": "가이드, FAQ 및 일반적인 워크플로우.",
|
||||
"feedback": "피드백 및 문제",
|
||||
"feedbackDescription": "GitHub에서 아이디어를 공유하거나 문제를 신고하세요.",
|
||||
"license": "라이선스",
|
||||
"licenseDescription": "오픈소스 라이선스 조건을 검토하세요.",
|
||||
"website": "공식 웹사이트",
|
||||
"websiteDescription": "제품 하이라이트, 로드맵 및 커뮤니티 뉴스."
|
||||
},
|
||||
"resourcesDescription": "VidBee에 대해 더 알아보고 연결을 유지하는 유용한 링크입니다.",
|
||||
"resourcesTitle": "리소스",
|
||||
"shareActions": {
|
||||
"copy": "링크 복사",
|
||||
"facebook": "Facebook에서 공유",
|
||||
"twitter": "X (Twitter)에서 공유"
|
||||
},
|
||||
"shareDescription": "한 번의 클릭으로 커뮤니티와 VidBee를 공유하세요.",
|
||||
"shareSupport": "우리의 성장과 업데이트를 지원하기 위해 친구들에게 VidBee를 추천하세요.",
|
||||
"shareTitle": "소문을 퍼뜨리세요",
|
||||
"sourceCode": "소스 코드 사용 가능",
|
||||
"title": "정보",
|
||||
"version": "버전",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "최신: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "새 버전 사용 가능",
|
||||
"uptodate": "최신 버전 사용 중",
|
||||
"error": "최신 버전을 가져올 수 없음"
|
||||
}
|
||||
},
|
||||
"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": "비디오 URL 입력",
|
||||
"enterUrlDescription": "비디오 URL을 붙여넣거나 입력하세요. ",
|
||||
"error": "오류",
|
||||
"fetch": "가져오기",
|
||||
"fetchingVideoInfo": "비디오 정보 가져오는 중...",
|
||||
"history": "기록",
|
||||
"imageLoadError": "이미지 로드 실패",
|
||||
"imagePlaceholder": "사용 가능한 이미지 없음",
|
||||
"infoUnavailable": "원클릭 다운로드 (정보 사용 불가)",
|
||||
"loading": "로딩 중",
|
||||
"moreOptions": "더 많은 옵션",
|
||||
"noActiveDownloads": "활성 다운로드 없음",
|
||||
"noAudio": "오디오 없음",
|
||||
"noHistory": "다운로드 기록 없음",
|
||||
"noItems": "항목을 찾을 수 없음",
|
||||
"oneClickDownload": "원클릭 다운로드",
|
||||
"oneClickDownloadDescription": "확인 없이 기본 설정으로 직접 다운로드",
|
||||
"oneClickDownloadNow": "지금 다운로드",
|
||||
"oneClickDownloadStarted": "기본 설정으로 다운로드 시작됨",
|
||||
"paste": "붙여넣기",
|
||||
"pastePlaylistUrl": "클립보드에서 재생목록 링크 붙여넣기 [Ctrl + V]",
|
||||
"pasteUrl": "비디오 URL 또는 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": "URL을 입력하세요",
|
||||
"errorDetails": "오류 세부 정보",
|
||||
"fetchInfoFailed": "비디오 정보 가져오기 실패",
|
||||
"networkError": "오류가 발생했습니다. 네트워크를 확인하고 올바른 URL을 사용하세요",
|
||||
"pasteFromClipboard": "클립보드에서 붙여넣기 실패"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "취소된 항목 지우기",
|
||||
"clearCompleted": "완료된 항목 지우기",
|
||||
"clearErrors": "오류 지우기",
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"copyUrl": "URL 복사",
|
||||
"date": "날짜",
|
||||
"description": "다운로드 기록 보기 및 관리",
|
||||
"duration": "지속 시간",
|
||||
"fileSize": "파일 크기",
|
||||
"filters": {
|
||||
"all": "모두",
|
||||
"cancelled": "취소됨",
|
||||
"completed": "완료",
|
||||
"errors": "오류"
|
||||
},
|
||||
"noHistory": "다운로드 기록이 아직 없습니다",
|
||||
"noHistoryDescription": "완료된 다운로드가 여기에 표시됩니다",
|
||||
"openDownloadFolder": "다운로드 폴더 열기",
|
||||
"openFile": "파일 열기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"openFolder": "폴더 열기",
|
||||
"openInBrowser": "브라우저에서 열려면 클릭",
|
||||
"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": "URL이 클립보드에 복사됨",
|
||||
"videoCopied": "비디오가 클립보드에 복사됨"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "재생목록 다운로드 기능이 곧 출시됩니다!",
|
||||
"completed": "재생목록 다운로드됨",
|
||||
"description": "YouTube 재생목록 또는 채널의 모든 비디오 다운로드",
|
||||
"downloadFailed": "재생목록 다운로드 시작 실패",
|
||||
"downloadPlaylist": "재생목록 다운로드",
|
||||
"downloadStarted": "재생목록에서 {{count}}개 비디오 다운로드 시작됨",
|
||||
"downloadType": "다운로드 유형",
|
||||
"downloading": "재생목록 다운로드 중:",
|
||||
"endIndex": "끝",
|
||||
"enterPlaylistUrl": "재생목록 URL 입력",
|
||||
"fetchFailed": "재생목록 정보 가져오기 실패",
|
||||
"filenameFormat": "재생목록용 파일명 형식",
|
||||
"folderFormat": "재생목록용 폴더명 형식",
|
||||
"foundVideos": "재생목록에서 {{count}}개 비디오 발견",
|
||||
"linkLabel": "재생목록 URL",
|
||||
"playlistUrlDescription": "재생목록의 모든 비디오를 일괄 다운로드",
|
||||
"range": "범위 (선택사항)",
|
||||
"resetToDefault": "기본값으로 재설정",
|
||||
"startIndex": "시작 (1)",
|
||||
"title": "재생목록 다운로드"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "정보",
|
||||
"advanced": "고급",
|
||||
"app": "앱 설정",
|
||||
"audio": "오디오 환경설정",
|
||||
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
||||
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "설정 파일 사용",
|
||||
"configFileDescription": "yt-dlp용 사용자 정의 설정 파일",
|
||||
"dark": "다크",
|
||||
"description": "다운로드 환경설정 및 앱 설정 구성",
|
||||
"directorySelectError": "디렉토리 선택 실패",
|
||||
"downloadPath": "다운로드 위치",
|
||||
"downloadPathDescription": "다운로드 파일을 저장할 위치 선택",
|
||||
"fileSelectError": "파일 선택 실패",
|
||||
"general": "일반",
|
||||
"language": "언어",
|
||||
"light": "라이트",
|
||||
"maxConcurrentDownloads": "최대 활성 다운로드 수",
|
||||
"maxConcurrentDownloadsDescription": "최대 동시 다운로드 수",
|
||||
"none": "없음",
|
||||
"oneClickDownload": "원클릭 다운로드",
|
||||
"oneClickDownloadDescription": "기본 설정으로 원클릭 다운로드 활성화",
|
||||
"oneClickDownloadType": "기본 다운로드 유형",
|
||||
"oneClickDownloadTypeDescription": "원클릭 다운로드의 기본 다운로드 유형을 선택하세요. 품질은 아래 사전 설정을 사용합니다.",
|
||||
"oneClickQuality": "선호 품질",
|
||||
"oneClickQualityDescription": "원클릭 다운로드에 사용되는 품질 사전 설정을 선택하세요",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "자동",
|
||||
"bad": "나쁨",
|
||||
"best": "최고",
|
||||
"good": "좋음",
|
||||
"normal": "보통",
|
||||
"worst": "최악"
|
||||
},
|
||||
"proxy": "프록시",
|
||||
"proxyDescription": "네트워크 요청용 프록시 서버",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "설정 파일 선택",
|
||||
"selectPath": "선택",
|
||||
"showMoreFormats": "더 많은 형식 옵션 표시",
|
||||
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
||||
"system": "시스템",
|
||||
"theme": "테마",
|
||||
"themeDescription": "VidBee용 라이트, 다크 또는 시스템 테마 선택",
|
||||
"title": "설정",
|
||||
"tray": {
|
||||
"quit": "종료",
|
||||
"showHome": "홈 표시"
|
||||
},
|
||||
"video": "비디오 환경설정"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "{{sites}} 및 더 많은 사이트를 지원합니다.",
|
||||
"moreDescription": "완전한 yt-dlp 목록은 커뮤니티에 의해 지속적으로 업데이트됩니다.",
|
||||
"moreTitle": "다른 사이트가 필요하신가요?",
|
||||
"openFullList": "지원되는 모든 사이트 목록 열기",
|
||||
"pageDescription": "VidBee는 수백 개의 소스에 도달하기 위해 yt-dlp를 백그라운드에서 사용합니다.",
|
||||
"pageIntro": "사람들이 가장 자주 다운로드하는 주요 서비스들입니다.",
|
||||
"pageTitle": "지원되는 사이트",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "인디 아티스트 앨범 및 커뮤니티 릴리스.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "글로벌 뉴스, 스포츠 및 엔터테인먼트 클립.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "공개 페이지의 피드, Watch 및 Reels 비디오.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "피드, Stories, Reels 및 Highlights 콘텐츠.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 플랫폼의 크리에이터 라이브 스트림 및 리플레이.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "전문 강연, 웨비나 및 학습 비디오.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 믹스, 라디오 쇼 및 롱폼 오디오.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "일본 애니메이션, 음악 및 라이브 방송 아카이브.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "아이디어 핀, 하우투 Reels 및 라이프스타일 영감 비디오.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "커뮤니티의 임베드 클립 및 호스팅 비디오.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "음악 트랙, 플레이리스트 및 DJ 세트.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "짧은 모바일 비디오, 효과 및 라이브 스트림.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "창의적인 짧은 미디어 및 팬 편집.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "게임, 음악 및 IRL 라이브 스트림 및 VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "타임라인 포스트, Spaces 녹음 및 방송.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "크리에이터 및 비즈니스용 고품질 비디오 호스팅.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "전 세계 크리에이터의 롱폼 및 라이브스트림 비디오.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "공식 뮤직 비디오, 앨범 및 라이브 공연.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "주요 플랫폼",
|
||||
"viewAll": "지원되는 모든 사이트 보기"
|
||||
}
|
||||
}
|
||||
394
src/renderer/src/locales/pt.json
Normal file
394
src/renderer/src/locales/pt.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "Verificar atualizações",
|
||||
"email": "Email",
|
||||
"feedback": "Feedback",
|
||||
"openRepo": "Abrir repositório GitHub",
|
||||
"view": "Ver",
|
||||
"visit": "Visitar"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "Baixar e instalar novas versões automaticamente em segundo plano.",
|
||||
"autoUpdateTitle": "Atualizações automáticas",
|
||||
"betaProgramDescription": "Receba builds antecipados e próximos recursos antes de todos.",
|
||||
"betaProgramTitle": "Canal de visualização",
|
||||
"description": "VidBee é um baixador gratuito e de código aberto construído com Electron e alimentado por yt-dlp.",
|
||||
"followAuthorActions": {
|
||||
"follow": "Seguir @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "Mantenha-se atualizado com as últimas notícias e atualizações do VidBee.",
|
||||
"followAuthorSupport": "Siga o desenvolvedor no X (Twitter) para obter as últimas atualizações e notícias sobre VidBee.",
|
||||
"followAuthorTitle": "Seguir o Desenvolvedor",
|
||||
"here": "aqui",
|
||||
"homepage": "Página inicial",
|
||||
"notifications": {
|
||||
"checkingUpdates": "Procurando atualizações...",
|
||||
"downloadError": "Falha ao baixar atualização",
|
||||
"downloadStarted": "Download iniciado...",
|
||||
"downloadUpdate": "Baixar e instalar atualização {{version}}?",
|
||||
"noUpdatesAvailable": "Você está usando a versão mais recente",
|
||||
"restartToUpdate": "Reiniciar agora para instalar atualização?",
|
||||
"updateAvailable": "Atualização disponível: {{version}}",
|
||||
"updateDownloaded": "Atualização baixada, reinicie para instalar",
|
||||
"updateError": "Falha ao verificar atualizações: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "Ajuste configurações de atualização sem sair desta página.",
|
||||
"preferencesTitle": "Alternâncias Rápidas",
|
||||
"resources": {
|
||||
"changelog": "Notas da versão",
|
||||
"changelogDescription": "Acompanhe o que mudou em cada versão.",
|
||||
"contact": "Suporte por email",
|
||||
"contactDescription": "Entre em contato diretamente para ajuda ou colaboração.",
|
||||
"documentation": "Central de ajuda",
|
||||
"documentationDescription": "Guias, FAQs e fluxos de trabalho comuns.",
|
||||
"feedback": "Feedback e problemas",
|
||||
"feedbackDescription": "Compartilhe ideias ou reporte problemas no GitHub.",
|
||||
"license": "Licença",
|
||||
"licenseDescription": "Revise os termos da licença de código aberto.",
|
||||
"website": "Site oficial",
|
||||
"websiteDescription": "Destaques do produto, roadmap e notícias da comunidade."
|
||||
},
|
||||
"resourcesDescription": "Links úteis para aprender mais sobre VidBee e manter-se conectado.",
|
||||
"resourcesTitle": "Recursos",
|
||||
"shareActions": {
|
||||
"copy": "Copiar link",
|
||||
"facebook": "Compartilhar no Facebook",
|
||||
"twitter": "Compartilhar no X (Twitter)"
|
||||
},
|
||||
"shareDescription": "Compartilhe VidBee com sua comunidade em um clique.",
|
||||
"shareSupport": "Recomende VidBee aos seus amigos para apoiar nosso crescimento e atualizações.",
|
||||
"shareTitle": "Espalhe a palavra",
|
||||
"sourceCode": "Código fonte disponível",
|
||||
"title": "Sobre",
|
||||
"version": "Versão",
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "Mais recente: v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "Nova versão disponível",
|
||||
"uptodate": "Você está atualizado",
|
||||
"error": "Não foi possível obter a versão mais recente"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "Fechar aplicativo quando download terminar",
|
||||
"currentLocation": "Local de download atual - ",
|
||||
"downloadLocation": "Local de download",
|
||||
"downloadSubs": "Baixar legendas se disponíveis",
|
||||
"end": "Fim",
|
||||
"endHint": "Se deixado vazio, será baixado até o final",
|
||||
"endPlaceholder": "10:00",
|
||||
"selectLocation": "Selecionar Local de Download",
|
||||
"start": "Início",
|
||||
"startHint": "Se deixado vazio, começará do início",
|
||||
"startPlaceholder": "00:00",
|
||||
"subtitles": "Legendas",
|
||||
"timeRange": "Baixar intervalo de tempo específico",
|
||||
"title": "Opções Avançadas"
|
||||
},
|
||||
"app": {
|
||||
"description": "Baixar vídeos e áudios de centenas de sites",
|
||||
"title": "VidBee"
|
||||
},
|
||||
"audioExtract": {
|
||||
"bad": "Ruim",
|
||||
"best": "Melhor",
|
||||
"extract": "Extrair",
|
||||
"good": "Bom",
|
||||
"normal": "Normal",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectQuality": "Selecionar Qualidade",
|
||||
"title": "Extrair Áudio",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"download": {
|
||||
"active": "Ativo",
|
||||
"all": "Todos",
|
||||
"audio": "Áudio",
|
||||
"back": "Voltar",
|
||||
"cancel": "Cancelar",
|
||||
"cancelled": "Cancelado",
|
||||
"clearCompleted": "Limpar Concluídos",
|
||||
"clearDownloads": "Limpar Downloads",
|
||||
"completed": "Concluído",
|
||||
"downloadAudio": "Baixar Áudio",
|
||||
"downloadBtn": "Baixar",
|
||||
"downloadPending": "Pendente",
|
||||
"downloadQueue": "Fila de Download",
|
||||
"downloadVideo": "Baixar Vídeo",
|
||||
"downloading": "Baixando...",
|
||||
"enterUrl": "Inserir URL do Vídeo",
|
||||
"enterUrlDescription": "Cole ou digite uma URL de vídeo. ",
|
||||
"error": "Erro",
|
||||
"fetch": "Buscar",
|
||||
"fetchingVideoInfo": "Buscando informações do vídeo...",
|
||||
"history": "Histórico",
|
||||
"imageLoadError": "Falha ao carregar imagem",
|
||||
"imagePlaceholder": "Nenhuma imagem disponível",
|
||||
"infoUnavailable": "Download de Um Clique (Info indisponível)",
|
||||
"loading": "Carregando",
|
||||
"moreOptions": "Mais opções",
|
||||
"noActiveDownloads": "Nenhum download ativo",
|
||||
"noAudio": "Sem Áudio",
|
||||
"noHistory": "Nenhum histórico de download",
|
||||
"noItems": "Nenhum item encontrado",
|
||||
"oneClickDownload": "Download de Um Clique",
|
||||
"oneClickDownloadDescription": "Baixar diretamente com configurações padrão sem confirmação",
|
||||
"oneClickDownloadNow": "Baixar Agora",
|
||||
"oneClickDownloadStarted": "Download iniciado com configurações padrão",
|
||||
"paste": "Colar",
|
||||
"pastePlaylistUrl": "Clique para colar link da playlist da área de transferência [Ctrl + V]",
|
||||
"pasteUrl": "Clique para colar URL do vídeo ou ID [Ctrl + V]",
|
||||
"preparing": "Preparando...",
|
||||
"processing": "Processando",
|
||||
"progress": "Progresso",
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"singleVideo": "Vídeo Único",
|
||||
"speed": "Velocidade",
|
||||
"title": "Título",
|
||||
"total": "Total",
|
||||
"unknownQuality": "Qualidade desconhecida",
|
||||
"unknownSize": "Tamanho desconhecido",
|
||||
"urlPlaceholder": "https://www.youtube.com/watch?v=...",
|
||||
"video": "Vídeo",
|
||||
"videoInfo": "Informações do Vídeo",
|
||||
"videoInfoUpdated": "Informações do vídeo atualizadas"
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Clique para copiar detalhes",
|
||||
"clipboardEmpty": "Área de transferência vazia",
|
||||
"downloadFailed": "Download falhou",
|
||||
"downloadNecessaryFilesFailed": "Falha ao baixar arquivos necessários. Verifique sua rede e tente novamente",
|
||||
"emptyUrl": "Por favor, insira uma URL",
|
||||
"errorDetails": "Detalhes do Erro",
|
||||
"fetchInfoFailed": "Falha ao buscar informações do vídeo",
|
||||
"networkError": "Algum erro ocorreu. Verifique sua rede e use uma URL correta",
|
||||
"pasteFromClipboard": "Falha ao colar da área de transferência"
|
||||
},
|
||||
"history": {
|
||||
"clearCancelled": "Limpar Cancelados",
|
||||
"clearCompleted": "Limpar Concluídos",
|
||||
"clearErrors": "Limpar Erros",
|
||||
"copyToClipboard": "Copiar para área de transferência",
|
||||
"copyUrl": "Copiar URL",
|
||||
"date": "Data",
|
||||
"description": "Ver e gerenciar seu histórico de downloads",
|
||||
"duration": "Duração",
|
||||
"fileSize": "Tamanho do Arquivo",
|
||||
"filters": {
|
||||
"all": "Todos",
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"errors": "Erros"
|
||||
},
|
||||
"noHistory": "Nenhum histórico de download ainda",
|
||||
"noHistoryDescription": "Seus downloads concluídos aparecerão aqui",
|
||||
"openDownloadFolder": "Abrir Pasta de Downloads",
|
||||
"openFile": "Abrir Arquivo",
|
||||
"openFileLocation": "Abrir Localização do Arquivo",
|
||||
"openFolder": "Abrir Pasta",
|
||||
"openInBrowser": "Clique para abrir no navegador",
|
||||
"removeItem": "Remover Item",
|
||||
"stats": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"errors": "Erros",
|
||||
"total": "Total"
|
||||
},
|
||||
"status": {
|
||||
"cancelled": "Cancelado",
|
||||
"completed": "Concluído",
|
||||
"error": "Erro"
|
||||
},
|
||||
"title": "Histórico de Downloads"
|
||||
},
|
||||
"menu": {
|
||||
"about": "Sobre",
|
||||
"download": "Download",
|
||||
"playlist": "Baixar Playlist",
|
||||
"preferences": "Preferências",
|
||||
"supportedSites": "Sites Suportados",
|
||||
"theme": "Tema:"
|
||||
},
|
||||
"notifications": {
|
||||
"copyFailed": "Falha ao copiar para área de transferência",
|
||||
"downloadCompleted": "Download concluído",
|
||||
"downloadFailed": "Download falhou",
|
||||
"downloadStarted": "Download iniciado",
|
||||
"itemRemoved": "Item removido",
|
||||
"openFileFailed": "Falha ao abrir arquivo",
|
||||
"openFolderFailed": "Falha ao abrir pasta",
|
||||
"removeFailed": "Falha ao remover item",
|
||||
"settingsSaved": "Configurações salvas",
|
||||
"urlCopied": "URL copiada para área de transferência",
|
||||
"videoCopied": "Vídeo copiado para área de transferência"
|
||||
},
|
||||
"playlist": {
|
||||
"comingSoon": "Recurso de download de playlist em breve!",
|
||||
"completed": "Playlist baixada",
|
||||
"description": "Baixar todos os vídeos de uma playlist ou canal do YouTube",
|
||||
"downloadFailed": "Falha ao iniciar download da playlist",
|
||||
"downloadPlaylist": "Baixar Playlist",
|
||||
"downloadStarted": "Iniciado download de {{count}} vídeos da playlist",
|
||||
"downloadType": "Tipo de Download",
|
||||
"downloading": "Baixando playlist:",
|
||||
"endIndex": "Fim",
|
||||
"enterPlaylistUrl": "Inserir URL da Playlist",
|
||||
"fetchFailed": "Falha ao buscar informações da playlist",
|
||||
"filenameFormat": "Formato de nome de arquivo para playlists",
|
||||
"folderFormat": "Formato de nome de pasta para playlists",
|
||||
"foundVideos": "Encontrados {{count}} vídeos na playlist",
|
||||
"linkLabel": "URL da Playlist",
|
||||
"playlistUrlDescription": "Baixar todos os vídeos de uma playlist em lote",
|
||||
"range": "Intervalo (Opcional)",
|
||||
"resetToDefault": "Redefinir para padrão",
|
||||
"startIndex": "Início (1)",
|
||||
"title": "Baixar Playlist"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "Sobre",
|
||||
"advanced": "Avançado",
|
||||
"app": "Configurações do App",
|
||||
"audio": "Preferências de Áudio",
|
||||
"browserForCookies": "Selecionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "Usar arquivo de configuração",
|
||||
"configFileDescription": "Arquivo de configuração personalizado para yt-dlp",
|
||||
"dark": "Escuro",
|
||||
"description": "Configure suas preferências de download e configurações do aplicativo",
|
||||
"directorySelectError": "Falha ao selecionar diretório",
|
||||
"downloadPath": "Local de download",
|
||||
"downloadPathDescription": "Escolha onde salvar os arquivos baixados",
|
||||
"fileSelectError": "Falha ao selecionar arquivo",
|
||||
"general": "Geral",
|
||||
"language": "Idioma",
|
||||
"light": "Claro",
|
||||
"maxConcurrentDownloads": "Número máximo de downloads ativos",
|
||||
"maxConcurrentDownloadsDescription": "Número máximo de downloads simultâneos",
|
||||
"none": "Nenhum",
|
||||
"oneClickDownload": "Download de Um Clique",
|
||||
"oneClickDownloadDescription": "Habilitar download de um clique com configurações padrão",
|
||||
"oneClickDownloadType": "Tipo de download padrão",
|
||||
"oneClickDownloadTypeDescription": "Escolha o tipo de download padrão para downloads de um clique. A qualidade usa o preset abaixo.",
|
||||
"oneClickQuality": "Qualidade preferida",
|
||||
"oneClickQualityDescription": "Selecione o preset de qualidade usado para downloads de um clique",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "Automático",
|
||||
"bad": "Ruim",
|
||||
"best": "Melhor",
|
||||
"good": "Bom",
|
||||
"normal": "Normal",
|
||||
"worst": "Pior"
|
||||
},
|
||||
"proxy": "Proxy",
|
||||
"proxyDescription": "Servidor proxy para requisições de rede",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "Selecionar arquivo de configuração",
|
||||
"selectPath": "Selecionar",
|
||||
"showMoreFormats": "Mostrar mais opções de formato",
|
||||
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
"themeDescription": "Escolha um tema claro, escuro ou sistema para VidBee",
|
||||
"title": "Configurações",
|
||||
"tray": {
|
||||
"quit": "Sair",
|
||||
"showHome": "Mostrar Início"
|
||||
},
|
||||
"video": "Preferências de Vídeo"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "Suporta {{sites}} e mais.",
|
||||
"moreDescription": "A lista completa do yt-dlp é atualizada constantemente pela comunidade.",
|
||||
"moreTitle": "Precisa de outro site?",
|
||||
"openFullList": "Abrir lista completa de sites suportados",
|
||||
"pageDescription": "VidBee usa yt-dlp nos bastidores para alcançar centenas de fontes.",
|
||||
"pageIntro": "Aqui estão os serviços principais que as pessoas mais baixam.",
|
||||
"pageTitle": "Sites Suportados",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "Álbuns de artistas independentes e lançamentos da comunidade.",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "Notícias globais, esportes e clipes de entretenimento.",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "Vídeos de feed, Watch e Reels de páginas públicas.",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "Conteúdo de feed, Stories, Reels e Highlights.",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Streams ao vivo e replays de criadores na plataforma Kick.",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "Palestras profissionais, webinars e vídeos de aprendizado.",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "Mixes de DJ, programas de rádio e áudio long-form.",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "Animação japonesa, música e arquivo de transmissão ao vivo.",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "Pins de ideias, Reels de tutoriais e vídeos de inspiração lifestyle.",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "Clipes incorporados e vídeos hospedados das comunidades.",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "Faixas musicais, playlists e sets de DJ.",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "Vídeos curtos móveis, efeitos e streams ao vivo.",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "Mídia curta criativa e edições de fãs.",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "Streams ao vivo de gaming, música e IRL e VOD.",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "Posts de timeline, gravações Spaces e transmissões.",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "Hospedagem de vídeo de alta qualidade para criadores e empresas.",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "Vídeo long-form e livestream de criadores em todo o mundo.",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "Vídeos musicais oficiais, álbuns e performances ao vivo.",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "Plataformas principais",
|
||||
"viewAll": "Ver todos os sites suportados"
|
||||
}
|
||||
}
|
||||
418
src/renderer/src/locales/zh-TW.json
Normal file
418
src/renderer/src/locales/zh-TW.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"about": {
|
||||
"actions": {
|
||||
"checkUpdates": "檢查更新",
|
||||
"email": "電子郵件",
|
||||
"feedback": "意見回饋",
|
||||
"openRepo": "開啟 GitHub 儲存庫",
|
||||
"view": "檢視",
|
||||
"visit": "造訪"
|
||||
},
|
||||
"appName": "VidBee",
|
||||
"autoUpdateDescription": "在背景自動下載並安裝新版本。",
|
||||
"autoUpdateTitle": "自動更新",
|
||||
"betaProgramDescription": "搶先獲得預覽版和即將發布的新功能。",
|
||||
"betaProgramTitle": "預覽通道",
|
||||
"description": "VidBee 是一個基於 Node.js 和 Electron 構建的自由開源應用,使用 yt-dlp 來完成下載。",
|
||||
"followAuthorActions": {
|
||||
"follow": "關注 @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "獲取 VidBee 的最新消息和更新。",
|
||||
"followAuthorSupport": "在 X (Twitter) 上關注開發者,獲取 VidBee 的最新更新和消息。",
|
||||
"followAuthorTitle": "關注開發者",
|
||||
"here": "此處",
|
||||
"homepage": "首頁",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"error": "無法取得最新版本",
|
||||
"uptodate": "您已是最新版本"
|
||||
},
|
||||
"notifications": {
|
||||
"checkingUpdates": "正在搜尋更新...",
|
||||
"downloadError": "下載更新失敗",
|
||||
"downloadStarted": "開始下載...",
|
||||
"downloadUpdate": "下載並安裝更新 {{version}}?",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"restartToUpdate": "立即重新啟動以安裝更新?",
|
||||
"updateAvailable": "發現新版本:{{version}}",
|
||||
"updateDownloaded": "更新已下載,重新啟動以安裝",
|
||||
"updateError": "檢查更新失敗:{{error}}"
|
||||
},
|
||||
"preferencesDescription": "無需離開此頁即可調整更新設定。",
|
||||
"preferencesTitle": "快速切換",
|
||||
"resources": {
|
||||
"changelog": "發行說明",
|
||||
"changelogDescription": "了解每個版本的變更內容。",
|
||||
"contact": "電子郵件支援",
|
||||
"contactDescription": "直接聯繫我們以獲取協助或開展合作。",
|
||||
"documentation": "說明中心",
|
||||
"documentationDescription": "指南、常見問題和常見流程。",
|
||||
"feedback": "意見回饋與問題",
|
||||
"feedbackDescription": "在 GitHub 上分享想法或回報問題。",
|
||||
"license": "授權條款",
|
||||
"licenseDescription": "查閱開源授權條款。",
|
||||
"website": "官方網站",
|
||||
"websiteDescription": "產品亮點、路線圖與社群動態。"
|
||||
},
|
||||
"resourcesDescription": "了解 VidBee 並保持關注的實用連結。",
|
||||
"resourcesTitle": "資源",
|
||||
"shareActions": {
|
||||
"copy": "複製連結",
|
||||
"facebook": "在 Facebook 上分享",
|
||||
"twitter": "在 X (Twitter) 上分享"
|
||||
},
|
||||
"shareDescription": "一鍵與您的社群分享 VidBee。",
|
||||
"shareSupport": "向您的朋友推薦 VidBee 以支援我們的成長和更新。",
|
||||
"shareTitle": "廣為宣傳",
|
||||
"sourceCode": "原始碼已開放",
|
||||
"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": "清除錯誤",
|
||||
"copyToClipboard": "複製到剪貼簿",
|
||||
"copyUrl": "複製連結",
|
||||
"date": "日期",
|
||||
"description": "檢視並管理下載歷史",
|
||||
"duration": "時長",
|
||||
"fileSize": "檔案大小",
|
||||
"filters": {
|
||||
"all": "全部",
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"errors": "錯誤"
|
||||
},
|
||||
"noHistory": "暫無下載歷史",
|
||||
"noHistoryDescription": "完成的下載會顯示在這裡",
|
||||
"openDownloadFolder": "開啟下載資料夾",
|
||||
"openFile": "開啟檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"openFolder": "開啟資料夾",
|
||||
"openInBrowser": "點擊在瀏覽器中開啟",
|
||||
"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": "連結已複製到剪貼簿",
|
||||
"videoCopied": "影片已複製到剪貼簿"
|
||||
},
|
||||
"playlist": {
|
||||
"clearPreview": "清晰預覽",
|
||||
"comingSoon": "播放清單下載功能即將推出!",
|
||||
"completed": "播放清單已下載",
|
||||
"description": "下載 YouTube 播放清單或頻道中的全部影片",
|
||||
"downloadFailed": "啟動播放清單下載失敗",
|
||||
"downloadPlaylist": "下載播放清單",
|
||||
"downloadStarted": "已開始下載播放清單中的 {{count}} 個影片",
|
||||
"downloadType": "下載類型",
|
||||
"downloading": "正在下載播放清單:",
|
||||
"endIndex": "結束",
|
||||
"enterPlaylistUrl": "輸入播放清單連結",
|
||||
"fetchFailed": "取得播放清單資訊失敗",
|
||||
"filenameFormat": "播放清單檔案名稱格式",
|
||||
"folderFormat": "播放清單資料夾命名格式",
|
||||
"foundVideos": "在播放清單中找到 {{count}} 個影片",
|
||||
"groupActive": "{{count}} 個活躍",
|
||||
"groupErrors": "{{count}} 失敗",
|
||||
"groupSummary": "{{已完成}} / {{總計}}已完成",
|
||||
"linkLabel": "播放清單連結",
|
||||
"noEntries": "在此播放列表中找不到視頻",
|
||||
"noEntriesInRange": "所選範圍內沒有視頻",
|
||||
"noRangeSelected": "沒有結束設置 - 已選擇完整播放列表",
|
||||
"playlistUrlDescription": "批量下載播放清單中的所有影片",
|
||||
"positionLabel": "第 {{index}} 項,共 {{total}} 項",
|
||||
"previewButton": "預覽播放列表",
|
||||
"previewFailed": "預覽播放列表失敗",
|
||||
"previewRequired": "下載前預覽播放列表。",
|
||||
"previewSummary": "下載前預覽播放列表項目。",
|
||||
"range": "範圍(可選)",
|
||||
"resetToDefault": "恢復預設",
|
||||
"selectedRange": "範圍:{{開始}}-{{結束}}",
|
||||
"showingCount": "顯示 {{count}} 個視頻",
|
||||
"startIndex": "開始(1)",
|
||||
"title": "下載播放清單",
|
||||
"totalVideos": "視頻總數:{{count}}",
|
||||
"untitled": "無標題播放列表"
|
||||
},
|
||||
"settings": {
|
||||
"aboutTab": "關於",
|
||||
"advanced": "進階",
|
||||
"app": "應用程式設定",
|
||||
"audio": "音訊偏好",
|
||||
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
||||
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"clearCookiesFile": "清除",
|
||||
"configFile": "使用設定檔",
|
||||
"configFileDescription": "yt-dlp 的自訂設定檔",
|
||||
"cookiesFile": "餅乾文件",
|
||||
"cookiesFileDescription": "要加載以進行身份驗證的 Netscape 格式的 cookie 文件",
|
||||
"cookiesHelpBrowser": "選擇上面的瀏覽器以自動重用其登錄會話。",
|
||||
"cookiesHelpFaq": "打開 yt-dlp cookies 常見問題解答",
|
||||
"cookiesHelpFile": "導出 Netscape cookies 文件(請參閱 yt-dlp FAQ)並在需要時在此處選擇它。",
|
||||
"cookiesHelpTitle": "使用cookie",
|
||||
"dark": "深色",
|
||||
"description": "設定下載偏好和應用程式設定",
|
||||
"directorySelectError": "選擇目錄失敗",
|
||||
"downloadPath": "下載位置",
|
||||
"downloadPathDescription": "選擇儲存下載檔案的位置",
|
||||
"fileSelectError": "選擇檔案失敗",
|
||||
"general": "一般",
|
||||
"language": "語言",
|
||||
"light": "淺色",
|
||||
"maxConcurrentDownloads": "最大活動下載數",
|
||||
"maxConcurrentDownloadsDescription": "最大同時下載數量",
|
||||
"none": "無",
|
||||
"oneClickDownload": "一鍵下載",
|
||||
"oneClickDownloadDescription": "啟用使用預設設定的一鍵下載",
|
||||
"oneClickDownloadType": "預設下載類型",
|
||||
"oneClickDownloadTypeDescription": "選擇一鍵下載的預設下載類型。品質使用下面的預設。",
|
||||
"oneClickQuality": "首選品質",
|
||||
"oneClickQualityDescription": "選擇用於一鍵下載的品質預設",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "自動",
|
||||
"bad": "較差",
|
||||
"best": "最佳",
|
||||
"good": "良好",
|
||||
"normal": "標準",
|
||||
"worst": "最差"
|
||||
},
|
||||
"openLinkError": "無法打開鏈接",
|
||||
"proxy": "代理伺服器",
|
||||
"proxyDescription": "網路請求的代理伺服器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "選擇設定檔",
|
||||
"selectPath": "選擇",
|
||||
"showMoreFormats": "顯示更多格式選項",
|
||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||
"system": "系統",
|
||||
"theme": "主題",
|
||||
"themeDescription": "為 VidBee 選擇淺色、深色或系統主題",
|
||||
"title": "設定",
|
||||
"tray": {
|
||||
"quit": "結束",
|
||||
"showHome": "顯示首頁"
|
||||
},
|
||||
"video": "影片偏好"
|
||||
},
|
||||
"sites": {
|
||||
"homeInlineDescription": "支援 {{sites}} 等更多網站。",
|
||||
"moreDescription": "完整的 yt-dlp 清單由社群持續更新。",
|
||||
"moreTitle": "需要其他網站?",
|
||||
"openFullList": "開啟全部支援網站清單",
|
||||
"pageDescription": "VidBee 使用 yt-dlp 覆蓋數百個資源。",
|
||||
"pageIntro": "以下是大家最常下載的主流服務。",
|
||||
"pageTitle": "支援的網站",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "獨立藝術家專輯和社群發布。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "全球新聞、體育和娛樂片段。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "來自公開頁面的動態、觀看和 Reels 影片。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "動態、故事、Reels 和精選內容。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 平台上的創作者直播和回放。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "專業演講、網路研討會和學習影片。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 混音、廣播節目和長音訊。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本動畫、音樂和直播檔案。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "創意圖釘、教學 Reels 和生活方式靈感影片。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "來自社群的嵌入片段和託管影片。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音樂曲目、播放清單和 DJ 套裝。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "短影片、特效和直播。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "創意短影片和粉絲編輯。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "遊戲、音樂和 IRL 直播和 VOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "時間軸貼文、Spaces 錄音和廣播。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "高品質創作者和商業影片託管。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "來自全球創作者的長影片和直播。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "官方音樂影片、專輯和現場表演。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "檢視全部支援的網站"
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,24 @@
|
||||
"betaProgramDescription": "抢先获得预览版和即将发布的新功能。",
|
||||
"betaProgramTitle": "预览通道",
|
||||
"description": "这是一个基于 Node.js 和 Electron 构建的自由开源应用,使用 yt-dlp 来完成下载。",
|
||||
"followAuthorActions": {
|
||||
"follow": "关注 @nexmoex"
|
||||
},
|
||||
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
|
||||
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
|
||||
"followAuthorTitle": "关注开发者",
|
||||
"here": "此处",
|
||||
"homepage": "主页",
|
||||
"notifications": {
|
||||
"checkingUpdates": "正在查找更新...",
|
||||
"updateAvailable": "发现新版本: {{version}}",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"updateError": "检查更新失败: {{error}}",
|
||||
"downloadUpdate": "下载并安装更新 {{version}}?",
|
||||
"downloadStarted": "开始下载...",
|
||||
"downloadError": "下载更新失败",
|
||||
"downloadStarted": "开始下载...",
|
||||
"downloadUpdate": "下载并安装更新 {{version}}?",
|
||||
"noUpdatesAvailable": "您正在使用最新版本",
|
||||
"restartToUpdate": "立即重启以安装更新?",
|
||||
"updateAvailable": "发现新版本: {{version}}",
|
||||
"updateDownloaded": "更新已下载,重启以安装",
|
||||
"restartToUpdate": "立即重启以安装更新?"
|
||||
"updateError": "检查更新失败: {{error}}"
|
||||
},
|
||||
"preferencesDescription": "无需离开此页即可调整更新设置。",
|
||||
"preferencesTitle": "快速切换",
|
||||
@@ -46,10 +52,15 @@
|
||||
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
|
||||
"resourcesTitle": "资源",
|
||||
"sourceCode": "源代码已开放",
|
||||
"tagline": "面向每位创作者的 AI 友好下载助手",
|
||||
"title": "关于",
|
||||
"version": "版本",
|
||||
"versionLabel": "v{{version}}"
|
||||
"versionLabel": "v{{version}}",
|
||||
"latestVersionBadge": "最新版本:v{{version}}",
|
||||
"latestVersionStatus": {
|
||||
"available": "有新版本可用",
|
||||
"uptodate": "您已是最新版本",
|
||||
"error": "无法获取最新版本"
|
||||
}
|
||||
},
|
||||
"advancedOptions": {
|
||||
"closeWhenDone": "下载完成后关闭应用",
|
||||
@@ -153,7 +164,6 @@
|
||||
"clearCompleted": "清除已完成",
|
||||
"clearErrors": "清除错误",
|
||||
"copyUrl": "复制链接",
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"date": "日期",
|
||||
"description": "查看并管理下载历史",
|
||||
"duration": "时长",
|
||||
@@ -166,11 +176,11 @@
|
||||
},
|
||||
"noHistory": "暂无下载历史",
|
||||
"noHistoryDescription": "完成的下载会显示在这里",
|
||||
"openDownloadFolder": "打开下载文件夹",
|
||||
"openFile": "打开文件",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"openFolder": "打开文件夹",
|
||||
"openDownloadFolder": "打开下载文件夹",
|
||||
"outputPath": "输出路径",
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"removeItem": "移除项目",
|
||||
"stats": {
|
||||
"cancelled": "已取消",
|
||||
@@ -233,34 +243,52 @@
|
||||
"app": "应用设置",
|
||||
"audio": "音频偏好",
|
||||
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
||||
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
||||
"browserOptions": {
|
||||
"brave": "Brave",
|
||||
"chrome": "Chrome",
|
||||
"edge": "Edge",
|
||||
"firefox": "Firefox",
|
||||
"safari": "Safari"
|
||||
},
|
||||
"configFile": "使用配置文件",
|
||||
"configFileDescription": "yt-dlp 的自定义配置文件",
|
||||
"dark": "深色",
|
||||
"description": "配置下载偏好和应用设置",
|
||||
"directorySelectError": "选择目录失败",
|
||||
"downloadPath": "下载位置",
|
||||
"downloadPathDescription": "选择保存下载文件的位置",
|
||||
"fileSelectError": "选择文件失败",
|
||||
"general": "通用",
|
||||
"language": "语言",
|
||||
"languageOptions": {
|
||||
"chinese": "简体中文",
|
||||
"english": "英语"
|
||||
},
|
||||
"light": "浅色",
|
||||
"maxConcurrentDownloads": "最大活动下载数",
|
||||
"maxConcurrentDownloadsDescription": "最大同时下载数量",
|
||||
"none": "无",
|
||||
"oneClickAudioForVideo": "视频默认音频",
|
||||
"oneClickAudioFormat": "默认音频格式",
|
||||
"oneClickDownload": "一键下载",
|
||||
"oneClickDownloadDescription": "启用使用默认设置的一键下载",
|
||||
"oneClickDownloadType": "默认下载类型",
|
||||
"oneClickVideoFormat": "默认视频格式",
|
||||
"preferredAudioQuality": "首选音频质量",
|
||||
"preferredVideoCodec": "首选视频编码",
|
||||
"preferredVideoQuality": "首选视频质量",
|
||||
"oneClickDownloadTypeDescription": "选择一键下载的默认下载类型。质量使用下面的预设。",
|
||||
"oneClickQuality": "首选质量",
|
||||
"oneClickQualityDescription": "选择用于一键下载的质量预设",
|
||||
"oneClickQualityOptions": {
|
||||
"auto": "自动",
|
||||
"bad": "较差",
|
||||
"best": "最佳",
|
||||
"good": "良好",
|
||||
"normal": "标准",
|
||||
"worst": "最差"
|
||||
},
|
||||
"proxy": "代理",
|
||||
"proxyDescription": "网络请求的代理服务器",
|
||||
"proxyPlaceholder": "http://proxy:port",
|
||||
"selectConfigFile": "选择配置文件",
|
||||
"selectPath": "选择",
|
||||
"showMoreFormats": "显示更多格式选项",
|
||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||
"system": "系统",
|
||||
"theme": "主题",
|
||||
"themeDescription": "为 VidBee 选择浅色、深色或系统主题",
|
||||
"title": "设置",
|
||||
"tray": {
|
||||
"quit": "退出",
|
||||
@@ -276,6 +304,80 @@
|
||||
"pageDescription": "VidBee 使用 yt-dlp 覆盖数百个资源。",
|
||||
"pageIntro": "以下是大家最常下载的主流服务。",
|
||||
"pageTitle": "支持的网站",
|
||||
"popular": {
|
||||
"bandcamp": {
|
||||
"description": "独立艺术家专辑和社区发布。",
|
||||
"label": "Bandcamp"
|
||||
},
|
||||
"dailymotion": {
|
||||
"description": "全球新闻、体育和娱乐片段。",
|
||||
"label": "Dailymotion"
|
||||
},
|
||||
"facebook": {
|
||||
"description": "来自公共页面的动态、观看和 Reels 视频。",
|
||||
"label": "Facebook"
|
||||
},
|
||||
"instagram": {
|
||||
"description": "动态、故事、Reels 和精选内容。",
|
||||
"label": "Instagram"
|
||||
},
|
||||
"kick": {
|
||||
"description": "Kick 平台上的创作者直播和回放。",
|
||||
"label": "Kick"
|
||||
},
|
||||
"linkedin": {
|
||||
"description": "专业演讲、网络研讨会和学习视频。",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
"mixcloud": {
|
||||
"description": "DJ 混音、广播节目和长音频。",
|
||||
"label": "Mixcloud"
|
||||
},
|
||||
"niconico": {
|
||||
"description": "日本动画、音乐和直播档案。",
|
||||
"label": "Niconico"
|
||||
},
|
||||
"pinterest": {
|
||||
"description": "创意图钉、教程 Reels 和生活方式灵感视频。",
|
||||
"label": "Pinterest"
|
||||
},
|
||||
"reddit": {
|
||||
"description": "来自社区的嵌入片段和托管视频。",
|
||||
"label": "Reddit"
|
||||
},
|
||||
"soundcloud": {
|
||||
"description": "音乐曲目、播放列表和 DJ 套装。",
|
||||
"label": "SoundCloud"
|
||||
},
|
||||
"tiktok": {
|
||||
"description": "短视频、特效和直播。",
|
||||
"label": "TikTok"
|
||||
},
|
||||
"tumblr": {
|
||||
"description": "创意短视频和粉丝编辑。",
|
||||
"label": "Tumblr"
|
||||
},
|
||||
"twitch": {
|
||||
"description": "游戏、音乐和 IRL 直播和 VOD。",
|
||||
"label": "Twitch"
|
||||
},
|
||||
"twitter": {
|
||||
"description": "时间线帖子、Spaces 录音和广播。",
|
||||
"label": "X (Twitter)"
|
||||
},
|
||||
"vimeo": {
|
||||
"description": "高质量创作者和商业视频托管。",
|
||||
"label": "Vimeo"
|
||||
},
|
||||
"youtube": {
|
||||
"description": "来自全球创作者的长视频和直播。",
|
||||
"label": "YouTube"
|
||||
},
|
||||
"youtubemusic": {
|
||||
"description": "官方音乐视频、专辑和现场表演。",
|
||||
"label": "YouTube Music"
|
||||
}
|
||||
},
|
||||
"popularSection": "主流平台",
|
||||
"viewAll": "查看全部支持的网站"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './assets/main.css'
|
||||
import './assets/global.css'
|
||||
import 'flag-icons/css/flag-icons.min.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
@@ -36,12 +36,19 @@ interface AboutResource {
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
type LatestVersionState =
|
||||
| { status: 'available'; version: string }
|
||||
| { status: 'uptodate'; version: string }
|
||||
| { status: 'error'; error?: string }
|
||||
| null
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://github.com/nexmoe/VidBee'
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
@@ -79,20 +86,35 @@ export function About() {
|
||||
|
||||
if (result.available) {
|
||||
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
|
||||
setLatestVersionState({
|
||||
status: 'available',
|
||||
version: result.version ?? ''
|
||||
})
|
||||
} else if (result.error) {
|
||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||
setLatestVersionState({
|
||||
status: 'error',
|
||||
error: result.error
|
||||
})
|
||||
} else {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error)
|
||||
toast.error(t('about.notifications.updateError', { error: 'Unknown error' }))
|
||||
setLatestVersionState({
|
||||
status: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const shareLinks = useMemo(() => {
|
||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||
const encodedText = encodeURIComponent(t('about.tagline'))
|
||||
const encodedText = encodeURIComponent(t('about.description'))
|
||||
|
||||
return {
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||
@@ -126,8 +148,30 @@ export function About() {
|
||||
}
|
||||
}
|
||||
|
||||
const latestVersionBadgeText =
|
||||
latestVersionState && latestVersionState.status !== 'error' && latestVersionState.version
|
||||
? t('about.latestVersionBadge', { version: latestVersionState.version })
|
||||
: null
|
||||
const latestVersionStatusKey = latestVersionState
|
||||
? `about.latestVersionStatus.${latestVersionState.status}`
|
||||
: null
|
||||
const latestVersionStatusClass =
|
||||
latestVersionState?.status === 'available'
|
||||
? 'text-primary'
|
||||
: latestVersionState?.status === 'error'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||
|
||||
const aboutResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
{
|
||||
icon: LinkIcon,
|
||||
label: t('about.resources.website'),
|
||||
description: t('about.resources.websiteDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://vidbee.org/'
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
label: t('about.resources.changelog'),
|
||||
@@ -163,11 +207,6 @@ export function About() {
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('about.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
@@ -176,11 +215,25 @@ export function About() {
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('about.tagline')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
</Badge>
|
||||
{latestVersionState ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{latestVersionBadgeText ? (
|
||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||
) : null}
|
||||
{latestVersionStatusText ? (
|
||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||
{latestVersionStatusText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -213,6 +266,29 @@ export function About() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.followAuthorTitle')}</CardTitle>
|
||||
<CardDescription>{t('about.followAuthorDescription')}</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.followAuthorSupport')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openShareUrl('https://x.com/nexmoex')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
{t('about.followAuthorActions.follow')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||
|
||||
@@ -15,15 +15,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Tabs, TabsContent } from '@renderer/components/ui/tabs'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import type { AppSettings, OneClickQualityPreset } from '@shared/types'
|
||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
|
||||
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
|
||||
import { VideoInfoCard } from '../components/video/VideoInfoCard'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import {
|
||||
@@ -141,17 +142,48 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inlinePreviewSites = popularSites
|
||||
.slice(0, 3)
|
||||
.map((site) => site.label)
|
||||
.map((site) => t(`sites.popular.${site.id}.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 [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
|
||||
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
|
||||
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
|
||||
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
|
||||
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
|
||||
|
||||
const computePlaylistRange = useCallback(
|
||||
(info: PlaylistInfo) => {
|
||||
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
|
||||
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
|
||||
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
|
||||
const endValue =
|
||||
rawEnd !== undefined
|
||||
? info.entryCount > 0
|
||||
? Math.min(rawEnd, info.entryCount)
|
||||
: rawEnd
|
||||
: undefined
|
||||
return { start, end: endValue }
|
||||
},
|
||||
[startIndex, endIndex]
|
||||
)
|
||||
|
||||
const selectedPlaylistEntries = useMemo(() => {
|
||||
if (!playlistInfo) {
|
||||
return []
|
||||
}
|
||||
const range = computePlaylistRange(playlistInfo)
|
||||
const previewEnd = range.end ?? playlistInfo.entryCount
|
||||
return playlistInfo.entries.filter(
|
||||
(entry) => entry.index >= range.start && entry.index <= previewEnd
|
||||
)
|
||||
}, [playlistInfo, computePlaylistRange])
|
||||
|
||||
const syncHistoryItem = useCallback(
|
||||
async (id: string) => {
|
||||
@@ -366,70 +398,131 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
|
||||
// Playlist handlers
|
||||
const handlePastePlaylistUrl = useCallback(async () => {
|
||||
if (playlistBusy) return
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (!text.trim()) {
|
||||
toast.error(t('errors.clipboardEmpty'))
|
||||
return
|
||||
}
|
||||
setPlaylistUrl(text.trim())
|
||||
const trimmed = text.trim()
|
||||
setPlaylistUrl(trimmed)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to paste URL:', error)
|
||||
toast.error(t('errors.pasteFromClipboard'))
|
||||
}
|
||||
}, [t])
|
||||
}, [playlistBusy, t])
|
||||
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
const handleClearPlaylistPreview = useCallback(() => {
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}, [])
|
||||
|
||||
const handlePreviewPlaylist = useCallback(async () => {
|
||||
if (!playlistUrl.trim()) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
setPlaylistLoading(true)
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistPreviewLoading(true)
|
||||
try {
|
||||
// Get playlist info first to show user what will be downloaded
|
||||
const playlistInfo = await ipcServices.download.getPlaylistInfo(playlistUrl)
|
||||
const trimmedUrl = playlistUrl.trim()
|
||||
const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)
|
||||
setPlaylistInfo(info)
|
||||
if (info.entryCount === 0) {
|
||||
toast.error(t('playlist.noEntries'))
|
||||
return
|
||||
}
|
||||
toast.success(t('playlist.foundVideos', { count: info.entryCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch playlist info:', error)
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : t('playlist.previewFailed')
|
||||
setPlaylistPreviewError(message)
|
||||
setPlaylistInfo(null)
|
||||
toast.error(t('playlist.previewFailed'))
|
||||
} finally {
|
||||
setPlaylistPreviewLoading(false)
|
||||
}
|
||||
}, [playlistUrl, t])
|
||||
|
||||
toast.success(t('playlist.foundVideos', { count: playlistInfo.entryCount }))
|
||||
const handleDownloadPlaylist = useCallback(async () => {
|
||||
const trimmedUrl = playlistUrl.trim()
|
||||
if (!trimmedUrl) {
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!playlistInfo) {
|
||||
toast.error(t('playlist.previewRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setPlaylistPreviewError(null)
|
||||
setPlaylistDownloadLoading(true)
|
||||
try {
|
||||
const info = playlistInfo
|
||||
setPlaylistInfo(info)
|
||||
|
||||
if (info.entryCount === 0) {
|
||||
toast.error(t('playlist.noEntries'))
|
||||
return
|
||||
}
|
||||
|
||||
const range = computePlaylistRange(info)
|
||||
const previewEnd = range.end ?? info.entryCount
|
||||
|
||||
if (previewEnd < range.start || previewEnd === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
// Build format preference based on settings
|
||||
const format =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
// Start playlist download
|
||||
const downloadIds = await ipcServices.download.startPlaylistDownload({
|
||||
url: playlistUrl.trim(),
|
||||
const result = await ipcServices.download.startPlaylistDownload({
|
||||
url: trimmedUrl,
|
||||
type: downloadType,
|
||||
format,
|
||||
startIndex: parseInt(startIndex, 10) || 1,
|
||||
endIndex: endIndex ? parseInt(endIndex, 10) : undefined
|
||||
startIndex: range.start,
|
||||
endIndex: range.end
|
||||
})
|
||||
|
||||
// Add all downloads to the renderer state
|
||||
for (const id of downloadIds) {
|
||||
if (result.totalCount === 0) {
|
||||
toast.error(t('playlist.noEntriesInRange'))
|
||||
return
|
||||
}
|
||||
|
||||
const baseCreatedAt = Date.now()
|
||||
result.entries.forEach((entry, index) => {
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: playlistUrl.trim(),
|
||||
title: t('download.fetchingVideoInfo'),
|
||||
id: entry.downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title || t('download.fetchingVideoInfo'),
|
||||
type: downloadType,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt: Date.now()
|
||||
createdAt: baseCreatedAt + index,
|
||||
playlistId: result.groupId,
|
||||
playlistTitle: result.playlistTitle,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: result.totalCount
|
||||
}
|
||||
addDownload(downloadItem)
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(t('playlist.downloadStarted', { count: downloadIds.length }))
|
||||
setPlaylistUrl('') // Clear the URL after starting download
|
||||
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
|
||||
} catch (error) {
|
||||
console.error('Failed to start playlist download:', error)
|
||||
toast.error(t('playlist.downloadFailed'))
|
||||
} finally {
|
||||
setPlaylistLoading(false)
|
||||
setPlaylistDownloadLoading(false)
|
||||
}
|
||||
}, [playlistUrl, downloadType, startIndex, endIndex, settings, addDownload, t])
|
||||
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
|
||||
|
||||
// Auto-focus input on mount
|
||||
useEffect(() => {
|
||||
@@ -442,7 +535,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Tabs defaultValue="single" className="w-full">
|
||||
{/* <TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('download.singleVideo')}
|
||||
@@ -451,7 +544,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
<ListVideo className="h-4 w-4" />
|
||||
{t('playlist.title')}
|
||||
</TabsTrigger>
|
||||
</TabsList> */}
|
||||
</TabsList>
|
||||
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6">
|
||||
@@ -580,14 +673,18 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
id={playlistUrlId}
|
||||
placeholder="https://www.youtube.com/playlist?list=..."
|
||||
value={playlistUrl}
|
||||
onChange={(e) => setPlaylistUrl(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPlaylistUrl(e.target.value)
|
||||
setPlaylistInfo(null)
|
||||
setPlaylistPreviewError(null)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Button
|
||||
onClick={handlePastePlaylistUrl}
|
||||
variant="outline"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
{t('download.paste')}
|
||||
</Button>
|
||||
@@ -600,7 +697,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
@@ -621,7 +718,7 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
value={startIndex}
|
||||
onChange={(e) => setStartIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -629,29 +726,68 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
|
||||
value={endIndex}
|
||||
onChange={(e) => setEndIndex(e.target.value)}
|
||||
min="1"
|
||||
disabled={playlistLoading}
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={playlistLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{playlistInfo && (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{playlistPreviewError}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||
import type { OneClickQualityPreset } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
@@ -32,11 +32,26 @@ export function Settings() {
|
||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlatform = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchPlatform()
|
||||
}, [])
|
||||
|
||||
const handleSettingChange = async (
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
@@ -54,7 +69,7 @@ export function Settings() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error('Failed to select directory')
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +82,32 @@ export function Settings() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select file:', error)
|
||||
toast.error('Failed to select file')
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCookiesFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('cookiesPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select cookies file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCookiesFaq = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
await ipcServices.fs.openExternal(
|
||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to open cookies FAQ:', error)
|
||||
toast.error(t('settings.openLinkError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +140,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.downloadPath')}</ItemTitle>
|
||||
<ItemDescription>Choose where to save downloaded files</ItemDescription>
|
||||
<ItemDescription>{t('settings.downloadPathDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
@@ -115,9 +155,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.theme')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Choose a light, dark, or system theme for VidBee
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.themeDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -160,8 +198,7 @@ export function Settings() {
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.oneClickDownloadType')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Choose the default download type for one-click downloads. Quality uses the
|
||||
preset below.
|
||||
{t('settings.oneClickDownloadTypeDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
@@ -185,9 +222,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.oneClickQuality')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Select the quality preset used for one-click downloads
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.oneClickQualityDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -230,9 +265,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.showMoreFormats')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
Display additional format options in the interface
|
||||
</ItemDescription>
|
||||
<ItemDescription>{t('settings.showMoreFormatsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
@@ -245,11 +278,30 @@ export function Settings() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-4 mt-2">
|
||||
{platform === 'darwin' && (
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.hideDockIcon')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.hideDockIconDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.hideDockIcon}
|
||||
onCheckedChange={(value) => handleSettingChange('hideDockIcon', value)}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
)}
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||
<ItemDescription>Maximum number of simultaneous downloads</ItemDescription>
|
||||
<ItemDescription>
|
||||
{t('settings.maxConcurrentDownloadsDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
@@ -274,43 +326,14 @@ export function Settings() {
|
||||
|
||||
<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>
|
||||
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
placeholder="http://proxy:port"
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={settings.proxy}
|
||||
onChange={(e) => handleSettingChange('proxy', e.target.value)}
|
||||
className="w-64"
|
||||
@@ -323,7 +346,7 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
||||
<ItemDescription>Custom configuration file for yt-dlp</ItemDescription>
|
||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
@@ -333,6 +356,76 @@ export function Settings() {
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesFile')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
||||
disabled={!settings.cookiesPath}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||
{t('settings.cookiesHelpFaq')}
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,22 @@ export function SupportedSites() {
|
||||
<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>
|
||||
))}
|
||||
{popularSites.map((site) => {
|
||||
const labelKey = `sites.popular.${site.id}.label`
|
||||
const descriptionKey = `sites.popular.${site.id}.description`
|
||||
const label = t(labelKey)
|
||||
const description = t(descriptionKey)
|
||||
const hasDescription = description !== descriptionKey
|
||||
|
||||
return (
|
||||
<li key={site.id} className="rounded-md border border-border px-6 py-5">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{hasDescription ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DownloadHistoryItem, DownloadItem } from '../../../shared/types'
|
||||
export type DownloadRecord = DownloadItem & {
|
||||
entryType: 'active' | 'history'
|
||||
downloadedAt?: number
|
||||
downloadPath?: string
|
||||
}
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
@@ -22,7 +23,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
status: item.status,
|
||||
progress: undefined,
|
||||
error: item.error,
|
||||
outputPath: item.outputPath,
|
||||
downloadPath: item.downloadPath,
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
fileSize: item.fileSize,
|
||||
@@ -38,6 +39,10 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
viewCount: item.viewCount,
|
||||
tags: item.tags,
|
||||
selectedFormat: item.selectedFormat,
|
||||
playlistId: item.playlistId,
|
||||
playlistTitle: item.playlistTitle,
|
||||
playlistIndex: item.playlistIndex,
|
||||
playlistSize: item.playlistSize,
|
||||
entryType: 'history',
|
||||
downloadedAt: item.downloadedAt
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { atom } from 'jotai'
|
||||
import { normalizeLanguageCode } from '../../../shared/languages'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { defaultSettings } from '../../../shared/types'
|
||||
import i18n from '../i18n'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
|
||||
// Settings atom
|
||||
@@ -10,7 +12,18 @@ export const settingsAtom = atom<AppSettings>(defaultSettings)
|
||||
export const loadSettingsAtom = atom(null, async (_get, set) => {
|
||||
try {
|
||||
const settings = await ipcServices.settings.getAll()
|
||||
set(settingsAtom, settings)
|
||||
const savedLanguage = normalizeLanguageCode(settings.language)
|
||||
const currentLanguage = normalizeLanguageCode(i18n.language)
|
||||
|
||||
if (currentLanguage !== savedLanguage) {
|
||||
try {
|
||||
await i18n.changeLanguage(savedLanguage)
|
||||
} catch (error) {
|
||||
console.error('Failed to apply saved language:', error)
|
||||
}
|
||||
}
|
||||
|
||||
set(settingsAtom, { ...settings, language: savedLanguage })
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
}
|
||||
|
||||
105
src/shared/languages.ts
Normal file
105
src/shared/languages.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface LanguageDefinition {
|
||||
flag: string
|
||||
name: string
|
||||
hreflang: string
|
||||
}
|
||||
|
||||
export const languages = {
|
||||
en: {
|
||||
flag: 'fi fi-us',
|
||||
name: 'English',
|
||||
hreflang: 'en'
|
||||
},
|
||||
es: {
|
||||
flag: 'fi fi-es',
|
||||
name: 'Español',
|
||||
hreflang: 'es'
|
||||
},
|
||||
ar: {
|
||||
flag: 'fi fi-sa',
|
||||
name: 'العربية',
|
||||
hreflang: 'ar'
|
||||
},
|
||||
id: {
|
||||
flag: 'fi fi-id',
|
||||
name: 'Bahasa Indonesia',
|
||||
hreflang: 'id'
|
||||
},
|
||||
pt: {
|
||||
flag: 'fi fi-br',
|
||||
name: 'Português',
|
||||
hreflang: 'pt-BR'
|
||||
},
|
||||
fr: {
|
||||
flag: 'fi fi-fr',
|
||||
name: 'Français',
|
||||
hreflang: 'fr'
|
||||
},
|
||||
it: {
|
||||
flag: 'fi fi-it',
|
||||
name: 'Italiano',
|
||||
hreflang: 'it'
|
||||
},
|
||||
zh: {
|
||||
flag: 'fi fi-cn',
|
||||
name: '中文',
|
||||
hreflang: 'zh-CN'
|
||||
},
|
||||
'zh-TW': {
|
||||
flag: 'fi fi-tw',
|
||||
name: '繁體中文',
|
||||
hreflang: 'zh-TW'
|
||||
},
|
||||
ko: {
|
||||
flag: 'fi fi-kr',
|
||||
name: '한국어',
|
||||
hreflang: 'ko'
|
||||
},
|
||||
ja: {
|
||||
flag: 'fi fi-jp',
|
||||
name: '日本語',
|
||||
hreflang: 'ja'
|
||||
},
|
||||
ru: {
|
||||
flag: 'fi fi-ru',
|
||||
name: 'Русский',
|
||||
hreflang: 'ru'
|
||||
},
|
||||
de: {
|
||||
flag: 'fi fi-de',
|
||||
name: 'Deutsch',
|
||||
hreflang: 'de'
|
||||
}
|
||||
} as const satisfies Record<string, LanguageDefinition>
|
||||
|
||||
export type LanguageCode = keyof typeof languages
|
||||
|
||||
export const defaultLanguageCode: LanguageCode = 'en'
|
||||
|
||||
export const languageList = Object.entries(languages).map(([code, definition]) => ({
|
||||
value: code as LanguageCode,
|
||||
...definition
|
||||
}))
|
||||
|
||||
export const supportedLanguageCodes = languageList.map((language) => language.value)
|
||||
|
||||
export function normalizeLanguageCode(code: string | null | undefined): LanguageCode {
|
||||
if (!code) {
|
||||
return defaultLanguageCode
|
||||
}
|
||||
|
||||
const normalizedInput = code.toLowerCase()
|
||||
const directMatch = supportedLanguageCodes.find(
|
||||
(languageCode) => languageCode.toLowerCase() === normalizedInput
|
||||
)
|
||||
if (directMatch) {
|
||||
return directMatch
|
||||
}
|
||||
|
||||
const base = normalizedInput.split('-')[0] ?? ''
|
||||
const baseMatch = supportedLanguageCodes.find(
|
||||
(languageCode) => languageCode.split('-')[0]?.toLowerCase() === base
|
||||
)
|
||||
|
||||
return baseMatch ?? defaultLanguageCode
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { LanguageCode } from '../languages'
|
||||
import { defaultLanguageCode } from '../languages'
|
||||
|
||||
// Download related types
|
||||
export interface VideoFormat {
|
||||
format_id: string
|
||||
@@ -54,7 +57,6 @@ export interface DownloadItem {
|
||||
status: DownloadStatus
|
||||
progress?: DownloadProgress
|
||||
error?: string
|
||||
outputPath?: string
|
||||
speed?: string
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
@@ -74,6 +76,11 @@ export interface DownloadItem {
|
||||
tags?: string[]
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
@@ -83,7 +90,7 @@ export interface DownloadHistoryItem {
|
||||
thumbnail?: string
|
||||
type: 'video' | 'audio' | 'extract'
|
||||
status: DownloadStatus
|
||||
outputPath?: string
|
||||
downloadPath?: string
|
||||
fileSize?: number
|
||||
duration?: number
|
||||
downloadedAt: number
|
||||
@@ -101,6 +108,11 @@ export interface DownloadHistoryItem {
|
||||
tags?: string[]
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
playlistId?: string
|
||||
playlistTitle?: string
|
||||
playlistIndex?: number
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface DownloadOptions {
|
||||
@@ -113,17 +125,19 @@ export interface DownloadOptions {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
outputPath?: string
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
id: string
|
||||
title: string
|
||||
entries: Array<{
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
}>
|
||||
entries: PlaylistEntry[]
|
||||
entryCount: number
|
||||
}
|
||||
|
||||
@@ -137,6 +151,25 @@ export interface PlaylistDownloadOptions {
|
||||
folderFormat?: string
|
||||
}
|
||||
|
||||
export interface PlaylistDownloadEntry {
|
||||
downloadId: string
|
||||
entryId: string
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface PlaylistDownloadResult {
|
||||
groupId: string
|
||||
playlistId: string
|
||||
playlistTitle: string
|
||||
type: 'video' | 'audio'
|
||||
totalCount: number
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
entries: PlaylistDownloadEntry[]
|
||||
}
|
||||
|
||||
// Settings types
|
||||
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
@@ -145,15 +178,17 @@ export interface AppSettings {
|
||||
showMoreFormats: boolean
|
||||
maxConcurrentDownloads: number
|
||||
browserForCookies: string
|
||||
cookiesPath: string
|
||||
proxy: string
|
||||
configPath: string
|
||||
betaProgram: boolean
|
||||
language: string
|
||||
language: LanguageCode
|
||||
theme: string
|
||||
oneClickDownload: boolean
|
||||
oneClickDownloadType: 'video' | 'audio'
|
||||
oneClickQuality: OneClickQualityPreset
|
||||
closeToTray: boolean
|
||||
hideDockIcon: boolean
|
||||
autoUpdate: boolean
|
||||
}
|
||||
|
||||
@@ -162,14 +197,16 @@ export const defaultSettings: AppSettings = {
|
||||
showMoreFormats: false,
|
||||
maxConcurrentDownloads: 5,
|
||||
browserForCookies: 'none',
|
||||
cookiesPath: '',
|
||||
proxy: '',
|
||||
configPath: '',
|
||||
betaProgram: false,
|
||||
language: 'en',
|
||||
language: defaultLanguageCode,
|
||||
theme: 'system',
|
||||
oneClickDownload: false,
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'auto',
|
||||
closeToTray: false,
|
||||
hideDockIcon: false,
|
||||
autoUpdate: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user