Compare commits

..

18 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
29 changed files with 1450 additions and 362 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,33 +5,5 @@ on:
branches: [ main ]
jobs:
test:
runs-on: windows-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: Install Dependencies
run: pnpm install
- name: Download yt-dlp binaries
run: |
# Download yt-dlp.exe for Windows
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe -o resources/yt-dlp.exe
- name: Lint and format check
run: pnpm run check
- name: build-win
run: pnpm run build:win
build:
uses: ./.github/workflows/build.yml

View File

@@ -6,59 +6,26 @@ 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:
generate_release_notes: true
@@ -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

@@ -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,6 +1,6 @@
{
"name": "vidbee",
"version": "0.3.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",

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') {

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,12 +1,13 @@
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'
@@ -22,18 +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
trafficLightPosition: { x: 12.5, y: 10 },
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,
@@ -41,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')
@@ -95,11 +107,6 @@ function setupDownloadEvents(): void {
}
function initAutoUpdater(): void {
if (process.env.NODE_ENV !== 'production') {
log.info('Skipping auto-updater initialization in development mode')
return
}
try {
log.info('Initializing auto-updater...')
@@ -111,6 +118,12 @@ function initAutoUpdater(): void {
autoUpdater.on('update-available', (info) => {
log.info('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) => {
@@ -141,12 +154,18 @@ function initAutoUpdater(): void {
}
})
if (settingsManager.get('autoUpdate')) {
log.info('Auto-update is enabled, checking for updates...')
void autoUpdater.checkForUpdatesAndNotify()
}
log.info('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)
}
@@ -168,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...')

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

@@ -12,7 +12,7 @@ import type {
VideoFormat,
VideoInfo
} from '../../shared/types'
import { buildDownloadArgs } from '../download-engine/args-builder'
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
import {
findFormatByIdCandidates,
parseSizeToBytes,
@@ -21,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'
@@ -52,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)
@@ -91,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) {
@@ -496,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
@@ -593,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)
}
}
}
@@ -621,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
})

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

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

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) {
@@ -320,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>
@@ -373,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

@@ -1,13 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { CardContent, CardHeader } from '@renderer/components/ui/card'
import { cn } from '@renderer/lib/utils'
import { useAtomValue, useSetAtom } from 'jotai'
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 type { DownloadRecord } from '../../store/downloads'
import { clearCompletedAtom, downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
import { DownloadItem } from './DownloadItem'
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
@@ -17,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()
@@ -99,33 +98,9 @@ export function UnifiedDownloadHistory() {
return { order, groups }
}, [filteredRecords])
const hasCompletedActive = allRecords.some(
(item) => item.entryType === 'active' && item.status === 'completed'
)
const handleClearCompleted = () => {
clearCompleted()
}
return (
<Card className="border border-border/60 bg-background max-w-full shadow-sm backdrop-blur-sm">
<CardHeader className="gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle>{t('download.downloadQueue')}</CardTitle>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
{hasCompletedActive && (
<Button
variant="ghost"
size="sm"
className="h-8 border border-border/60 px-3"
onClick={handleClearCompleted}
>
{t('download.clearCompleted')}
</Button>
)}
</div>
</div>
<div className="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
@@ -155,7 +130,7 @@ export function UnifiedDownloadHistory() {
})}
</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" />
@@ -191,6 +166,6 @@ export function UnifiedDownloadHistory() {
</div>
)}
</CardContent>
</Card>
</div>
)
}

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

@@ -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"
@@ -27,11 +29,16 @@
"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",
"updateError": "Failed to check for updates: {{error}}"
"updateDownloadedVersion": "Update {{version}} downloaded, restart to install",
"updateError": "Failed to check for updates: {{error}}",
"unknownErrorFallback": "Unknown error"
},
"preferencesDescription": "Tune update settings without leaving this page.",
"preferencesTitle": "Quick Toggles",
@@ -132,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",

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,
@@ -77,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 () => {
@@ -85,7 +127,12 @@ 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 ?? ''
@@ -247,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')}
@@ -266,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

@@ -19,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, ListVideo, Loader2, Search } from 'lucide-react'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -42,7 +42,6 @@ import {
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: null,
good: 1080,
normal: 720,
@@ -51,7 +50,6 @@ const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> =
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
auto: null,
best: 320,
good: 256,
normal: 192,
@@ -72,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]
@@ -110,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('/')
@@ -123,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)
@@ -537,11 +539,9 @@ export function Home({ onOpenSupportedSites }: HomeProps) {
<Tabs defaultValue="single" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
<Download className="h-4 w-4" />
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
<ListVideo className="h-4 w-4" />
{t('playlist.title')}
</TabsTrigger>
</TabsList>
@@ -621,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>
)}

View File

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

View File

@@ -17,6 +17,7 @@ export interface VideoFormat {
audio_ext?: string
tbr?: number
quality?: number
protocol?: string // http, https, m3u8, m3u8_native, etc.
}
export interface VideoInfo {
@@ -171,7 +172,7 @@ export interface PlaylistDownloadResult {
}
// Settings types
export type OneClickQualityPreset = 'auto' | 'best' | 'good' | 'normal' | 'bad' | 'worst'
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
export interface AppSettings {
downloadPath: string
@@ -205,7 +206,7 @@ export const defaultSettings: AppSettings = {
theme: 'system',
oneClickDownload: false,
oneClickDownloadType: 'video',
oneClickQuality: 'auto',
oneClickQuality: 'best',
closeToTray: false,
hideDockIcon: false,
autoUpdate: true