Compare commits

..

34 Commits

Author SHA1 Message Date
Nexmoe
e22fe7679a chore: release v0.3.5 2025-11-08 13:45:05 +08:00
Nexmoe
7c2e526c49 feat(ui): add one-click texts, file existence checks, clipboard copy (#15)
* feat(ui): add one-click texts, file existence checks, clipboard copy

* refactor(ui): remove unused clearCompleted and adjust card bg
2025-11-08 13:44:28 +08:00
Nexmoe
2f5776d1e0 feat(theme): update primary/accent colors to new OKLCH values (#17) 2025-11-08 13:41:05 +08:00
Nexmoe
7901fc7e82 feat(settings): remove 'auto' preset and default to 'best' to (#16) 2025-11-08 13:40:17 +08:00
Nexmoe
612800bef2 chore: release v0.3.4 2025-11-03 20:20:41 +08:00
Nexmoe
41b58a6f70 feat(format-selector): exclude HLS, sort and improve labels for formats (#13) 2025-11-03 20:13:18 +08:00
Nexmoe
1a8e26a847 feat(update): add download action and guide users to download page when (#12)
* feat(update): add download action and guide users to download page when

* fix(updater): always initialize auto-updater in non-prod builds for testing
2025-11-02 19:35:49 +08:00
Nexmoe
0bcddddc37 chore: release v0.3.3 2025-11-02 17:59:39 +08:00
Nexmoe
99163865e2 fix(release): update release script to ensure main branch is checked out and up-to-date before running checks 2025-11-02 17:59:30 +08:00
Nexmoe
40113a2b53 feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and (#11)
* feat(ffmpeg): add ffmpeg manager, bundle/download ffmpeg in CI and

* fix(download-engine): let yt-dlp merge format to avoid codec conflicts and use actual format for filenames

* chore(ci): replace monolithic job with platform-specific reusable jobs

* chore(ci): run build matrix for windows, macos, linux in one to

* feat(ci): remove platform matrix and platform gating to simplify build

* chore(ci): pin github-translate-action to specific commit to ensure
2025-11-02 17:42:33 +08:00
Nexmoe
75c8b19f31 feat: add GitHub Actions workflow for translation automation
Create a new workflow to automate translation tasks for issues, comments, discussions, and pull requests. This includes permissions for writing to issues and discussions, and utilizes the github-translate-action for title modifications.
2025-10-31 20:27:59 +08:00
Nexmoe
2a28b0db85 chore: release v0.3.2 2025-10-31 20:21:46 +08:00
Nexmoe
ca4b851ba4 Update mac build script to output separate mac packages (#9)
* build: produce separate mac binaries

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

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

* Run CI on Linux builds

* Update release workflow for Linux builds
2025-10-30 21:36:00 +08:00
Nexmoe
10a97b1f4a support of macOS x64 packing (#7) 2025-10-30 21:31:42 +08:00
Nexmoe
11eaaf0f88 chore: release v0.3.0 2025-10-29 20:34:05 +08:00
Nexmoe
512b9d1175 feat: playlist (#2)
* feat: implement playlist download feature with UI components, enhance download service for playlist handling, and update translations for playlist-related terms

* refactor: replace console logs with log.info for auto-updater events and enhance zh-TW translations with new keys and updates
2025-10-29 20:32:27 +08:00
Nexmoe
a1307bdd0b feat: add hide-dock setting and fix about text handling (#3)
- Add "hideDockIcon" setting label and description to English locale,
  enabling users to remove VidBee from the macOS Dock and rely on the
  menu bar/tray icon.
- Apply dock visibility changes when settings change, when settings
  are loaded, and after settings are reset by calling
  applyDockVisibility from the settings service.
- Update About page to use about.description for share text and display
  content, replacing the removed tagline key usage.
- Remove unused "tagline" entries from multiple locale files (en, fr,
  it, pt, ja, zh) to keep translations in sync with UI text changes.

These changes add OS integration for hiding the dock icon and ensure UI
copy is consistent and correctly internationalized.
2025-10-29 20:32:03 +08:00
Nexmoe
06132a0704 chore: release v0.2.2 2025-10-27 22:01:44 +08:00
Nexmoe
a7e4eb865b feat: add cookies file support in settings and download engine, enhance i18n with new cookie-related translations 2025-10-27 22:01:11 +08:00
Nexmoe
5246ab8369 feat: implement version comparison for updates, enhance TitleBar component with platform support, and improve language handling in settings 2025-10-27 20:23:29 +08:00
Nexmoe
baa887da2e chore: release v0.2.1 2025-10-26 12:52:50 +08:00
Nexmoe
be40f087ad feat: enhance i18n support by dynamically loading translations and updating popular sites with localized labels 2025-10-25 17:36:59 +08:00
Nexmoe
ad62bd07aa feat: enhance About page with latest version check and status display 2025-10-25 16:27:44 +08:00
Nexmoe
365b9c0660 feat: add flag-icons package and improve language handling in sidebar and i18n 2025-10-25 15:59:03 +08:00
Nexmoe
a1f5e0480c Merge branch 'main' of https://github.com/nexmoe/VidBee 2025-10-25 15:31:16 +08:00
Nexmoe
00a7950e6a feat: update en.json with new follow author section and improved notifications 2025-10-25 15:31:12 +08:00
Nexmoe
598ed9c964 docs: add macOS installation notes to README for resolving "file is damaged" error 2025-10-25 14:44:47 +08:00
Nexmoe
2b3edab4eb chore: update CI workflow to run on Windows and download yt-dlp binaries 2025-10-25 11:34:58 +08:00
Nexmoe
d0bdfce803 chore: remove draft option from release workflow 2025-10-25 11:31:09 +08:00
Nexmoe
17a4568492 chore: enable release notes generation in workflow 2025-10-25 11:30:51 +08:00
58 changed files with 5307 additions and 714 deletions

133
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Build
on:
workflow_call:
inputs:
upload_artifacts:
required: false
type: boolean
default: false
description: 'Whether to upload build artifacts'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: windows
os: windows-latest
build_script: pnpm run build:win
ytdlp_asset: yt-dlp.exe
ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
ffmpeg_output: ffmpeg.exe
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos
ytdlp_output: yt-dlp_macos
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
ffmpeg_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
ytdlp_asset: yt-dlp
ytdlp_output: yt-dlp_linux
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Download ffmpeg binary (Windows)
if: matrix.platform == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$ffmpegUrl = '${{ matrix.ffmpeg_url }}'
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
Copy-Item -Path $source -Destination $destination -Force
- name: Download ffmpeg binary (macOS)
if: matrix.platform == 'macos'
shell: bash
env:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
run: |
set -euo pipefail
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
if [[ ! -f "$arm_bin" ]]; then
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
exit 1
fi
if [[ ! -f "$x86_bin" ]]; then
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
exit 1
fi
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
chmod +x "resources/$FFMPEG_OUTPUT"
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
- name: Download ffmpeg binary (Linux)
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
chmod +x "resources/${{ matrix.ffmpeg_output }}"
- name: Download yt-dlp binary
shell: bash
run: |
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
chmod +x "resources/${{ matrix.ytdlp_output }}"
fi
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Build application
run: ${{ matrix.build_script }}
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: dist/
retention-days: 1

View File

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

View File

@@ -6,62 +6,29 @@ on:
- v*.*.*
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-latest, macos-latest]
needs: [build]
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
node-version: 20
pattern: dist-*
merge-multiple: true
path: dist/
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- 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
# 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
# Download yt-dlp for Linux (for cross-platform builds)
# curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o resources/yt-dlp_linux
# chmod +x resources/yt-dlp_linux
- name: Lint and format check
run: pnpm run check
# - name: build-linux
# 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-win
if: matrix.os == 'windows-latest'
run: pnpm run build:win
- name: release
- name: Release
uses: softprops/action-gh-release@v1
with:
draft: true
generate_release_notes: true
files: |
dist/*.exe
dist/*.zip
@@ -75,4 +42,3 @@ jobs:
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}

29
.github/workflows/translator.yaml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: 'translator'
on:
issues:
types: [opened, edited]
issue_comment:
types: [created, edited]
discussion:
types: [created, edited]
discussion_comment:
types: [created, edited]
pull_request_target:
types: [opened, edited]
pull_request_review_comment:
types: [created, edited]
jobs:
translate:
permissions:
issues: write
discussions: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
IS_MODIFY_TITLE: true

View File

@@ -58,6 +58,7 @@ src/
- 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.
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
## Working on Changes
- Keep each pull request focused on a single problem or feature.

View File

@@ -16,7 +16,6 @@
- **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
@@ -33,6 +32,16 @@
- **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.
```bash
xattr -rd com.apple.quarantine /Applications/VidBee.app/
```
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.
## 📸 Screenshots
![VidBee Main Interface](screenshots/main-interface.png)

View File

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

View File

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

BIN
icon.icns

Binary file not shown.

BIN
icon.ico

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "0.2.0",
"version": "0.3.5",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -12,13 +12,14 @@
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"start": "electron-vite preview",
"dev": "node scripts/set-console-encoding.js && electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "pnpm run check && bumpp"
"release": "git checkout main && git pull && pnpm run check && bumpp"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
@@ -47,6 +48,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
View File

@@ -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

View File

@@ -2,8 +2,11 @@
yt-dlp.exe
yt-dlp_macos
yt-dlp_linux
ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
# But keep the README
!README.md
!.gitignore

View File

@@ -48,8 +48,24 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
```
## ffmpeg Binaries
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
### Required Files
1. **Windows**: `ffmpeg.exe`
2. **macOS**: `ffmpeg_macos`
3. **Linux**: `ffmpeg_linux`
### How to Download
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
### Note
- If you don't place binaries here, the app will attempt to download them at runtime
- The app will automatically use the bundled version if available
- File sizes: ~10-15 MB per binary
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary

View File

@@ -3,38 +3,6 @@
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]
@@ -44,5 +12,61 @@ if (!platform) {
process.exit(1)
}
// Run the check
checkYtDlpExists(platform)
const supportedPlatforms = ['win', 'mac', 'linux']
if (!supportedPlatforms.includes(platform)) {
console.error('❌ Error: Invalid platform specified!')
console.error('Usage: node scripts/check-ytdlp.js [win|mac|linux]')
process.exit(1)
}
const binaries = [
{
label: 'yt-dlp',
filenameMap: {
win: 'yt-dlp.exe',
mac: 'yt-dlp_macos',
linux: 'yt-dlp_linux'
},
help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
}
},
{
label: 'ffmpeg',
filenameMap: {
win: 'ffmpeg.exe',
mac: 'ffmpeg_macos',
linux: 'ffmpeg_linux'
},
help: {
win: 'https://ffmpeg.org/download.html',
linux: 'https://ffmpeg.org/download.html',
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
}
}
]
let hasMissingBinary = false
for (const binary of binaries) {
const filename = binary.filenameMap[platform]
const binaryPath = path.join(__dirname, '..', 'resources', filename)
if (!fs.existsSync(binaryPath)) {
console.error(`❌ Error: resources/${filename} not found!`)
console.error(`Please download ${filename} to the resources/ directory first.`)
const help =
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
if (help) {
console.error(`See ${help}`)
}
hasMissingBinary = true
} else {
console.log(`${filename} found in resources/ directory`)
}
}
if (hasMissingBinary) {
process.exit(1)
}

343
scripts/setup-dev-binaries.js Executable file
View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* Development environment setup script
* Automatically downloads yt-dlp and ffmpeg binaries based on the current system
*/
const fs = require('node:fs')
const path = require('node:path')
const os = require('node:os')
const { execSync } = require('node:child_process')
const https = require('node:https')
const http = require('node:http')
// Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
// Platform configuration
const PLATFORM_CONFIG = {
win32: {
ytdlp: {
asset: 'yt-dlp.exe',
output: 'yt-dlp.exe'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
output: 'ffmpeg.exe',
extract: 'unzip'
}
},
darwin: {
ytdlp: {
asset: 'yt-dlp_macos',
output: 'yt-dlp_macos'
},
ffmpeg: {
// For development, download only the architecture matching current system
arm64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
extract: 'unzip'
}
}
},
linux: {
ytdlp: {
asset: 'yt-dlp',
output: 'yt-dlp_linux'
},
ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
extract: 'tar'
}
}
}
// Utility functions
function log(message, type = 'info') {
const icons = {
info: '📦',
success: '✅',
error: '❌',
warn: '⚠️',
download: '⬇️'
}
console.log(`${icons[type] || ''} ${message}`)
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
protocol
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
fs.unlinkSync(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
file.close()
fs.unlinkSync(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
}
response.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
})
.on('error', (err) => {
file.close()
fs.unlinkSync(dest)
reject(err)
})
})
}
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
if (platform === 'win32') {
// Use PowerShell Expand-Archive on Windows
try {
const zipAbsPath = path.resolve(zipPath)
const extractAbsDir = path.resolve(extractDir)
execSync(
`powershell -NoProfile -Command "Expand-Archive -Path '${zipAbsPath.replace(/'/g, "''")}' -DestinationPath '${extractAbsDir.replace(/'/g, "''")}' -Force"`,
{ stdio: 'inherit' }
)
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
} else {
// Use unzip command on macOS/Linux
try {
execSync(`unzip -q "${zipPath}" -d "${extractDir}"`, { stdio: 'inherit' })
} catch (error) {
throw new Error(`Failed to extract zip: ${error.message}`)
}
}
}
function extractTarXz(tarPath, extractDir) {
ensureDir(extractDir)
execSync(`tar -xf "${tarPath}" -C "${extractDir}"`, { stdio: 'inherit' })
}
function setExecutable(filePath) {
if (os.platform() !== 'win32') {
fs.chmodSync(filePath, 0o755)
}
}
function fileExists(filePath) {
return fs.existsSync(filePath)
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ${asset}...`, 'download')
const url = `${YTDLP_BASE_URL}/${asset}`
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFile(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
} catch (error) {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
throw error
}
}
async function downloadFfmpegWindows(config) {
const { url, innerPath, output } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Windows...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath.replace(/\\/g, path.sep))
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegMac(config) {
const arch = os.arch()
const ffmpegConfig = config.ffmpeg[arch === 'arm64' ? 'arm64' : 'x64']
if (!ffmpegConfig) {
throw new Error(`Unsupported architecture: ${arch}`)
}
const { url, innerPath, output } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
async function downloadFfmpegLinux(config) {
const { url, innerPath, output } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
log(`${output} already exists, skipping download`, 'info')
return
}
log(`Downloading ffmpeg for Linux...`, 'download')
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
try {
await downloadFile(url, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
const sourcePath = path.join(extractDir, innerPath)
if (!fileExists(sourcePath)) {
throw new Error(`ffmpeg binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
// Cleanup
fs.unlinkSync(tempTar)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempTar)) fs.unlinkSync(tempTar)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
const config = PLATFORM_CONFIG[platform]
if (!config) {
log(`Unsupported platform: ${platform}`, 'error')
process.exit(1)
}
log(`Setting up development binaries for ${platform}...`, 'info')
ensureDir(RESOURCES_DIR)
try {
// Download yt-dlp
await downloadYtDlp(config)
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)
} else if (platform === 'darwin') {
await downloadFfmpegMac(config)
} else if (platform === 'linux') {
await downloadFfmpegLinux(config)
}
log('Development environment setup completed!', 'success')
} catch (error) {
log(`Setup failed: ${error.message}`, 'error')
process.exit(1)
}
}
// Run setup
if (require.main === module) {
setup()
}
module.exports = { setup }

View File

@@ -14,7 +14,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
return 'best'
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
return 'bestvideo+bestaudio'
}
return `bestvideo+${audioFormat}`
}
@@ -53,7 +54,11 @@ export const buildDownloadArgs = (
// Format selection
if (options.type === 'video') {
args.push('-f', resolveVideoFormatSelector(options))
const formatSelector = resolveVideoFormatSelector(options)
args.push('-f', formatSelector)
// Let yt-dlp automatically choose the best merge format (mkv/webm/mp4)
// based on codec compatibility. Forcing MP4 can cause failures
// when codecs are incompatible (e.g., VP9+Opus requires mkv/webm)
} else if (options.type === 'audio') {
args.push('-f', resolveAudioFormatSelector(options))
} else if (options.type === 'extract') {
@@ -90,6 +95,11 @@ export const buildDownloadArgs = (
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)
}

View File

@@ -6,7 +6,6 @@ import type {
} from '../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -15,7 +14,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -187,7 +185,7 @@ export const resolveSelectedFormat = (
return directMatch
}
const preset = settings.oneClickQuality ?? 'auto'
const preset = settings.oneClickQuality ?? 'best'
if (options.type === 'video') {
const videoFormats = formats.filter(

View File

@@ -1,15 +1,17 @@
import { join } from 'node:path'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, type BrowserWindowConstructorOptions, shell } from 'electron'
import log from 'electron-log/main'
import { autoUpdater } from 'electron-updater'
import appIcon from '../../build/icon.png?asset'
import { configureLogger } from './config/logger-config'
import { services } from './ipc'
import { downloadEngine } from './lib/download-engine'
import { ffmpegManager } from './lib/ffmpeg-manager'
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()
@@ -21,17 +23,16 @@ let mainWindow: BrowserWindow | null = null
let isQuitting = false
export function createWindow(): void {
// Create the browser window
mainWindow = new BrowserWindow({
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200,
height: 800,
show: false,
titleBarStyle: 'hidden', // Hide title bar on macOS
autoHideMenuBar: true,
icon: appIcon, // Set application icon
frame: false,
vibrancy: 'fullscreen-ui', // on MacOS
backgroundMaterial: 'acrylic', // on Windows 11
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
@@ -39,7 +40,20 @@ export function createWindow(): void {
nodeIntegration: false,
webSecurity: false // Allow drag regions to work
}
})
}
if (isMac) {
windowOptions.titleBarStyle = 'hidden'
windowOptions.trafficLightPosition = { x: 12.5, y: 10 }
windowOptions.vibrancy = 'fullscreen-ui'
}
if (isWindows) {
windowOptions.backgroundMaterial = 'acrylic'
}
// Create the browser window
mainWindow = new BrowserWindow(windowOptions)
mainWindow.on('close', (event) => {
const closeToTray = settingsManager.get('closeToTray')
@@ -93,13 +107,8 @@ function setupDownloadEvents(): void {
}
function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') {
console.log('Skipping auto-updater initialization in development mode')
return
}
try {
console.log('Initializing auto-updater...')
log.info('Initializing auto-updater...')
log.transports.file.level = 'info'
autoUpdater.logger = log
@@ -108,31 +117,32 @@ function initAutoUpdater(): void {
autoUpdater.on('update-available', (info) => {
log.info('Update available:', info.version)
console.log('Update available:', info.version)
mainWindow?.webContents.send('update:available', info)
// If auto-update is enabled, the update will be downloaded automatically
// because autoDownload is set to true
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, update will be downloaded automatically')
}
})
autoUpdater.on('update-not-available', (info) => {
log.info('Update not available:', info.version)
console.log('Update not available:', info.version)
mainWindow?.webContents.send('update:not-available', info)
})
autoUpdater.on('error', (err) => {
log.error('Update error:', err)
console.error('Update error:', err)
mainWindow?.webContents.send('update:error', err.message)
})
autoUpdater.on('download-progress', (progressObj) => {
log.info('Download progress:', progressObj.percent)
console.log('Download progress:', progressObj.percent)
mainWindow?.webContents.send('update:download-progress', progressObj)
})
autoUpdater.on('update-downloaded', (info) => {
log.info('Update downloaded:', info.version)
console.log('Update downloaded:', info.version)
mainWindow?.webContents.send('update:downloaded', info)
if (mainWindow) {
@@ -144,17 +154,20 @@ function initAutoUpdater(): void {
}
})
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
console.log('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify()
}
log.info('Auto-updater initialized successfully')
console.log('Auto-updater initialized successfully')
// Check for updates immediately if auto-update is enabled
const autoUpdateEnabled = settingsManager.get('autoUpdate')
if (autoUpdateEnabled) {
log.info('Auto-update is enabled, checking for updates immediately...')
// Use checkForUpdates instead of checkForUpdatesAndNotify
// because we have our own notification system and want to ensure immediate download
void autoUpdater.checkForUpdates()
} else {
log.info('Auto-update is disabled, skipping automatic update check')
}
} catch (error) {
log.error('Failed to initialize auto-updater:', error)
console.error('Failed to initialize auto-updater:', error)
}
}
@@ -174,6 +187,15 @@ app.whenReady().then(async () => {
// IPC services are automatically registered by electron-ipc-decorator when imported
log.info('IPC services available:', Object.keys(services))
// Initialize ffmpeg
try {
log.info('Initializing ffmpeg...')
await ffmpegManager.initialize()
log.info('ffmpeg initialized successfully')
} catch (error) {
log.error('Failed to initialize ffmpeg:', error)
}
// Initialize yt-dlp
try {
log.info('Initializing yt-dlp...')
@@ -183,6 +205,8 @@ app.whenReady().then(async () => {
log.error('Failed to initialize yt-dlp:', error)
}
applyDockVisibility(settingsManager.get('hideDockIcon'))
createWindow()
initAutoUpdater()

View File

@@ -3,6 +3,7 @@ import type {
DownloadItem,
DownloadOptions,
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoInfo
} from '../../../shared/types'
@@ -45,7 +46,7 @@ class DownloadService extends IpcService {
async startPlaylistDownload(
_context: IpcContext,
options: PlaylistDownloadOptions
): Promise<string[]> {
): Promise<PlaylistDownloadResult> {
return downloadEngine.startPlaylistDownload(options)
}
}

View File

@@ -1,4 +1,4 @@
import { execFile } from 'node:child_process'
import { execFile, execSync } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -40,7 +40,20 @@ class FileSystemService extends IpcService {
@IpcMethod()
getDefaultDownloadPath(_context: IpcContext): string {
return `${os.homedir()}/Downloads`
const fallbackPath = path.join(os.homedir(), 'Downloads')
if (process.platform === 'linux' || process.platform === 'freebsd') {
try {
const xdgPath = execSync('xdg-user-dir DOWNLOAD', { encoding: 'utf8' }).trim()
if (xdgPath) {
return xdgPath
}
} catch (error) {
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
}
}
return fallbackPath
}
@IpcMethod()
@@ -216,6 +229,25 @@ class FileSystemService extends IpcService {
.replace(/'/g, '&apos;')
}
@IpcMethod()
async fileExists(_context: IpcContext, filePath: string): Promise<boolean> {
try {
if (!filePath) {
return false
}
const sanitizedPath = this.sanitizePath(filePath)
const normalizedPath = path.normalize(sanitizedPath)
const stats = await fs.stat(normalizedPath).catch(() => null)
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
return false
}
}
@IpcMethod()
async deleteFile(_context: IpcContext, filePath: string): Promise<boolean> {
try {

View File

@@ -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'))
}
}

View File

@@ -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 {

View File

@@ -7,11 +7,12 @@ import type {
DownloadOptions,
DownloadProgress,
PlaylistDownloadOptions,
PlaylistDownloadResult,
PlaylistInfo,
VideoFormat,
VideoInfo
} from '../../shared/types'
import { buildDownloadArgs } from '../download-engine/args-builder'
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
@@ -20,6 +21,7 @@ import {
import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger'
import { DownloadQueue } from './download-queue'
import { ffmpegManager } from './ffmpeg-manager'
import { historyManager } from './history-manager'
import { ytdlpManager } from './ytdlp-manager'
@@ -51,6 +53,11 @@ class DownloadEngine extends EventEmitter {
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
// Note: Some sites (e.g., YouTube) may not provide filesize information
// in the initial request. This is normal behavior and filesize may be null/undefined
// for many formats. File size information might require additional HTTP HEAD requests
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
// Add proxy if configured
if (settings.proxy) {
args.push('--proxy', settings.proxy)
@@ -61,6 +68,11 @@ class DownloadEngine extends EventEmitter {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
if (settings.configPath) {
args.push('--config-location', `"${settings.configPath}"`)
@@ -85,6 +97,27 @@ class DownloadEngine extends EventEmitter {
if (code === 0 && stdout) {
try {
const info = JSON.parse(stdout)
// Calculate estimated file size for formats missing filesize information
// Using tbr (total bitrate in kbps) and duration (in seconds)
// Formula: (tbr * 1000) / 8 * duration = size in bytes
if (info.formats && Array.isArray(info.formats) && info.duration) {
const duration = info.duration
for (const format of info.formats) {
if (
!format.filesize &&
!format.filesize_approx &&
format.tbr &&
typeof format.tbr === 'number' &&
duration > 0
) {
// Calculate estimated size: tbr (kbps) * 1000 / 8 bits per byte * duration (seconds)
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
format.filesize_approx = estimatedSize
}
}
}
scopedLoggers.download.info('Successfully retrieved video info for:', url)
resolve(info)
} catch (error) {
@@ -115,7 +148,7 @@ class DownloadEngine extends EventEmitter {
const ytdlp = ytdlpManager.getInstance()
const settings = settingsManager.getAll()
const args = ['-j', '--flat-playlist', '--no-warnings']
const args = ['-J', '--flat-playlist', '--no-warnings']
// Add encoding support for proper handling of non-ASCII characters
args.push('--encoding', 'utf-8')
@@ -130,8 +163,57 @@ class DownloadEngine extends EventEmitter {
args.push('--cookies-from-browser', settings.browserForCookies)
}
const cookiesPath = settings.cookiesPath?.trim()
if (cookiesPath) {
args.push('--cookies', cookiesPath)
}
// Add config file if configured
if (settings.configPath) {
args.push('--config-location', `"${settings.configPath}"`)
}
args.push(url)
type RawPlaylistEntry = {
id?: string
title?: string
url?: string
webpage_url?: string
original_url?: string
ie_key?: string
}
const resolveEntryUrl = (entry: RawPlaylistEntry): string => {
if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('http')) {
return entry.url
}
if (entry.webpage_url && typeof entry.webpage_url === 'string') {
return entry.webpage_url
}
if (entry.original_url && typeof entry.original_url === 'string') {
return entry.original_url
}
if (entry.url && typeof entry.url === 'string') {
if (entry.ie_key && typeof entry.ie_key === 'string') {
const extractor = entry.ie_key.toLowerCase()
if (extractor.includes('youtube')) {
return `https://www.youtube.com/watch?v=${entry.url}`
}
if (extractor.includes('youtubemusic')) {
return `https://music.youtube.com/watch?v=${entry.url}`
}
}
if (entry.url.startsWith('https://') || entry.url.startsWith('http://')) {
return entry.url
}
}
if (entry.id && typeof entry.id === 'string') {
return entry.id
}
return ''
}
return new Promise((resolve, reject) => {
const process = ytdlp.exec(args)
let stdout = ''
@@ -148,51 +230,107 @@ class DownloadEngine extends EventEmitter {
process.on('close', (code) => {
if (code === 0 && stdout) {
try {
const lines = stdout.trim().split('\n')
const entries = lines.map((line) => JSON.parse(line))
const playlistEntry = entries[0]
const parsed = JSON.parse(stdout) as {
id?: string
title?: string
entries?: RawPlaylistEntry[]
}
const rawEntries = Array.isArray(parsed.entries) ? parsed.entries : []
const entries = rawEntries
.map((entry, index) => {
const resolvedUrl = resolveEntryUrl(entry)
return {
id: entry.id || `${index}`,
title: entry.title || `Entry ${index + 1}`,
url: resolvedUrl,
index: index + 1
}
})
.filter((entry) => entry.url)
scopedLoggers.download.info(
'Successfully retrieved playlist info for:',
url,
'entries:',
entries.length
)
resolve({
id: playlistEntry.id || '',
title: playlistEntry.title || 'Playlist',
entries: entries.map((entry) => ({
id: entry.id || '',
title: entry.title || 'Unknown',
url: entry.url || entry.webpage_url || ''
})),
id: parsed.id || url,
title: parsed.title || 'Playlist',
entries,
entryCount: entries.length
})
} catch (error) {
scopedLoggers.download.error('Failed to parse playlist info for:', url, error)
reject(new Error(`Failed to parse playlist info: ${error}`))
}
} else {
scopedLoggers.download.error(
'Failed to fetch playlist info for:',
url,
'Exit code:',
code,
'Error:',
stderr
)
reject(new Error(stderr || 'Failed to fetch playlist info'))
}
})
process.on('error', (error) => {
scopedLoggers.download.error('yt-dlp process error while fetching playlist info:', error)
reject(error)
})
})
}
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<string[]> {
async startPlaylistDownload(options: PlaylistDownloadOptions): Promise<PlaylistDownloadResult> {
const playlistInfo = await this.getPlaylistInfo(options.url)
const downloadIds: string[] = []
const downloadEntries: PlaylistDownloadResult['entries'] = []
const groupId = `playlist_group_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`
// Calculate the range of entries to download
const startIndex = (options.startIndex || 1) - 1 // Convert to 0-based index
const endIndex = options.endIndex ? options.endIndex - 1 : playlistInfo.entries.length - 1
const entriesToDownload = playlistInfo.entries.slice(startIndex, endIndex + 1)
const totalEntries = playlistInfo.entries.length
if (totalEntries === 0) {
scopedLoggers.download.warn('Playlist has no entries:', options.url)
return {
groupId,
playlistId: playlistInfo.id,
playlistTitle: playlistInfo.title,
type: options.type,
totalCount: 0,
startIndex: 0,
endIndex: 0,
entries: []
}
}
const requestedStart = Math.max((options.startIndex ?? 1) - 1, 0)
const requestedEnd = options.endIndex
? Math.min(options.endIndex - 1, totalEntries - 1)
: totalEntries - 1
const rangeStart = Math.min(requestedStart, requestedEnd)
const rangeEnd = Math.max(requestedStart, requestedEnd)
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
const settings = settingsManager.getAll()
const selectedEntries = rawEntries.filter((entry) => {
if (!entry.url) {
scopedLoggers.download.warn('Skipping playlist entry with missing URL:', entry)
return false
}
return true
})
const selectionSize = selectedEntries.length
scopedLoggers.download.info(
`Starting playlist download: ${entriesToDownload.length} videos from "${playlistInfo.title}"`
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
)
// Create download items for each video in the playlist
for (const entry of entriesToDownload) {
const downloadId = `playlist_${Date.now()}_${Math.random().toString(36).substring(7)}`
downloadIds.push(downloadId)
for (const entry of selectedEntries) {
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
const downloadOptions: DownloadOptions = {
url: entry.url,
@@ -202,6 +340,13 @@ class DownloadEngine extends EventEmitter {
}
const createdAt = Date.now()
downloadEntries.push({
downloadId,
entryId: entry.id,
title: entry.title,
url: entry.url,
index: entry.index
})
// Add to queue
this.queue.add(downloadId, downloadOptions, {
@@ -211,17 +356,35 @@ class DownloadEngine extends EventEmitter {
type: options.type,
status: 'pending',
progress: { percent: 0 },
createdAt
createdAt,
playlistId: groupId,
playlistTitle: playlistInfo.title,
playlistIndex: entry.index,
playlistSize: selectionSize
})
this.upsertHistoryEntry(downloadId, downloadOptions, {
title: entry.title,
status: 'pending',
downloadedAt: createdAt
downloadedAt: createdAt,
downloadPath: settings.downloadPath,
playlistId: groupId,
playlistTitle: playlistInfo.title,
playlistIndex: entry.index,
playlistSize: selectionSize
})
}
return downloadIds
return {
groupId,
playlistId: playlistInfo.id,
playlistTitle: playlistInfo.title,
type: options.type,
totalCount: selectionSize,
startIndex: selectedEntries[0]?.index ?? rangeStart + 1,
endIndex: selectedEntries[selectedEntries.length - 1]?.index ?? rangeEnd + 1,
entries: downloadEntries
}
}
startDownload(id: string, options: DownloadOptions): void {
@@ -360,6 +523,46 @@ class DownloadEngine extends EventEmitter {
const args = buildDownloadArgs(options, downloadPath, settings)
// Check if format selector contains '+' which means video and audio will be merged
const formatSelector =
options.type === 'video' ? resolveVideoFormatSelector(options) : undefined
const willMerge = formatSelector?.includes('+') ?? false
const urlArg = args.pop()
if (!urlArg) {
const missingUrlError = new Error('Download arguments missing URL.')
scopedLoggers.download.error('Missing URL argument for download ID:', id)
this.updateDownloadInfo(id, {
status: 'error',
completedAt: Date.now(),
error: missingUrlError.message
})
this.queue.downloadCompleted(id)
this.emit('download-error', id, missingUrlError)
this.addToHistory(id, options, 'error', missingUrlError.message)
return
}
let ffmpegPath: string
try {
ffmpegPath = ffmpegManager.getPath()
} catch (error) {
const ffmpegError = error instanceof Error ? error : new Error(String(error))
scopedLoggers.download.error('Failed to resolve ffmpeg for download ID:', id, ffmpegError)
this.updateDownloadInfo(id, {
status: 'error',
completedAt: Date.now(),
error: ffmpegError.message
})
this.queue.downloadCompleted(id)
this.emit('download-error', id, ffmpegError)
this.addToHistory(id, options, 'error', ffmpegError.message)
return
}
args.push('--ffmpeg-location', ffmpegPath)
args.push(urlArg)
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
signal: controller.signal
@@ -457,23 +660,81 @@ class DownloadEngine extends EventEmitter {
// Generate file path using downloadPath + title + ext
const title = videoInfo?.title || 'Unknown'
const sanitizedTitle = title.replace(/[<>:"/\\|?*]/g, '_').substring(0, 50)
const extension =
options.type === 'audio' ? options.extractFormat || 'mp3' : actualFormat || 'mp4'
// Determine file extension based on download type and format
// yt-dlp automatically chooses the best merge format (mkv/webm/mp4)
// based on codec compatibility, so we should use actualFormat when available
let extension: string
if (options.type === 'audio') {
extension = options.extractFormat || 'mp3'
} else if (willMerge) {
// For merged files, yt-dlp auto-selects format (mkv/webm/mp4)
// Use actualFormat if available, otherwise default to mkv (most compatible)
extension = actualFormat || 'mkv'
} else {
extension = actualFormat || 'mp4'
}
const fileName = `${sanitizedTitle}.${extension}`
const finalOutputPath = path.join(downloadPath, fileName)
scopedLoggers.download.info('Generated file path for ID:', id, 'Path:', finalOutputPath)
scopedLoggers.download.info(
'Generated file path for ID:',
id,
'Path:',
finalOutputPath,
'Will merge:',
willMerge
)
let fileSize: number | undefined
let actualFilePath = finalOutputPath
try {
const fs = await import('node:fs/promises')
// Try to find the actual file - yt-dlp may generate files with slightly different names
const stats = await fs.stat(finalOutputPath)
fileSize = stats.size
actualFilePath = finalOutputPath
} catch (error) {
if (latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else {
scopedLoggers.download.warn('Failed to get file size for ID:', id, error)
// If the expected file doesn't exist, try to find it by scanning the directory
try {
const fs = await import('node:fs/promises')
const files = await fs.readdir(downloadPath)
// Look for files matching the title pattern with the correct extension
const matchingFiles = files.filter((file) => {
const baseName = file.replace(/\.[^.]+$/, '')
const fileExt = file.split('.').pop()?.toLowerCase()
return (
(baseName === sanitizedTitle || baseName.startsWith(sanitizedTitle)) &&
fileExt === extension.toLowerCase()
)
})
if (matchingFiles.length > 0) {
// Use the most recently modified file if multiple matches
const fileStats = await Promise.all(
matchingFiles.map(async (file) => {
const filePath = path.join(downloadPath, file)
const stats = await fs.stat(filePath)
return { file, path: filePath, mtime: stats.mtime, size: stats.size }
})
)
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
actualFilePath = mostRecent.path
fileSize = mostRecent.size
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
} else if (latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
} else {
scopedLoggers.download.warn('Failed to find file for ID:', id, error)
}
} catch (scanError) {
if (latestKnownSizeBytes !== undefined) {
fileSize = latestKnownSizeBytes
} else {
scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError)
}
}
}
@@ -485,7 +746,7 @@ class DownloadEngine extends EventEmitter {
status: 'completed',
completedAt: Date.now(),
fileSize,
format: actualFormat || undefined,
format: willMerge ? 'mp4' : actualFormat || undefined,
quality: actualQuality || undefined,
codec: actualCodec || undefined
})
@@ -597,6 +858,18 @@ class DownloadEngine extends EventEmitter {
if (updates.tags !== undefined) {
historyUpdates.tags = updates.tags
}
if (updates.playlistId !== undefined) {
historyUpdates.playlistId = updates.playlistId
}
if (updates.playlistTitle !== undefined) {
historyUpdates.playlistTitle = updates.playlistTitle
}
if (updates.playlistIndex !== undefined) {
historyUpdates.playlistIndex = updates.playlistIndex
}
if (updates.playlistSize !== undefined) {
historyUpdates.playlistSize = updates.playlistSize
}
if (updates.status !== undefined) {
historyUpdates.status = updates.status
}
@@ -641,7 +914,11 @@ class DownloadEngine extends EventEmitter {
channel: completedDownload?.item.channel,
uploader: completedDownload?.item.uploader,
viewCount: completedDownload?.item.viewCount,
tags: completedDownload?.item.tags
tags: completedDownload?.item.tags,
playlistId: completedDownload?.item.playlistId,
playlistTitle: completedDownload?.item.playlistTitle,
playlistIndex: completedDownload?.item.playlistIndex,
playlistSize: completedDownload?.item.playlistSize
})
}
@@ -673,7 +950,11 @@ class DownloadEngine extends EventEmitter {
viewCount: updates.viewCount,
tags: updates.tags,
// Download-specific format info
selectedFormat: updates.selectedFormat
selectedFormat: updates.selectedFormat,
playlistId: updates.playlistId,
playlistTitle: updates.playlistTitle,
playlistIndex: updates.playlistIndex,
playlistSize: updates.playlistSize
}
const merged: DownloadHistoryItem = {

View File

@@ -0,0 +1,101 @@
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
class FfmpegManager {
private ffmpegPath: string | null = null
async initialize(): Promise<void> {
this.ffmpegPath = await this.findFfmpegBinary()
console.log('ffmpeg initialized at:', this.ffmpegPath)
}
getPath(): string {
if (!this.ffmpegPath) {
throw new Error('ffmpeg not initialized. Call initialize() first.')
}
return this.ffmpegPath
}
private getResourcesPath(): string {
if (process.env.NODE_ENV === 'development') {
return path.join(process.cwd(), 'resources')
}
return path.join(process.resourcesPath, 'app.asar.unpacked', 'resources')
}
private async findFfmpegBinary(): Promise<string> {
const platform = os.platform()
const resourceCandidates: string[] = []
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
return process.env.FFMPEG_PATH
}
if (platform === 'win32') {
resourceCandidates.push('ffmpeg.exe')
} else if (platform === 'darwin') {
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
} else {
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
}
const resourcesPath = this.getResourcesPath()
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission on ffmpeg binary:', error)
}
}
console.log('Using bundled ffmpeg:', fullPath)
return fullPath
}
}
if (platform === 'darwin') {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for (const candidate of commonPaths) {
if (fs.existsSync(candidate)) {
console.log('Using system ffmpeg:', candidate)
return candidate
}
}
}
if (platform === 'linux' || platform === 'freebsd') {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system ffmpeg:', systemPath)
return systemPath
}
} catch (_error) {
// Ignore error and continue
}
}
if (platform === 'win32') {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
console.log('Using system ffmpeg:', output)
return output
}
} catch (_error) {
// Ignore error and continue
}
}
throw new Error(
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
)
}
}
export const ffmpegManager = new FfmpegManager()

View File

@@ -1,3 +1,4 @@
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'
@@ -9,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: {
@@ -17,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]
}

16
src/main/utils/dock.ts Normal file
View File

@@ -0,0 +1,16 @@
import { app } from 'electron'
/**
* Apply Dock visibility preference on macOS.
*/
export function applyDockVisibility(hideDockIcon: boolean): void {
if (process.platform !== 'darwin' || !app.dock) {
return
}
if (hideDockIcon) {
app.dock.hide()
} else {
app.dock.show()
}
}

View File

@@ -2,19 +2,27 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
import { Sidebar } from '@renderer/components/ui/sidebar'
import { Toaster } from '@renderer/components/ui/sonner'
import { TitleBar } from '@renderer/components/ui/title-bar'
import { useAtom } from 'jotai'
import { ThemeProvider } from 'next-themes'
import { useEffect, useState } from 'react'
import { ipcServices } from './lib/ipc'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About'
import { Home } from './pages/Home'
import { Settings } from './pages/Settings'
import { SupportedSites } from './pages/SupportedSites'
import { settingsAtom } from './store/settings'
type Page = 'home' | 'settings' | 'about' | 'sites'
function AppContent() {
const [currentPage, setCurrentPage] = useState<Page>('home')
const [platform, setPlatform] = useState<string>('')
const [settings] = useAtom(settingsAtom)
const { t } = useTranslation()
const autoUpdateEnabled = settings.autoUpdate
const updateDownloadInProgressRef = useRef(false)
useEffect(() => {
// Get platform info to determine if we should show title bar
@@ -31,10 +39,126 @@ function AppContent() {
getPlatform()
}, [])
useEffect(() => {
if (!window?.api) {
return
}
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
const handleGoToDownloadPage = () => {
if (typeof window !== 'undefined') {
window.open('https://vidbee.org/download/', '_blank', 'noopener,noreferrer')
}
}
const handleUpdateAvailable = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
const versionLabel = info.version ?? ''
if (autoUpdateEnabled) {
// Update will be downloaded automatically because autoDownload is enabled in main process
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
action: {
label: t('about.actions.goToDownload'),
onClick: handleGoToDownloadPage
}
})
// No need to manually call downloadUpdate() because autoDownload is true
} else {
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }), {
action: {
label: t('about.actions.goToDownload'),
onClick: handleGoToDownloadPage
}
})
}
}
const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState()
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.success(downloadedMessage)
showRestartPrompt()
}
const handleUpdateError = (rawMessage: unknown) => {
const message = typeof rawMessage === 'string' ? rawMessage : ''
resetDownloadState()
const errorMessage = message || t('about.notifications.unknownErrorFallback')
toast.error(t('about.notifications.updateError', { error: errorMessage }))
}
const handleDownloadProgress = (rawProgress: unknown) => {
const progress = (rawProgress ?? {}) as { percent?: number }
if (typeof progress?.percent === 'number') {
console.info('Update download progress:', progress.percent.toFixed(2))
}
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
const versionLabel = payload.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
ipcEvents.on('update:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [autoUpdateEnabled, t])
const renderPage = () => {
switch (currentPage) {
case 'home':
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
return (
<Home
onOpenSupportedSites={() => setCurrentPage('sites')}
onOpenSettings={() => setCurrentPage('settings')}
/>
)
case 'settings':
return <Settings />
case 'about':
@@ -42,7 +166,12 @@ function AppContent() {
case 'sites':
return <SupportedSites />
default:
return <Home onOpenSupportedSites={() => setCurrentPage('sites')} />
return (
<Home
onOpenSupportedSites={() => setCurrentPage('sites')}
onOpenSettings={() => setCurrentPage('settings')}
/>
)
}
}
@@ -53,8 +182,8 @@ function AppContent() {
{/* Main Content */}
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
{/* Custom Title Bar - Hide on macOS */}
{platform !== 'darwin' && <TitleBar />}
{/* Custom Title Bar */}
<TitleBar platform={platform} />
<ScrollArea
className="flex-1 w-full overflow-y-auto overflow-x-hidden"

View File

@@ -60,32 +60,32 @@
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6692 0.1607 245.0110);
--primary: oklch(0.8223 0.1704 79.8747);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.2090 0 0);
--muted: oklch(0.3485 0 0);
--muted-foreground: oklch(0.5637 0.0078 247.9662);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6692 0.1607 245.0110);
--accent-foreground: oklch(0.8223 0.1704 79.8747);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2674 0.0047 248.0045);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6818 0.1584 243.3540);
--chart-1: oklch(0.6723 0.1606 244.9955);
--ring: oklch(0.8223 0.1704 79.8747);
--chart-1: oklch(0.8223 0.1704 79.8747);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6818 0.1584 243.3540);
--sidebar-primary: oklch(0.8223 0.1704 79.8747);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.6692 0.1607 245.0110);
--sidebar-accent-foreground: oklch(0.8223 0.1704 79.8747);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--sidebar-ring: oklch(0.8223 0.1704 79.8747);
--font-sans: Open Sans, sans-serif;
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;

View File

@@ -5,6 +5,7 @@ import { Progress } from '@renderer/components/ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { useAtomValue, useSetAtom } from 'jotai'
import { AlertCircle, CheckCircle2, Copy, FolderOpen, Loader2, Play, Trash2, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
@@ -67,6 +68,30 @@ export function DownloadItem({ download }: DownloadItemProps) {
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
// Check if file exists when download data changes
useEffect(() => {
const checkFileExists = async () => {
if (!download.title || !download.downloadPath || !download.format) {
setFileExists(false)
return
}
try {
const filePath = generateFilePath(download.downloadPath, download.title, download.format)
const exists = await ipcServices.fs.fileExists(filePath)
setFileExists(exists)
} catch (error) {
console.error('Failed to check file existence:', error)
setFileExists(false)
}
}
checkFileExists()
}, [download.title, download.downloadPath, download.format])
const handleCancel = async () => {
if (isHistory) return
try {
@@ -93,18 +118,31 @@ export function DownloadItem({ download }: DownloadItemProps) {
toast.error(t('notifications.openFolderFailed'))
}
}
// Check if copy to clipboard is available
const canCopyToClipboard = () => {
return !!(download.title && download.downloadPath && download.format && fileExists)
}
// need title, downloadPath, format
const handleCopyToClipboard = async () => {
if (!download.title || !download.downloadPath || !download.format) {
if (!canCopyToClipboard()) {
toast.error(t('notifications.copyFailed'))
return
}
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
const downloadPath = download.downloadPath
const format = download.format
const title = download.title
if (!downloadPath || !format || !title) {
toast.error(t('notifications.copyFailed'))
return
}
try {
// Generate file path using downloadPath + title + ext
const downloadPath = download.downloadPath
const format = download.format
const filePath = generateFilePath(downloadPath, download.title, format)
const filePath = generateFilePath(downloadPath, title, format)
const success = await ipcServices.fs.copyFileToClipboard(filePath)
if (!success) {
@@ -214,6 +252,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>
@@ -298,7 +358,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!download.title || !download.downloadPath || !download.format}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>
@@ -351,6 +411,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
size="icon"
className="h-8 w-8 shrink-0"
onClick={handleCopyToClipboard}
disabled={!canCopyToClipboard()}
>
<Copy className="h-4 w-4" />
</Button>

View File

@@ -0,0 +1,59 @@
import { useTranslation } from 'react-i18next'
import type { DownloadRecord } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
interface PlaylistDownloadGroupProps {
groupId: string
title: string
records: DownloadRecord[]
totalCount: number
}
export function PlaylistDownloadGroup({
groupId,
title,
records,
totalCount
}: PlaylistDownloadGroupProps) {
const { t } = useTranslation()
const completedCount = records.filter((record) => record.status === 'completed').length
const errorCount = records.filter((record) => record.status === 'error').length
const activeCount = records.filter((record) =>
['downloading', 'processing', 'pending'].includes(record.status)
).length
const displayTitle = title || t('playlist.untitled')
return (
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
<p className="text-xs text-muted-foreground">
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
{errorCount > 0 && (
<span className="text-destructive">
{t('playlist.groupErrors', { count: errorCount })}
</span>
)}
</div>
</div>
<div className="space-y-2">
{records.map((record) => (
<div
key={`${groupId}:${record.entryType}:${record.id}`}
className="border-l border-border/50 pl-3"
>
<DownloadItem download={record} />
</div>
))}
</div>
</div>
)
}

View File

@@ -1,12 +1,15 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { useAtomValue, useSetAtom } from 'jotai'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import { cn } from '@renderer/lib/utils'
import { useAtomValue } from 'jotai'
import { History as HistoryIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useHistorySync } from '../../hooks/use-history-sync'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import type { DownloadRecord } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
@@ -14,7 +17,6 @@ export function UnifiedDownloadHistory() {
const { t } = useTranslation()
const allRecords = useAtomValue(downloadsArrayAtom)
const downloadStats = useAtomValue(downloadStatsAtom)
const clearCompleted = useSetAtom(clearCompletedAtom)
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
useHistorySync()
@@ -46,33 +48,59 @@ export function UnifiedDownloadHistory() {
{ key: 'error', label: t('download.error'), count: downloadStats.error }
]
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
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])
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="space-y-4">
<CardHeader className="gap-4 p-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
{filters.map((filter) => {
const isActive = statusFilter === filter.key
@@ -89,26 +117,55 @@ 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>
)
})}
</div>
</CardHeader>
<CardContent className="space-y-3 overflow-hidden w-full">
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
{filteredRecords.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
<HistoryIcon className="h-10 w-10 opacity-50" />
<p className="text-sm font-medium">{t('download.noItems')}</p>
</div>
) : (
<div className="space-y-2 sm:space-y-4 overflow-hidden w-full">
{filteredRecords.map((record) => (
<DownloadItem key={`${record.entryType}:${record.id}`} download={record} />
))}
<div 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>
</Card>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { ScrollArea } from '@renderer/components/ui/scroll-area'
import type { PlaylistEntry, PlaylistInfo } from '@shared/types'
import { useTranslation } from 'react-i18next'
interface PlaylistPreviewCardProps {
playlist: PlaylistInfo
entries: PlaylistEntry[]
onClear?: () => void
}
export function PlaylistPreviewCard({ playlist, entries, onClear }: PlaylistPreviewCardProps) {
const { t } = useTranslation()
const totalCount = playlist.entryCount
const selectedCount = entries.length
const firstIndex = entries[0]?.index ?? null
const lastIndex = entries[entries.length - 1]?.index ?? firstIndex ?? null
return (
<Card className="border border-border/60 bg-background/80 shadow-sm overflow-hidden">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1 space-y-1">
<CardTitle
className="truncate text-base font-semibold sm:text-lg wrap-break-word"
title={playlist.title}
>
{playlist.title || t('playlist.untitled')}
</CardTitle>
<CardDescription className="text-xs text-muted-foreground sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center gap-3 text-xs text-muted-foreground sm:text-sm">
<span className="truncate">{t('playlist.totalVideos', { count: totalCount })}</span>
{firstIndex !== null && lastIndex !== null ? (
<span className="truncate">
{t('playlist.selectedRange', {
start: firstIndex,
end: lastIndex
})}
</span>
) : (
<span className="truncate">{t('playlist.noRangeSelected')}</span>
)}
<span className="truncate">
{t('playlist.showingCount', { count: selectedCount })}
</span>
</div>
</CardDescription>
</div>
{onClear && (
<Button variant="ghost" size="sm" onClick={onClear} className="shrink-0">
{t('playlist.clearPreview')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/20">
<ScrollArea className="max-h-64 w-full pr-1 overflow-y-auto overflow-x-hidden">
<ol className="w-full min-w-0 divide-y divide-border/60 text-sm leading-snug">
{entries.length === 0 ? (
<li className="px-4 py-6 text-center text-xs text-muted-foreground">
{t('playlist.noEntriesInRange')}
</li>
) : (
entries.map((entry) => (
<li
key={`${entry.index}-${entry.id}`}
className="flex items-start gap-3 px-4 py-2 min-w-0 w-full max-w-full overflow-hidden"
>
<span className="w-12 shrink-0 text-xs font-semibold text-muted-foreground text-center">
#{entry.index}
</span>
<span
className="min-w-0 flex-1 truncate text-sm overflow-hidden wrap-break-word"
title={entry.title}
>
{entry.title}
</span>
</li>
))
)}
</ol>
</ScrollArea>
</div>
</CardContent>
</Card>
)
}

View File

@@ -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>
)
})}

View File

@@ -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

View File

@@ -41,44 +41,46 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
]
return (
<Card>
<CardHeader>
<CardTitle>{t('audioExtract.title')}</CardTitle>
<Card className="border-2 border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-lg">{t('audioExtract.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
<Select value={extractFormat} onValueChange={setExtractFormat}>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{audioFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2.5">
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('audioExtract.selectQuality')}</Label>
<Select value={extractQuality} onValueChange={setExtractQuality}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{qualities.map((quality) => (
<SelectItem key={quality.value} value={quality.value}>
{quality.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={() => onExtract('extract')} className="w-full">
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
{t('audioExtract.extract')}
</Button>
</CardContent>

View File

@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -73,9 +72,22 @@ export function FormatSelector({
useEffect(() => {
// Filter and sort formats
const videos = formats.filter((f) => f.video_ext !== 'none' && f.vcodec && f.vcodec !== 'none')
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
const videos = formats.filter(
(f) =>
f.video_ext !== 'none' &&
f.vcodec &&
f.vcodec !== 'none' &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
const audios = formats.filter(
(f) => f.acodec && f.acodec !== 'none' && (f.video_ext === 'none' || !f.video_ext)
(f) =>
f.acodec &&
f.acodec !== 'none' &&
(f.video_ext === 'none' || !f.video_ext) &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
)
// Apply showMoreFormats filter
@@ -87,6 +99,48 @@ export function FormatSelector({
? audios
: audios.filter((f) => f.ext !== 'webm')
// Sort formats by quality (best first)
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by height (higher is better)
const aHeight = a.height ?? 0
const bHeight = b.height ?? 0
if (aHeight !== bHeight) {
return bHeight - aHeight
}
// If same height, sort by fps (higher is better)
const aFps = a.fps ?? 0
const bFps = b.fps ?? 0
if (aFps !== bFps) {
return bFps - aFps
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by bitrate/quality if available
const aQuality = a.tbr ?? a.quality ?? 0
const bQuality = b.tbr ?? b.quality ?? 0
if (aQuality !== bQuality) {
return bQuality - aQuality
}
// If same quality, prefer formats with file size information
const aHasSize = !!(a.filesize || a.filesize_approx)
const bHasSize = !!(b.filesize || b.filesize_approx)
if (aHasSize !== bHasSize) {
return bHasSize ? 1 : -1
}
return 0
}
filteredVideos.sort(sortVideoFormatsByQuality)
filteredAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
@@ -121,25 +175,50 @@ export function FormatSelector({
}
const formatVideoLabel = (format: VideoFormat) => {
const quality = `${format.height || '???'}p${format.fps === 60 ? '60' : ''}`
const codec = settings.showMoreFormats ? ` | ${format.vcodec?.split('.')[0]}` : ''
const parts: string[] = []
// Resolution
if (format.height) {
parts.push(`${format.height}p${format.fps === 60 ? '60' : ''}`)
}
// Format extension
parts.push(format.ext.toUpperCase())
// Codec (if showMoreFormats is enabled)
if (settings.showMoreFormats && format.vcodec) {
parts.push(format.vcodec.split('.')[0])
}
// Audio indicator
if (format.acodec !== 'none') {
parts.push('🔊')
}
// File size
const size = formatSize(format.filesize || format.filesize_approx)
const hasAudio = format.acodec !== 'none' ? ' 🔊' : ''
return `${quality} | ${format.ext} ${codec} | ${size}${hasAudio}`
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
const formatAudioLabel = (format: VideoFormat) => {
const parts: string[] = []
// Quality
const quality = format.format_note || t('download.unknownQuality')
parts.push(quality)
// Format extension
const ext = format.ext === 'webm' ? 'opus' : format.ext
parts.push(ext.toUpperCase())
// File size
const size = formatSize(format.filesize || format.filesize_approx)
return `${quality} | ${ext} | ${size}`
if (size !== t('download.unknownSize')) {
parts.push(size)
}
return parts.join(' • ')
}
if (type === 'video') {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('download.selectVideoFormat')}</Label>
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
@@ -147,25 +226,25 @@ export function FormatSelector({
onVideoFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatVideoLabel(format)}
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('download.selectAudioFormat')}</Label>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
@@ -173,18 +252,20 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('download.noAudio')}</SelectItem>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatAudioLabel(format)}
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
@@ -196,8 +277,8 @@ export function FormatSelector({
// Audio only
return (
<div className="space-y-2">
<Label>{t('download.selectFormat')}</Label>
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
@@ -205,17 +286,17 @@ export function FormatSelector({
onAudioFormatChange?.(value)
}}
>
<SelectTrigger>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-h-[300px] p-1.5">
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="font-mono text-xs"
className="cursor-pointer py-2.5"
>
{formatAudioLabel(format)}
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -115,53 +115,59 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
}
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2">
<div className="space-y-5">
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
<ArrowLeft className="h-4 w-4" />
{t('download.back')}
</Button>
<Card>
<CardHeader>
<Card className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex flex-col md:flex-row gap-6">
{/* Thumbnail */}
<div className="shrink-0">
<ImageWithPlaceholder
src={cachedThumbnail}
alt={title}
className="w-full md:w-80 rounded-lg aspect-video"
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
fallbackIcon={<Play className="h-12 w-12" />}
/>
</div>
{/* Video Metadata */}
<div className="flex-1 space-y-3">
<div>
<CardTitle className="text-xl mb-2">{t('download.videoInfo')}</CardTitle>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-3">
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
<CardDescription className="flex flex-wrap gap-2 items-center">
{videoInfo.duration && (
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
{formatDuration(videoInfo.duration)}
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Clock className="h-3.5 w-3.5" />
<span>{formatDuration(videoInfo.duration)}</span>
</Badge>
)}
{videoInfo.view_count && (
<Badge variant="secondary" className="gap-1">
<Eye className="h-3 w-3" />
{formatViews(videoInfo.view_count)}
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
<Eye className="h-3.5 w-3.5" />
<span>{formatViews(videoInfo.view_count)}</span>
</Badge>
)}
{videoInfo.uploader && (
<Badge variant="outline" className="px-2.5 py-1">
{videoInfo.uploader}
</Badge>
)}
{videoInfo.uploader && <Badge variant="outline">{videoInfo.uploader}</Badge>}
</CardDescription>
</div>
<div className="space-y-2">
<Label htmlFor={titleId}>{t('download.title')}</Label>
<div className="space-y-2.5">
<Label htmlFor={titleId} className="text-sm font-semibold">
{t('download.title')}
</Label>
<Input
id={titleId}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="font-medium"
className="font-medium h-10"
/>
</div>
</div>
@@ -170,14 +176,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
<Separator />
<CardContent className="pt-6 space-y-6">
<CardContent className="pt-6 pb-6">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="video">{t('download.video')}</TabsTrigger>
<TabsTrigger value="audio">{t('download.audio')}</TabsTrigger>
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="video" className="text-sm font-medium">
{t('download.video')}
</TabsTrigger>
<TabsTrigger value="audio" className="text-sm font-medium">
{t('download.audio')}
</TabsTrigger>
</TabsList>
<TabsContent value="video" className="space-y-4 mt-4">
<TabsContent value="video" className="space-y-5 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="video"
@@ -194,13 +204,18 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('video')} className="w-full" size="lg">
<Button
onClick={() => handleDownload('video')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadVideo')}
</Button>
</TabsContent>
<TabsContent value="audio" className="space-y-4 mt-4">
<TabsContent value="audio" className="space-y-5 mt-0">
<FormatSelector
formats={videoInfo.formats || []}
type="audio"
@@ -218,7 +233,12 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
onDownloadSubsChange={setDownloadSubs}
/>
<Button onClick={() => handleDownload('audio')} className="w-full" size="lg">
<Button
onClick={() => handleDownload('audio')}
className="w-full"
size="lg"
variant="default"
>
<DownloadIcon className="mr-2 h-5 w-5" />
{t('download.downloadAudio')}
</Button>

View File

@@ -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' }
]

View File

@@ -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
}

View File

@@ -2,8 +2,10 @@
"about": {
"actions": {
"checkUpdates": "Check updates",
"download": "Download",
"email": "Email",
"feedback": "Feedback",
"goToDownload": "Go to download page",
"openRepo": "Open GitHub repository",
"view": "View",
"visit": "Visit"
@@ -14,18 +16,29 @@
"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}}?",
"manualDownloadAction": "Download now",
"noUpdatesAvailable": "You're using the latest version",
"restartToUpdate": "Restart now to install update?",
"restartNowAction": "Restart now",
"updateAvailable": "Update available: {{version}}",
"updateAvailableMessage": "A new version {{version}} is available. Please download it from the official website.",
"updateDownloaded": "Update downloaded, restart to install",
"restartToUpdate": "Restart now to install update?"
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
"updateError": "Failed to check for updates: {{error}}",
"unknownErrorFallback": "Unknown error"
},
"preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles",
@@ -45,25 +58,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.",
"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",
"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",
@@ -97,7 +109,7 @@
"worst": "Worst"
},
"download": {
"active": "active",
"active": "Active",
"all": "All",
"audio": "Audio",
"back": "Back",
@@ -127,8 +139,10 @@
"noAudio": "No Audio",
"noHistory": "No download history",
"noItems": "No items found",
"goToSettings": "Go to Settings",
"oneClickDownload": "One-Click Download",
"oneClickDownloadDescription": "Download directly with default settings without confirmation",
"oneClickDownloadEnabled": "One-Click Download is enabled. Downloads will start directly with default settings.",
"oneClickDownloadNow": "Download Now",
"oneClickDownloadStarted": "Download started with default settings",
"paste": "Paste",
@@ -166,8 +180,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",
@@ -180,11 +194,11 @@
},
"noHistory": "No download history yet",
"noHistoryDescription": "Your completed downloads will appear here",
"openDownloadFolder": "Open Download Folder",
"openFile": "Open File",
"copyToClipboard": "Copy to clipboard",
"openFileLocation": "Open File Location",
"openFolder": "Open Folder",
"openDownloadFolder": "Open Download Folder",
"openInBrowser": "Click to open in browser",
"removeItem": "Remove Item",
"stats": {
"cancelled": "Cancelled",
@@ -209,7 +223,6 @@
},
"notifications": {
"copyFailed": "Failed to copy to clipboard",
"videoCopied": "Video copied to clipboard",
"downloadCompleted": "Download completed",
"downloadFailed": "Download failed",
"downloadStarted": "Download started",
@@ -218,9 +231,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",
@@ -235,12 +251,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",
@@ -248,23 +279,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",
@@ -274,11 +326,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",
@@ -294,6 +350,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"
}

View 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"
}
}

View 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"
}
}

View 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": "サポートされているすべてのサイトを表示"
}
}

View 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": "지원되는 모든 사이트 보기"
}
}

View 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"
}
}

View 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": "檢視全部支援的網站"
}
}

View File

@@ -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": "快速切换",
@@ -43,19 +49,18 @@
"website": "官方网站",
"websiteDescription": "产品亮点、路线图与社区动态。"
},
"followAuthorActions": {
"follow": "关注 @nexmoex"
},
"followAuthorDescription": "获取 VidBee 的最新消息和更新。",
"followAuthorSupport": "在 X (Twitter) 上关注开发者,获取 VidBee 的最新更新和消息。",
"followAuthorTitle": "关注开发者",
"resourcesDescription": "了解 VidBee 并保持关注的实用链接。",
"resourcesTitle": "资源",
"sourceCode": "源代码已开放",
"tagline": "面向每位创作者的 AI 友好下载助手",
"title": "关于",
"version": "版本",
"versionLabel": "v{{version}}"
"versionLabel": "v{{version}}",
"latestVersionBadge": "最新版本v{{version}}",
"latestVersionStatus": {
"available": "有新版本可用",
"uptodate": "您已是最新版本",
"error": "无法获取最新版本"
}
},
"advancedOptions": {
"closeWhenDone": "下载完成后关闭应用",
@@ -159,7 +164,6 @@
"clearCompleted": "清除已完成",
"clearErrors": "清除错误",
"copyUrl": "复制链接",
"openInBrowser": "点击在浏览器中打开",
"date": "日期",
"description": "查看并管理下载历史",
"duration": "时长",
@@ -172,10 +176,11 @@
},
"noHistory": "暂无下载历史",
"noHistoryDescription": "完成的下载会显示在这里",
"openDownloadFolder": "打开下载文件夹",
"openFile": "打开文件",
"openFileLocation": "打开文件位置",
"openFolder": "打开文件夹",
"openDownloadFolder": "打开下载文件夹",
"openInBrowser": "点击在浏览器中打开",
"removeItem": "移除项目",
"stats": {
"cancelled": "已取消",
@@ -238,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": "退出",
@@ -281,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": "查看全部支持的网站"
}

View File

@@ -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'

View File

@@ -11,6 +11,7 @@ import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
import type { LucideIcon } from 'lucide-react'
import {
Download,
Facebook,
FileText,
Github,
@@ -36,12 +37,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
@@ -70,6 +78,47 @@ export function About() {
) => {
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
// If auto-update is enabled, check for updates immediately
if (key === 'autoUpdate' && value === true) {
try {
toast.info(t('about.notifications.checkingUpdates'))
const result = await ipcServices.update.checkForUpdates()
if (result.available) {
// The update will be downloaded automatically because autoDownload is enabled
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
action: {
label: t('about.actions.goToDownload'),
onClick: handleGoToDownload
}
})
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' }))
}
}
}
const handleGoToDownload = () => {
openShareUrl('https://vidbee.org/download/')
}
const handleCheckForUpdates = async () => {
@@ -78,21 +127,41 @@ export function About() {
const result = await ipcServices.update.checkForUpdates()
if (result.available) {
toast.success(t('about.notifications.updateAvailable', { version: result.version }))
toast.success(t('about.notifications.updateAvailable', { version: result.version }), {
action: {
label: t('about.actions.goToDownload'),
onClick: handleGoToDownload
}
})
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 +195,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 +254,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 +262,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">
@@ -194,6 +294,12 @@ export function About() {
<Github className="h-4 w-4" />
</a>
</Button>
{latestVersionState?.status === 'available' ? (
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
<Download className="h-4 w-4" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
@@ -213,49 +319,49 @@ 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>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button variant="secondary" size="sm" onClick={handleCopyShareLink} className="gap-2">
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground md:max-w-md">{t('about.shareSupport')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleShareTwitter} className="gap-2">
<Twitter className="h-4 w-4" />
{t('about.shareActions.twitter')}
</Button>
<Button variant="outline" size="sm" onClick={handleShareFacebook} className="gap-2">
<Facebook className="h-4 w-4" />
{t('about.shareActions.facebook')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleCopyShareLink}
className="gap-2"
>
<LinkIcon className="h-4 w-4" />
{t('about.shareActions.copy')}
</Button>
</div>
</div>
<div 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>
</div>
</CardContent>
</Card>

View File

@@ -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 { 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 {
@@ -41,7 +42,6 @@ import {
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -50,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -71,22 +70,24 @@ const dedupe = (candidates: Array<string | undefined>): string[] => {
}
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
settings.oneClickQuality ?? 'auto'
settings.oneClickQuality ?? 'best'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio', 'worst']
return ['worstaudio']
}
const abrLimit = qualityPresetToAudioAbr[preset]
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio', 'best'])
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
return 'worstvideo+worstaudio/worst'
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
}
const maxHeight = qualityPresetToVideoHeight[preset]
@@ -109,7 +110,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
combinations.push(video)
}
} else {
combinations.push('best')
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
combinations.push('bestvideo+bestaudio')
}
return dedupe(combinations).join('/')
@@ -122,9 +124,10 @@ const buildAudioFormatPreference = (settings: AppSettings): string => {
interface HomeProps {
onOpenSupportedSites?: () => void
onOpenSettings?: () => void
}
export function Home({ onOpenSupportedSites }: HomeProps) {
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
@@ -141,17 +144,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 +400,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,16 +537,14 @@ 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')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList> */}
</TabsList>
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6">
@@ -528,18 +621,20 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/10 p-4">
<div className="flex items-start gap-3">
<Download className="h-5 w-5 text-blue-600 mt-0.5 dark:text-blue-400" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t('download.oneClickDownload')}
</p>
<p className="text-sm text-blue-700">
{t('download.oneClickDownloadDescription')}
</p>
</div>
<div className="flex items-center justify-between gap-2 rounded-lg bg-card px-4 py-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('download.oneClickDownloadEnabled')}</span>
</div>
{onOpenSettings && (
<Button
type="button"
variant="link"
className="h-auto px-2 py-0 text-xs"
onClick={onOpenSettings}
>
{t('download.goToSettings')}
</Button>
)}
</div>
)}
@@ -580,14 +675,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 +699,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 +720,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 +728,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>

View File

@@ -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
@@ -200,9 +235,6 @@ export function Settings() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">
{t('settings.oneClickQualityOptions.auto')}
</SelectItem>
<SelectItem value="best">
{t('settings.oneClickQualityOptions.best')}
</SelectItem>
@@ -230,9 +262,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 +275,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 +323,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 +343,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 +353,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>

View File

@@ -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>

View File

@@ -39,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
})

View File

@@ -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
View 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
}

View File

@@ -1,3 +1,6 @@
import type { LanguageCode } from '../languages'
import { defaultLanguageCode } from '../languages'
// Download related types
export interface VideoFormat {
format_id: string
@@ -14,6 +17,7 @@ export interface VideoFormat {
audio_ext?: string
tbr?: number
quality?: number
protocol?: string // http, https, m3u8, m3u8_native, etc.
}
export interface VideoInfo {
@@ -73,6 +77,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 {
@@ -100,6 +109,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 {
@@ -114,14 +128,17 @@ export interface DownloadOptions {
downloadSubs?: boolean
}
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
}
@@ -135,23 +152,44 @@ 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'
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
export interface AppSettings {
downloadPath: string
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
}
@@ -160,14 +198,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',
oneClickQuality: 'best',
closeToTray: false,
hideDockIcon: false,
autoUpdate: true
}