Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8acd761189 | ||
|
|
20c6ae6543 | ||
|
|
66f2211d68 | ||
|
|
ac183b4e3c | ||
|
|
877f088136 | ||
|
|
5ee08a3e62 | ||
|
|
137a07cf4e | ||
|
|
21a5adbdf2 | ||
|
|
81f52f86e5 | ||
|
|
c8147cafe3 | ||
|
|
7074f698e5 | ||
|
|
b254bb6dba | ||
|
|
1caca0ea9a | ||
|
|
12ec142a77 | ||
|
|
0136da410d | ||
|
|
4971a5fe8a | ||
|
|
d2806164a2 | ||
|
|
e332ec4ddc | ||
|
|
3b74712f57 | ||
|
|
475e4127c2 | ||
|
|
3041307aa2 | ||
|
|
e2ab8dce60 | ||
|
|
c1806e5321 | ||
|
|
92494966c6 | ||
|
|
f04680b8c2 | ||
|
|
a5b94411fe | ||
|
|
71ae4a4425 | ||
|
|
e8413aefae | ||
|
|
b229225b6e | ||
|
|
5497ed6245 | ||
|
|
155bf652f2 | ||
|
|
86bd77d995 | ||
|
|
b38b356c22 | ||
|
|
5020605590 | ||
|
|
b15c3d8ce5 | ||
|
|
a51655697d | ||
|
|
f5c26f5f02 | ||
|
|
3bc9d76f6e | ||
|
|
5751d73d5b | ||
|
|
50bd9f1659 |
37
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a problem or regression
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
body:
|
||||||
|
- type: input
|
||||||
|
id: app_version
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: The VidBee version (e.g., 1.2.3)
|
||||||
|
placeholder: 1.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: os_version
|
||||||
|
attributes:
|
||||||
|
label: OS version
|
||||||
|
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
|
||||||
|
placeholder: macOS 14.2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Observed behavior
|
||||||
|
placeholder: What actually happened
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs or screenshots
|
||||||
|
description: Paste relevant logs or add screenshots
|
||||||
|
placeholder: Attach files or paste logs here
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
38
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest an idea or improvement
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem to solve
|
||||||
|
description: What problem are you trying to solve?
|
||||||
|
placeholder: I want to...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: Describe the feature or change you want
|
||||||
|
placeholder: It would be great if...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Other solutions or workarounds you considered
|
||||||
|
placeholder: I tried...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: Add any other context or screenshots
|
||||||
|
placeholder: Links, screenshots, or related issues
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
104
.github/workflows/build.yml
vendored
@@ -8,6 +8,17 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
description: 'Whether to upload build artifacts'
|
description: 'Whether to upload build artifacts'
|
||||||
|
secrets:
|
||||||
|
MAC_CERT_P12_BASE64:
|
||||||
|
required: false
|
||||||
|
MAC_CERT_P12_PASSWORD:
|
||||||
|
required: false
|
||||||
|
APPLE_API_KEY_ID:
|
||||||
|
required: false
|
||||||
|
APPLE_API_ISSUER:
|
||||||
|
required: false
|
||||||
|
APPLE_API_KEY_P8_BASE64:
|
||||||
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -68,6 +79,8 @@ jobs:
|
|||||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||||
Copy-Item -Path $source -Destination $destination -Force
|
Copy-Item -Path $source -Destination $destination -Force
|
||||||
|
Remove-Item ffmpeg.zip -Force
|
||||||
|
Remove-Item ffmpeg -Recurse -Force
|
||||||
|
|
||||||
- name: Download ffmpeg binary (macOS)
|
- name: Download ffmpeg binary (macOS)
|
||||||
if: matrix.platform == 'macos'
|
if: matrix.platform == 'macos'
|
||||||
@@ -76,10 +89,10 @@ jobs:
|
|||||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -L "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||||
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
|
||||||
|
|
||||||
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
|
||||||
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
|
||||||
|
|
||||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||||
@@ -103,16 +116,21 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
|
||||||
|
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||||
|
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
mkdir ffmpeg
|
mkdir ffmpeg
|
||||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||||
|
rm -rf ffmpeg.tar.xz ffmpeg
|
||||||
|
|
||||||
- name: Download yt-dlp binary
|
- name: Download yt-dlp binary
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
|
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "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
|
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
|
||||||
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
chmod +x "resources/${{ matrix.ytdlp_output }}"
|
||||||
fi
|
fi
|
||||||
@@ -120,9 +138,86 @@ jobs:
|
|||||||
- name: Lint and format check
|
- name: Lint and format check
|
||||||
run: pnpm run check && pnpm run typecheck
|
run: pnpm run check && pnpm run typecheck
|
||||||
|
|
||||||
|
- name: Setup macOS signing
|
||||||
|
if: matrix.platform == 'macos'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
|
||||||
|
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
|
||||||
|
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
# Check if all required secrets are present
|
||||||
|
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
|
||||||
|
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
|
||||||
|
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
|
||||||
|
echo "::notice::macOS signing secrets not available, skipping code signing setup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
|
||||||
|
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
|
||||||
|
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
|
||||||
|
|
||||||
|
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
|
||||||
|
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
|
||||||
|
|
||||||
|
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||||
|
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
|
||||||
|
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||||
|
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
|
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||||
|
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
|
||||||
|
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
|
||||||
|
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
|
||||||
|
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
|
||||||
|
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: ${{ matrix.build_script }}
|
run: ${{ matrix.build_script }}
|
||||||
|
|
||||||
|
- name: Verify macOS codesign and notarization
|
||||||
|
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apps_found=0
|
||||||
|
while IFS= read -r app; do
|
||||||
|
apps_found=1
|
||||||
|
echo "Verifying codesign for $app"
|
||||||
|
codesign --verify --deep --strict --verbose=2 "$app"
|
||||||
|
spctl -a -t exec -vv "$app"
|
||||||
|
echo "Validating notarization ticket for $app"
|
||||||
|
xcrun stapler validate "$app"
|
||||||
|
done < <(find dist -type d -name "*.app" -prune -print)
|
||||||
|
|
||||||
|
if [[ "$apps_found" -eq 0 ]]; then
|
||||||
|
echo "::error::No .app bundles found in dist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dmgs_found=0
|
||||||
|
while IFS= read -r dmg; do
|
||||||
|
dmgs_found=1
|
||||||
|
echo "Submitting DMG for notarization: $dmg"
|
||||||
|
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
|
||||||
|
echo "Stapling notarization ticket for $dmg"
|
||||||
|
xcrun stapler staple "$dmg"
|
||||||
|
echo "Validating notarization ticket for $dmg"
|
||||||
|
xcrun stapler validate "$dmg"
|
||||||
|
done < <(find dist -type f -name "*.dmg" -print)
|
||||||
|
|
||||||
|
if [[ "$dmgs_found" -eq 0 ]]; then
|
||||||
|
echo "::notice::No DMG artifacts found to validate"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
if: inputs.upload_artifacts == true
|
if: inputs.upload_artifacts == true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -130,4 +225,3 @@ jobs:
|
|||||||
name: dist-${{ matrix.os }}
|
name: dist-${{ matrix.os }}
|
||||||
path: dist/
|
path: dist/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
|
|||||||
7
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
with:
|
with:
|
||||||
upload_artifacts: true
|
upload_artifacts: true
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [build]
|
needs: [build]
|
||||||
@@ -42,3 +43,9 @@ jobs:
|
|||||||
dist/*.blockmap
|
dist/*.blockmap
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Notify Cloudflare Pages
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
|
||||||
|
run: |
|
||||||
|
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
|
||||||
|
|||||||
2
.gitignore
vendored
@@ -2,6 +2,8 @@ node_modules
|
|||||||
/dist
|
/dist
|
||||||
out
|
out
|
||||||
.conductor/
|
.conductor/
|
||||||
|
.wxt
|
||||||
|
.output
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
3. Support i18n, only translate the English version of en.json
|
3. Support i18n, only translate the English version of en.json
|
||||||
4. use English for comments&console
|
4. use English for comments&console
|
||||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
||||||
|
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
|
||||||
|
|||||||
17
README.md
@@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
<h3>VidBee</h3>
|
<h3>VidBee</h3>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
|
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
|
||||||
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
|
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
|
||||||
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
|
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
|
||||||
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&style=flat-square&label=Latest%20Release" /></a>
|
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
|
||||||
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
|
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
|
||||||
|
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||||
<br />
|
<br />
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
|
||||||
@@ -27,11 +28,7 @@ VidBee is currently under active development, and feedback is welcome for any [i
|
|||||||
|
|
||||||
Feel free to try it using the following methods:
|
Feel free to try it using the following methods:
|
||||||
|
|
||||||
| Operating System | Source |
|
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
|
||||||
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
|
|
||||||
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
|
|
||||||
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
|
|
||||||
|
|
||||||
### 🍎 macOS Installation Notes
|
### 🍎 macOS Installation Notes
|
||||||
|
|
||||||
|
|||||||
58
build/after-pack.cjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
|
||||||
|
|
||||||
|
const findAppBundle = (appOutDir) => {
|
||||||
|
const entries = fs.readdirSync(appOutDir)
|
||||||
|
const app = entries.find((entry) => entry.endsWith('.app'))
|
||||||
|
return app ? path.join(appOutDir, app) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSigningIdentity = () =>
|
||||||
|
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
|
||||||
|
|
||||||
|
const signBinary = (targetPath, entitlementsPath) => {
|
||||||
|
const identity = resolveSigningIdentity()
|
||||||
|
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
|
||||||
|
|
||||||
|
if (identity !== '-') {
|
||||||
|
args.push('--options', 'runtime', '--timestamp')
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(targetPath)
|
||||||
|
execFileSync('codesign', args, { stdio: 'inherit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.default = async function afterPack(context) {
|
||||||
|
if (context.electronPlatformName !== 'darwin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appBundle = findAppBundle(context.appOutDir)
|
||||||
|
if (!appBundle) {
|
||||||
|
console.warn('afterPack: No .app bundle found, skipping tool signing.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourcesPath = path.join(
|
||||||
|
appBundle,
|
||||||
|
'Contents',
|
||||||
|
'Resources',
|
||||||
|
'app.asar.unpacked',
|
||||||
|
'resources'
|
||||||
|
)
|
||||||
|
|
||||||
|
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
|
||||||
|
|
||||||
|
for (const binary of BINARIES) {
|
||||||
|
const targetPath = path.join(resourcesPath, binary)
|
||||||
|
if (!fs.existsSync(targetPath)) {
|
||||||
|
console.warn(`afterPack: Missing ${binary}, skipping.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
console.log(`afterPack: Signing ${binary} with entitlements.`)
|
||||||
|
signBinary(targetPath, entitlementsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup": "cp $CONDUCTOR_ROOT_PATH/resources/ resources/ && pnpm install",
|
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
|
||||||
"run": "pnpm run dev"
|
"run": "pnpm run dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ appId: com.vidbee
|
|||||||
productName: VidBee
|
productName: VidBee
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
afterPack: build/after-pack.cjs
|
||||||
protocols:
|
protocols:
|
||||||
- name: VidBee
|
- name: VidBee
|
||||||
schemes:
|
schemes:
|
||||||
- vidbee
|
- vidbee
|
||||||
files:
|
files:
|
||||||
- '!**/.vscode/*'
|
- '!**/.vscode/*'
|
||||||
|
- '!**/.context/**'
|
||||||
|
- '!**/.github/**'
|
||||||
- '!src/*'
|
- '!src/*'
|
||||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||||
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
|
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
|
||||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||||
|
- '!extension/**'
|
||||||
|
- '!monkey/**'
|
||||||
|
- '!screenshots/**'
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- resources/**
|
- resources/**
|
||||||
win:
|
win:
|
||||||
@@ -23,9 +29,10 @@ nsis:
|
|||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
mac:
|
mac:
|
||||||
identity: null
|
hardenedRuntime: true
|
||||||
|
entitlements: build/entitlements.mac.plist
|
||||||
entitlementsInherit: build/entitlements.mac.plist
|
entitlementsInherit: build/entitlements.mac.plist
|
||||||
notarize: false
|
notarize: true
|
||||||
artifactName: ${name}-${version}-${arch}.${ext}
|
artifactName: ${name}-${version}-${arch}.${ext}
|
||||||
target:
|
target:
|
||||||
- target: zip
|
- target: zip
|
||||||
@@ -50,3 +57,5 @@ publish:
|
|||||||
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
||||||
electronDownload:
|
electronDownload:
|
||||||
mirror: https://npmmirror.com/mirrors/electron/
|
mirror: https://npmmirror.com/mirrors/electron/
|
||||||
|
electronLanguages:
|
||||||
|
- en
|
||||||
|
|||||||
26
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
stats.html
|
||||||
|
stats-*.json
|
||||||
|
.wxt
|
||||||
|
web-ext.config.ts
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
extension/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# WXT + React
|
||||||
|
|
||||||
|
This template should help get you started developing with React in WXT.
|
||||||
163
extension/assets/content.css
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
.vidbee-download-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-container.vidbee-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-button {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-button svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: currentColor;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 8px);
|
||||||
|
top: 50%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
transform: translateY(-50%) translateX(4px);
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-left-color: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-button:hover .vidbee-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(255, 77, 77, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-container:hover .vidbee-close-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button:hover {
|
||||||
|
background: rgba(255, 77, 77, 1);
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button:active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: currentColor;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button .vidbee-tooltip {
|
||||||
|
right: calc(100% + 6px);
|
||||||
|
top: 50%;
|
||||||
|
left: auto;
|
||||||
|
transform: translateY(-50%) translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-close-button .vidbee-tooltip::after {
|
||||||
|
left: 100%;
|
||||||
|
top: 50%;
|
||||||
|
right: auto;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-left-color: rgba(0, 0, 0, 0.9);
|
||||||
|
border-top-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-50%) translateX(0);
|
||||||
|
}
|
||||||
203
extension/entrypoints/background.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
type VideoFormat = {
|
||||||
|
format_id?: string
|
||||||
|
ext?: string
|
||||||
|
format_note?: string
|
||||||
|
resolution?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
fps?: number
|
||||||
|
vcodec?: string
|
||||||
|
acodec?: string
|
||||||
|
filesize?: number
|
||||||
|
filesize_approx?: number
|
||||||
|
tbr?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoInfo = {
|
||||||
|
title?: string
|
||||||
|
thumbnail?: string
|
||||||
|
duration?: number
|
||||||
|
formats?: VideoFormat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoInfoCacheEntry = {
|
||||||
|
url: string
|
||||||
|
status: 'pending' | 'ready' | 'error'
|
||||||
|
fetchedAt: number
|
||||||
|
info?: VideoInfo
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT_RANGE_START = 27100
|
||||||
|
const PORT_RANGE_END = 27120
|
||||||
|
const STATUS_TIMEOUT_MS = 800
|
||||||
|
const INFO_TIMEOUT_MS = 60000
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
const pendingRequests = new Map<string, Promise<void>>()
|
||||||
|
const defaultIconPaths = {
|
||||||
|
16: 'icon/16.png',
|
||||||
|
32: 'icon/32.png',
|
||||||
|
48: 'icon/48.png',
|
||||||
|
128: 'icon/128.png'
|
||||||
|
}
|
||||||
|
const loadingIconPaths = {
|
||||||
|
16: 'icon/icon-loading-16.png',
|
||||||
|
32: 'icon/icon-loading-32.png',
|
||||||
|
48: 'icon/icon-loading-48.png',
|
||||||
|
128: 'icon/icon-loading-128.png'
|
||||||
|
}
|
||||||
|
const successIconPaths = {
|
||||||
|
16: 'icon/icon-success-16.png',
|
||||||
|
32: 'icon/icon-success-32.png',
|
||||||
|
48: 'icon/icon-success-48.png',
|
||||||
|
128: 'icon/icon-success-128.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setActionIcon = (status: 'default' | 'loading' | 'success', tabId?: number): void => {
|
||||||
|
const paths =
|
||||||
|
status === 'loading'
|
||||||
|
? loadingIconPaths
|
||||||
|
: status === 'success'
|
||||||
|
? successIconPaths
|
||||||
|
: defaultIconPaths
|
||||||
|
const options = tabId ? { path: paths, tabId } : { path: paths }
|
||||||
|
void browser.action.setIcon(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchJson = async <T>(url: string, timeoutMs: number): Promise<T> => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal })
|
||||||
|
const data = (await response.json().catch(() => null)) as (T & { error?: string }) | null
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data && typeof data === 'object' && 'error' in data ? data.error : null
|
||||||
|
const details = data && typeof data === 'object' && 'details' in data ? data.details : null
|
||||||
|
const combined = [message, details].filter(Boolean).join('\n\n')
|
||||||
|
throw new Error(combined || `Request failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out.')
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.includes('signal is aborted')) {
|
||||||
|
throw new Error('Request timed out.')
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message.includes('Failed to fetch')) {
|
||||||
|
throw new Error('VidBee app not responding on this port.')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findAvailablePort = async (): Promise<number | null> => {
|
||||||
|
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
try {
|
||||||
|
await fetchJson<{ ok: boolean }>(`${baseUrl}/status`, STATUS_TIMEOUT_MS)
|
||||||
|
return port
|
||||||
|
} catch {
|
||||||
|
// Keep scanning.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestVideoInfo = async (targetUrl: string): Promise<VideoInfo> => {
|
||||||
|
const port = await findAvailablePort()
|
||||||
|
if (!port) {
|
||||||
|
throw new Error('VidBee app not found on localhost.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
const tokenResponse = await fetchJson<{ token?: string }>(`${baseUrl}/token`, STATUS_TIMEOUT_MS)
|
||||||
|
if (!tokenResponse.token) {
|
||||||
|
throw new Error('Failed to acquire token from VidBee.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchJson<VideoInfo>(
|
||||||
|
`${baseUrl}/video-info?url=${encodeURIComponent(targetUrl)}&token=${encodeURIComponent(
|
||||||
|
tokenResponse.token
|
||||||
|
)}`,
|
||||||
|
INFO_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCacheMap = async (): Promise<Record<string, VideoInfoCacheEntry>> => {
|
||||||
|
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||||
|
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||||
|
if (!map) return {}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneCache = (map: Record<string, VideoInfoCacheEntry>): void => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of Object.entries(map)) {
|
||||||
|
if (now - entry.fetchedAt > CACHE_TTL_MS) {
|
||||||
|
delete map[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCache = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||||
|
const map = await getCacheMap()
|
||||||
|
pruneCache(map)
|
||||||
|
const cache = map[url]
|
||||||
|
if (!cache) return null
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCacheEntry = async (cache: VideoInfoCacheEntry): Promise<void> => {
|
||||||
|
const map = await getCacheMap()
|
||||||
|
pruneCache(map)
|
||||||
|
map[cache.url] = cache
|
||||||
|
await browser.storage.local.set({ videoInfoCacheByUrl: map })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAndCache = async (url: string, tabId?: number): Promise<void> => {
|
||||||
|
if (pendingRequests.has(url)) {
|
||||||
|
return pendingRequests.get(url) as Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
const existing = await loadCache(url)
|
||||||
|
if (existing?.status === 'ready') {
|
||||||
|
setActionIcon('success', tabId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionIcon('loading', tabId)
|
||||||
|
await saveCacheEntry({ url, status: 'pending', fetchedAt: Date.now() })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await requestVideoInfo(url)
|
||||||
|
await saveCacheEntry({ url, status: 'ready', fetchedAt: Date.now(), info })
|
||||||
|
setActionIcon('success', tabId)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch video info.'
|
||||||
|
await saveCacheEntry({ url, status: 'error', fetchedAt: Date.now(), error: message })
|
||||||
|
setActionIcon('default', tabId)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
pendingRequests.set(url, task)
|
||||||
|
try {
|
||||||
|
await task
|
||||||
|
} finally {
|
||||||
|
pendingRequests.delete(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineBackground(() => {
|
||||||
|
browser.runtime.onMessage.addListener((message: { type?: string; url?: string }, sender) => {
|
||||||
|
if (message.type !== 'video-info:fetch' || !message.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void fetchAndCache(message.url, sender.tab?.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
291
extension/entrypoints/popup/App.css
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--fg: #111111;
|
||||||
|
--fg-secondary: #757575;
|
||||||
|
--border: #f0f0f0;
|
||||||
|
--accent: #000000;
|
||||||
|
--error: #e00000;
|
||||||
|
--success: #00c853;
|
||||||
|
--warning: #ffd600;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 360px;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--border);
|
||||||
|
}
|
||||||
|
.status-dot.loading { background-color: var(--warning); box-shadow: 0 0 4px var(--warning); }
|
||||||
|
.status-dot.ok { background-color: var(--success); }
|
||||||
|
.status-dot.error { background-color: var(--error); }
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 90px;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-details h2 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 90px;
|
||||||
|
height: 50px;
|
||||||
|
background: var(--border);
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formats-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky headers for long lists */
|
||||||
|
.sticky-title {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 4px 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-table td {
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--fg);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-id { width: 50px; color: var(--fg-secondary); }
|
||||||
|
.col-ext { width: 50px; }
|
||||||
|
.col-size { width: 60px; text-align: right; }
|
||||||
|
.format-table th.col-size { text-align: right; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
padding: 12px 0;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--error);
|
||||||
|
line-height: 1.5;
|
||||||
|
background: rgba(224, 0, 0, 0.05);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troubleshoot-box {
|
||||||
|
background: var(--border);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troubleshoot-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.troubleshoot-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 200px;
|
||||||
|
gap: 16px;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2.5px solid var(--border);
|
||||||
|
border-top-color: var(--fg);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-secondary);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
415
extension/entrypoints/popup/App.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
type VideoFormat = {
|
||||||
|
format_id?: string
|
||||||
|
ext?: string
|
||||||
|
format_note?: string
|
||||||
|
resolution?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
fps?: number
|
||||||
|
vcodec?: string
|
||||||
|
acodec?: string
|
||||||
|
filesize?: number
|
||||||
|
filesize_approx?: number
|
||||||
|
tbr?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoInfo = {
|
||||||
|
title?: string
|
||||||
|
thumbnail?: string
|
||||||
|
duration?: number
|
||||||
|
formats?: VideoFormat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
|
const isValidHttpUrl = (value?: string): boolean => {
|
||||||
|
if (!value) return false
|
||||||
|
return value.startsWith('http://') || value.startsWith('https://')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (value?: number): string => {
|
||||||
|
if (!value || value <= 0) return 'Unknown'
|
||||||
|
const totalSeconds = Math.round(value)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||||
|
const paddedSeconds = String(seconds).padStart(2, '0')
|
||||||
|
return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${minutes}:${paddedSeconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatBytes = (value?: number): string => {
|
||||||
|
if (!value || value <= 0) return '-'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let size = value
|
||||||
|
let unitIndex = 0
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${size.toFixed(size >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideoFormat = (format: VideoFormat): boolean => {
|
||||||
|
if (format.vcodec && format.vcodec !== 'none') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return Boolean(format.resolution || format.width || format.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAudioFormat = (format: VideoFormat): boolean => {
|
||||||
|
return Boolean(format.acodec && format.acodec !== 'none' && !isVideoFormat(format))
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoInfoCacheEntry = {
|
||||||
|
url: string
|
||||||
|
status: 'pending' | 'ready' | 'error'
|
||||||
|
fetchedAt: number
|
||||||
|
info?: VideoInfo
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoGroup = {
|
||||||
|
label: string
|
||||||
|
height: number
|
||||||
|
formats: VideoFormat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCachedInfo = async (url: string): Promise<VideoInfoCacheEntry | null> => {
|
||||||
|
const data = await browser.storage.local.get('videoInfoCacheByUrl')
|
||||||
|
const map = data.videoInfoCacheByUrl as Record<string, VideoInfoCacheEntry> | undefined
|
||||||
|
if (!map) return null
|
||||||
|
const cached = map[url]
|
||||||
|
if (!cached) return null
|
||||||
|
if (Date.now() - cached.fetchedAt > CACHE_TTL_MS) return null
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeError = (error: string): string => {
|
||||||
|
const message = error.toLowerCase()
|
||||||
|
if (
|
||||||
|
message.includes('localhost') ||
|
||||||
|
message.includes('fetch') ||
|
||||||
|
message.includes('network') ||
|
||||||
|
message.includes('connect') ||
|
||||||
|
message.includes('failed to request')
|
||||||
|
) {
|
||||||
|
return 'Client connection failed'
|
||||||
|
}
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [info, setInfo] = useState<VideoInfo | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [currentUrl, setCurrentUrl] = useState<string>('')
|
||||||
|
const [retryTrigger, setRetryTrigger] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
const targetState = { url: '' }
|
||||||
|
|
||||||
|
const handleStorageChange = (
|
||||||
|
changes: Record<string, browser.storage.StorageChange>,
|
||||||
|
areaName: string
|
||||||
|
) => {
|
||||||
|
if (!active || areaName !== 'local') return
|
||||||
|
const change = changes.videoInfoCacheByUrl
|
||||||
|
if (!change?.newValue) return
|
||||||
|
|
||||||
|
const map = change.newValue as Record<string, VideoInfoCacheEntry>
|
||||||
|
const next = map[targetState.url]
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
if (next.status === 'ready' && next.info) {
|
||||||
|
setInfo(next.info)
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
} else if (next.status === 'error' && next.error) {
|
||||||
|
setError(sanitizeError(next.error))
|
||||||
|
setInfo(null)
|
||||||
|
setLoading(false)
|
||||||
|
} else if (next.status === 'pending') {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
browser.storage.onChanged.addListener(handleStorageChange)
|
||||||
|
|
||||||
|
const loadInfo = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setInfo(null)
|
||||||
|
|
||||||
|
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
|
||||||
|
if (!isValidHttpUrl(tab?.url)) {
|
||||||
|
setError('Please open a valid video page first.')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = tab.url as string
|
||||||
|
targetState.url = targetUrl
|
||||||
|
setCurrentUrl(targetUrl)
|
||||||
|
|
||||||
|
const cached = await loadCachedInfo(targetUrl)
|
||||||
|
const shouldBypassCache = retryTrigger > 0
|
||||||
|
if (cached && !shouldBypassCache) {
|
||||||
|
if (cached.status === 'ready' && cached.info) {
|
||||||
|
setInfo(cached.info)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cached.status === 'error' && cached.error) {
|
||||||
|
setError(sanitizeError(cached.error))
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.runtime.sendMessage({
|
||||||
|
type: 'video-info:fetch',
|
||||||
|
url: targetUrl
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to request video info.'
|
||||||
|
setError(sanitizeError(message))
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = await loadCachedInfo(targetUrl)
|
||||||
|
if (latest && latest.status === 'ready' && latest.info) {
|
||||||
|
setInfo(latest.info)
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
} else if (latest && latest.status === 'error' && latest.error) {
|
||||||
|
setError(sanitizeError(latest.error))
|
||||||
|
setInfo(null)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadInfo()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
browser.storage.onChanged.removeListener(handleStorageChange)
|
||||||
|
}
|
||||||
|
}, [retryTrigger])
|
||||||
|
|
||||||
|
const formats = useMemo(() => info?.formats ?? [], [info])
|
||||||
|
const groupedFormats = useMemo(() => {
|
||||||
|
const video: VideoFormat[] = []
|
||||||
|
const audio: VideoFormat[] = []
|
||||||
|
const other: VideoFormat[] = []
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
if (isVideoFormat(format)) {
|
||||||
|
video.push(format)
|
||||||
|
} else if (isAudioFormat(format)) {
|
||||||
|
audio.push(format)
|
||||||
|
} else {
|
||||||
|
other.push(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { video, audio, other }
|
||||||
|
}, [formats])
|
||||||
|
|
||||||
|
const groupedVideoFormats = useMemo(() => {
|
||||||
|
const raw = groupedFormats.video
|
||||||
|
if (!raw.length) return []
|
||||||
|
|
||||||
|
const groups: Record<number, VideoFormat[]> = {}
|
||||||
|
const noHeight: VideoFormat[] = []
|
||||||
|
|
||||||
|
for (const f of raw) {
|
||||||
|
const h = f.height || f.resolution?.match(/x(\d+)/)?.[1]
|
||||||
|
const heightVal = h ? Number(h) : 0
|
||||||
|
|
||||||
|
if (heightVal > 0) {
|
||||||
|
if (!groups[heightVal]) groups[heightVal] = []
|
||||||
|
groups[heightVal].push(f)
|
||||||
|
} else {
|
||||||
|
noHeight.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedLabels = Object.keys(groups)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
|
||||||
|
const result: VideoGroup[] = sortedLabels.map((h) => ({
|
||||||
|
label: `${h}p`,
|
||||||
|
height: h,
|
||||||
|
formats: groups[h].sort((a, b) => {
|
||||||
|
const sa = a.filesize || a.filesize_approx || 0
|
||||||
|
const sb = b.filesize || b.filesize_approx || 0
|
||||||
|
return sb - sa
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (noHeight.length > 0) {
|
||||||
|
result.push({
|
||||||
|
label: 'Other',
|
||||||
|
height: 0,
|
||||||
|
formats: noHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [groupedFormats.video])
|
||||||
|
|
||||||
|
const handleOpenClient = () => {
|
||||||
|
if (!currentUrl) return
|
||||||
|
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||||
|
window.location.href = deepLink
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderStatus = () => {
|
||||||
|
// if (loading) return null
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<span className="status-indicator">
|
||||||
|
<div className="status-dot error" /> Error
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
if (info)
|
||||||
|
return (
|
||||||
|
<span className="status-indicator">
|
||||||
|
<div className="status-dot ok" /> Ready
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<span className="status-indicator">
|
||||||
|
<div className="status-dot" /> Idle
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header>
|
||||||
|
<h1>VidBee</h1>
|
||||||
|
{renderStatus()}
|
||||||
|
</header>
|
||||||
|
{loading && (
|
||||||
|
<div className="loading-container">
|
||||||
|
<div className="spinner" />
|
||||||
|
<div className="loading-text">Analyzing video...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="error-container">
|
||||||
|
<div className="error-banner">{error}</div>
|
||||||
|
<div className="troubleshoot-box">
|
||||||
|
<p className="troubleshoot-title">Having trouble?</p>
|
||||||
|
<div className="troubleshoot-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="link-button"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = 'vidbee://'
|
||||||
|
setTimeout(() => setRetryTrigger((c) => c + 1), 100)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Open Client
|
||||||
|
</button>
|
||||||
|
<span className="divider">•</span>
|
||||||
|
<a
|
||||||
|
href="https://vidbee.app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="link-button"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && info && (
|
||||||
|
<>
|
||||||
|
<section className="video-info">
|
||||||
|
<div className="video-details">
|
||||||
|
<h2>{info.title || 'Untitled video'}</h2>
|
||||||
|
<div className="meta-row">
|
||||||
|
<span>{formatDuration(info.duration)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formats.length} formats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{info.thumbnail && <img className="thumbnail" src={info.thumbnail} alt="" />}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button type="button" className="primary-button" onClick={handleOpenClient}>
|
||||||
|
Download with VidBee
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<section className="formats-section">
|
||||||
|
{groupedVideoFormats.map((group) => (
|
||||||
|
<div className="format-group" key={group.label}>
|
||||||
|
<div className="group-title sticky-title">{group.label}</div>
|
||||||
|
<table className="format-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-id">ID</th>
|
||||||
|
<th className="col-ext">Ext</th>
|
||||||
|
<th className="col-size">Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.formats.map((f) => (
|
||||||
|
<tr key={`vg-${group.label}-${f.format_id ?? f.ext ?? 'video'}`}>
|
||||||
|
<td className="col-id">{f.format_id || '-'}</td>
|
||||||
|
<td className="col-ext">{f.ext || '-'}</td>
|
||||||
|
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{groupedVideoFormats.length === 0 && groupedFormats.audio.length === 0 && (
|
||||||
|
<div className="empty-state">No compatible formats.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupedFormats.audio.length > 0 && (
|
||||||
|
<div className="format-group">
|
||||||
|
<div className="group-title">Audio Only</div>
|
||||||
|
<table className="format-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="col-id">ID</th>
|
||||||
|
<th className="col-ext">Ext</th>
|
||||||
|
<th className="col-size">Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{groupedFormats.audio.map((f) => (
|
||||||
|
<tr key={`a-${f.format_id ?? f.ext ?? 'audio'}`}>
|
||||||
|
<td className="col-id">{f.format_id || '-'}</td>
|
||||||
|
<td className="col-ext">{f.ext || '-'}</td>
|
||||||
|
<td className="col-size">{formatBytes(f.filesize || f.filesize_approx)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
13
extension/entrypoints/popup/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Default Popup Title</title>
|
||||||
|
<meta name="manifest.type" content="browser_action" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
extension/entrypoints/popup/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
ReactDOM.createRoot(root).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
extension/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "wxt-react-starter",
|
||||||
|
"description": "manifest.json description",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wxt",
|
||||||
|
"dev:firefox": "wxt -b firefox",
|
||||||
|
"build": "wxt build",
|
||||||
|
"build:firefox": "wxt build -b firefox",
|
||||||
|
"zip": "wxt zip",
|
||||||
|
"zip:firefox": "wxt zip -b firefox",
|
||||||
|
"compile": "tsc --noEmit",
|
||||||
|
"postinstall": "wxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.3",
|
||||||
|
"react-dom": "^19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@wxt-dev/module-react": "^1.1.5",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"wxt": "^0.20.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
3541
extension/pnpm-lock.yaml
generated
Normal file
8
extension/public/_locales/en/messages.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"downloadWithVidBee": {
|
||||||
|
"message": "Download with VidBee"
|
||||||
|
},
|
||||||
|
"hideButton": {
|
||||||
|
"message": "Hide"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
extension/public/icon/128.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
extension/public/icon/16.png
Normal file
|
After Width: | Height: | Size: 924 B |
BIN
extension/public/icon/32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/public/icon/48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-loading-128.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
extension/public/icon/icon-loading-16.png
Normal file
|
After Width: | Height: | Size: 744 B |
BIN
extension/public/icon/icon-loading-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-loading-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
extension/public/icon/icon-success-128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
extension/public/icon/icon-success-16.png
Normal file
|
After Width: | Height: | Size: 751 B |
BIN
extension/public/icon/icon-success-32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
extension/public/icon/icon-success-48.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.wxt/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
extension/wxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'wxt'
|
||||||
|
|
||||||
|
// See https://wxt.dev/api/config.html
|
||||||
|
export default defineConfig({
|
||||||
|
modules: ['@wxt-dev/module-react'],
|
||||||
|
manifest: {
|
||||||
|
default_locale: 'en',
|
||||||
|
host_permissions: ['http://127.0.0.1/*'],
|
||||||
|
permissions: ['activeTab', 'storage']
|
||||||
|
}
|
||||||
|
})
|
||||||
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vidbee",
|
"name": "vidbee",
|
||||||
"version": "1.1.0",
|
"version": "1.1.10",
|
||||||
"description": "A modern Electron application for downloading videos and audios",
|
"description": "A modern Electron application for downloading videos and audios",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "VidBee",
|
"author": "VidBee",
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"setup": "node scripts/setup-dev-binaries.js",
|
"setup": "node scripts/setup-dev-binaries.js",
|
||||||
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
||||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
|
||||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
"build:win": "pnpm run setup && 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 --x64 --arm64",
|
"build:mac": "pnpm run setup && 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",
|
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||||
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||||
"db:generate": "drizzle-kit generate"
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"better-sqlite3": "^12.4.1",
|
"better-sqlite3": "^12.4.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"dayjs": "^1.11.18",
|
"dayjs": "^1.11.18",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"electron-ipc-decorator": "^0.2.0",
|
"electron-ipc-decorator": "^0.2.0",
|
||||||
|
|||||||
60
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
specifier: ^2.1.7
|
||||||
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-progress':
|
'@radix-ui/react-progress':
|
||||||
specifier: ^1.1.7
|
specifier: ^1.1.7
|
||||||
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -80,6 +83,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.18
|
specifier: ^1.11.18
|
||||||
version: 1.11.18
|
version: 1.11.18
|
||||||
@@ -999,6 +1005,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1896,6 +1915,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cmdk@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -4481,6 +4506,29 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -5429,6 +5477,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
2
resources/.gitignore
vendored
@@ -6,6 +6,8 @@ ffmpeg.exe
|
|||||||
ffmpeg_macos
|
ffmpeg_macos
|
||||||
ffmpeg_linux
|
ffmpeg_linux
|
||||||
ffmpeg
|
ffmpeg
|
||||||
|
deno.exe
|
||||||
|
deno
|
||||||
|
|
||||||
# But keep the README
|
# But keep the README
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
@@ -69,3 +69,23 @@ ffmpeg is required for merging audio/video streams and audio extraction. Bundle
|
|||||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
|
- 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.
|
- 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
|
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
|
||||||
|
|
||||||
|
## JS Runtime (Deno)
|
||||||
|
|
||||||
|
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
|
||||||
|
|
||||||
|
### Required Files
|
||||||
|
|
||||||
|
1. **Windows**: `deno.exe`
|
||||||
|
2. **macOS**: `deno`
|
||||||
|
3. **Linux**: `deno`
|
||||||
|
|
||||||
|
### How to Download
|
||||||
|
|
||||||
|
- Visit: <https://github.com/denoland/deno/releases/latest>
|
||||||
|
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
|
||||||
|
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
|
||||||
|
|
||||||
|
### Note
|
||||||
|
|
||||||
|
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ const binaries = [
|
|||||||
linux: 'https://ffmpeg.org/download.html',
|
linux: 'https://ffmpeg.org/download.html',
|
||||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'deno',
|
||||||
|
filenameMap: {
|
||||||
|
win: 'deno.exe',
|
||||||
|
mac: 'deno',
|
||||||
|
linux: 'deno'
|
||||||
|
},
|
||||||
|
help: {
|
||||||
|
default: 'https://github.com/denoland/deno/releases/latest'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,14 @@
|
|||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const os = require('node:os')
|
const os = require('node:os')
|
||||||
const { execSync } = require('node:child_process')
|
const { execSync, spawnSync } = require('node:child_process')
|
||||||
const https = require('node:https')
|
const https = require('node:https')
|
||||||
const http = require('node:http')
|
const http = require('node:http')
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||||
|
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||||
const GITHUB_TOKEN =
|
const GITHUB_TOKEN =
|
||||||
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ const PLATFORM_CONFIG = {
|
|||||||
extract: 'unzip',
|
extract: 'unzip',
|
||||||
release: {
|
release: {
|
||||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||||
binaryName: 'ffmpeg.exe'
|
binaryName: 'ffmpeg.exe'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +79,7 @@ const PLATFORM_CONFIG = {
|
|||||||
extract: 'tar',
|
extract: 'tar',
|
||||||
release: {
|
release: {
|
||||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||||
binaryName: 'ffmpeg'
|
binaryName: 'ffmpeg'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,40 +104,101 @@ function ensureDir(dir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeUnlink(filePath) {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDownloadHeaders(url) {
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'vidbee-setup',
|
||||||
|
Accept: '*/*'
|
||||||
|
}
|
||||||
|
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
|
||||||
|
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
function downloadFile(url, dest) {
|
function downloadFile(url, dest) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
const file = fs.createWriteStream(dest)
|
const file = fs.createWriteStream(dest)
|
||||||
|
let downloadedBytes = 0
|
||||||
|
|
||||||
protocol
|
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||||
.get(url, (response) => {
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
// Handle redirect
|
||||||
// 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()
|
file.close()
|
||||||
fs.unlinkSync(dest)
|
safeUnlink(dest)
|
||||||
reject(err)
|
const redirectUrl = response.headers.location
|
||||||
|
if (!redirectUrl) {
|
||||||
|
return reject(new Error(`Redirect without location for ${url}`))
|
||||||
|
}
|
||||||
|
log(`Redirected to ${redirectUrl}`, 'info')
|
||||||
|
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = response.headers['content-length']
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
file.close()
|
||||||
|
safeUnlink(dest)
|
||||||
|
return reject(
|
||||||
|
new Error(
|
||||||
|
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
downloadedBytes += chunk.length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
response.pipe(file)
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close()
|
||||||
|
log(
|
||||||
|
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
|
||||||
|
downloadedBytes ? 'success' : 'warn'
|
||||||
|
)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
request.setTimeout(30000, () => {
|
||||||
|
request.destroy(new Error('Download timeout'))
|
||||||
|
})
|
||||||
|
|
||||||
|
request.on('error', (err) => {
|
||||||
|
file.close()
|
||||||
|
safeUnlink(dest)
|
||||||
|
log(`Download error for ${url}: ${err.message}`, 'error')
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||||
|
let lastError
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
||||||
|
try {
|
||||||
|
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
|
||||||
|
await downloadFile(url, dest)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
safeUnlink(dest)
|
||||||
|
if (attempt < retries) {
|
||||||
|
const backoff = delayMs * attempt
|
||||||
|
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
function fetchJson(url) {
|
function fetchJson(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
@@ -257,12 +319,73 @@ function fileExists(filePath) {
|
|||||||
return fs.existsSync(filePath)
|
return fs.existsSync(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes <= 0) {
|
||||||
|
return 'unknown size'
|
||||||
|
}
|
||||||
|
if (bytes >= 1024 * 1024) {
|
||||||
|
return `${Math.round(bytes / (1024 * 1024))} MB`
|
||||||
|
}
|
||||||
|
return `${Math.round(bytes / 1024)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkBinary(filePath, args, label) {
|
||||||
|
const result = spawnSync(filePath, args, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 8000,
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return { ok: false, message: result.error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||||
|
return { ok: false, message: output || `exit code ${result.status}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||||
|
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
|
||||||
|
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDenoAssetName(platform, arch) {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return 'deno-aarch64-pc-windows-msvc.zip'
|
||||||
|
}
|
||||||
|
return 'deno-x86_64-pc-windows-msvc.zip'
|
||||||
|
}
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return 'deno-aarch64-apple-darwin.zip'
|
||||||
|
}
|
||||||
|
return 'deno-x86_64-apple-darwin.zip'
|
||||||
|
}
|
||||||
|
if (platform === 'linux') {
|
||||||
|
if (arch === 'arm64') {
|
||||||
|
return 'deno-aarch64-unknown-linux-gnu.zip'
|
||||||
|
}
|
||||||
|
return 'deno-x86_64-unknown-linux-gnu.zip'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDenoOutputName(platform) {
|
||||||
|
return platform === 'win32' ? 'deno.exe' : 'deno'
|
||||||
|
}
|
||||||
|
|
||||||
// Main download functions
|
// Main download functions
|
||||||
async function downloadYtDlp(config) {
|
async function downloadYtDlp(config) {
|
||||||
const { asset, output } = config.ytdlp
|
const { asset, output } = config.ytdlp
|
||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
const outputPath = path.join(RESOURCES_DIR, output)
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
if (fileExists(outputPath)) {
|
||||||
|
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||||
|
if (!validation.ok) {
|
||||||
|
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||||
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
log(`${output} already exists, skipping download`, 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -272,9 +395,14 @@ async function downloadYtDlp(config) {
|
|||||||
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, tempPath)
|
await downloadFileWithRetry(url, tempPath)
|
||||||
fs.renameSync(tempPath, outputPath)
|
fs.renameSync(tempPath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
|
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||||
|
if (!validation.ok) {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (fs.existsSync(tempPath)) {
|
if (fs.existsSync(tempPath)) {
|
||||||
@@ -289,6 +417,10 @@ async function downloadFfmpegWindows(config) {
|
|||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
const outputPath = path.join(RESOURCES_DIR, output)
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
if (fileExists(outputPath)) {
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||||
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
log(`${output} already exists, skipping download`, 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -315,7 +447,7 @@ async function downloadFfmpegWindows(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -325,6 +457,11 @@ async function downloadFfmpegWindows(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -349,6 +486,10 @@ async function downloadFfmpegMac(config) {
|
|||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
const outputPath = path.join(RESOURCES_DIR, output)
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
if (fileExists(outputPath)) {
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||||
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
log(`${output} already exists, skipping download`, 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -370,7 +511,7 @@ async function downloadFfmpegMac(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -381,6 +522,11 @@ async function downloadFfmpegMac(config) {
|
|||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -398,6 +544,10 @@ async function downloadFfmpegLinux(config) {
|
|||||||
const outputPath = path.join(RESOURCES_DIR, output)
|
const outputPath = path.join(RESOURCES_DIR, output)
|
||||||
|
|
||||||
if (fileExists(outputPath)) {
|
if (fileExists(outputPath)) {
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||||
|
}
|
||||||
log(`${output} already exists, skipping download`, 'info')
|
log(`${output} already exists, skipping download`, 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -424,7 +574,7 @@ async function downloadFfmpegLinux(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(downloadUrl, tempTar)
|
await downloadFileWithRetry(downloadUrl, tempTar)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractTarXz(tempTar, extractDir)
|
extractTarXz(tempTar, extractDir)
|
||||||
|
|
||||||
@@ -435,6 +585,11 @@ async function downloadFfmpegLinux(config) {
|
|||||||
|
|
||||||
fs.copyFileSync(sourcePath, outputPath)
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
setExecutable(outputPath)
|
setExecutable(outputPath)
|
||||||
|
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||||
|
if (!validation.ok) {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
log(`Downloaded ${output} successfully`, 'success')
|
log(`Downloaded ${output} successfully`, 'success')
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
@@ -447,6 +602,61 @@ async function downloadFfmpegLinux(config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadDenoRuntime() {
|
||||||
|
const platform = os.platform()
|
||||||
|
const arch = os.arch()
|
||||||
|
const assetName = getDenoAssetName(platform, arch)
|
||||||
|
|
||||||
|
if (!assetName) {
|
||||||
|
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputName = getDenoOutputName(platform)
|
||||||
|
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||||
|
|
||||||
|
if (fileExists(outputPath)) {
|
||||||
|
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||||
|
if (!validation.ok) {
|
||||||
|
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
|
||||||
|
}
|
||||||
|
log(`${outputName} already exists, skipping download`, 'info')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
|
||||||
|
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
|
||||||
|
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
|
||||||
|
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
|
log('Extracting Deno runtime...', 'info')
|
||||||
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
|
const sourcePath = path.join(extractDir, outputName)
|
||||||
|
if (!fileExists(sourcePath)) {
|
||||||
|
throw new Error(`Deno binary not found at ${sourcePath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(sourcePath, outputPath)
|
||||||
|
setExecutable(outputPath)
|
||||||
|
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||||
|
if (!validation.ok) {
|
||||||
|
safeUnlink(outputPath)
|
||||||
|
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
|
||||||
|
}
|
||||||
|
log(`Downloaded ${outputName} successfully`, 'success')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main setup function
|
// Main setup function
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const platform = os.platform()
|
const platform = os.platform()
|
||||||
@@ -464,6 +674,9 @@ async function setup() {
|
|||||||
// Download yt-dlp
|
// Download yt-dlp
|
||||||
await downloadYtDlp(config)
|
await downloadYtDlp(config)
|
||||||
|
|
||||||
|
// Download JS runtime (Deno)
|
||||||
|
await downloadDenoRuntime()
|
||||||
|
|
||||||
// Download ffmpeg
|
// Download ffmpeg
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
await downloadFfmpegWindows(config)
|
await downloadFfmpegWindows(config)
|
||||||
|
|||||||
@@ -5,15 +5,6 @@ import log from 'electron-log/main'
|
|||||||
* Set log format, file path, transport methods, etc.
|
* Set log format, file path, transport methods, etc.
|
||||||
*/
|
*/
|
||||||
export function configureLogger() {
|
export function configureLogger() {
|
||||||
// Configure console output format - support colors and scope, time in gray
|
|
||||||
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
|
|
||||||
|
|
||||||
// Enable console colors
|
|
||||||
log.transports.console.useStyles = true
|
|
||||||
|
|
||||||
// Configure file output format - include scope information
|
|
||||||
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
|
|
||||||
|
|
||||||
// Set log levels
|
// Set log levels
|
||||||
// Development: show all logs
|
// Development: show all logs
|
||||||
// Production: show info level and above only
|
// Production: show info level and above only
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
|
|||||||
const format = options.format
|
const format = options.format
|
||||||
const audioFormat = options.audioFormat
|
const audioFormat = options.audioFormat
|
||||||
|
|
||||||
|
if (format && audioFormat === '') {
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
|
||||||
return format
|
return format
|
||||||
}
|
}
|
||||||
@@ -30,8 +34,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
|
|||||||
return 'bestvideo+none'
|
return 'bestvideo+none'
|
||||||
}
|
}
|
||||||
if (!audioFormat || audioFormat === 'best') {
|
if (!audioFormat || audioFormat === 'best') {
|
||||||
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
|
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
|
||||||
return 'bestvideo+bestaudio'
|
return 'bestvideo+bestaudio/best'
|
||||||
}
|
}
|
||||||
return `bestvideo+${audioFormat}`
|
return `bestvideo+${audioFormat}`
|
||||||
}
|
}
|
||||||
@@ -61,9 +65,10 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
|||||||
export const buildDownloadArgs = (
|
export const buildDownloadArgs = (
|
||||||
options: DownloadOptions,
|
options: DownloadOptions,
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
settings: AppSettings
|
settings: AppSettings,
|
||||||
|
jsRuntimeArgs: string[] = []
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
const args: string[] = ['--no-playlist', '--no-mtime']
|
||||||
|
|
||||||
// Add encoding support for proper handling of non-ASCII characters
|
// Add encoding support for proper handling of non-ASCII characters
|
||||||
args.push('--encoding', 'utf-8')
|
args.push('--encoding', 'utf-8')
|
||||||
@@ -90,11 +95,26 @@ export const buildDownloadArgs = (
|
|||||||
args.push('--download-sections', `*${start}-${end || ''}`)
|
args.push('--download-sections', `*${start}-${end || ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const embedSubs = settings.embedSubs
|
||||||
|
const embedMetadata = settings.embedMetadata
|
||||||
|
const embedChapters = settings.embedChapters
|
||||||
|
|
||||||
// Subtitles
|
// Subtitles
|
||||||
if (options.downloadSubs) {
|
if (options.downloadSubs || embedSubs) {
|
||||||
args.push('--write-subs', '--sub-langs', 'all')
|
args.push('--sub-langs', 'all')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.downloadSubs) {
|
||||||
|
args.push('--write-subs')
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||||
|
}
|
||||||
|
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
||||||
|
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
||||||
|
|
||||||
// Output path with proper encoding handling
|
// Output path with proper encoding handling
|
||||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||||
const filenameTemplate = sanitizeFilenameTemplate(
|
const filenameTemplate = sanitizeFilenameTemplate(
|
||||||
@@ -130,6 +150,10 @@ export const buildDownloadArgs = (
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jsRuntimeArgs.length > 0) {
|
||||||
|
args.push(...jsRuntimeArgs)
|
||||||
|
}
|
||||||
|
|
||||||
args.push(options.url)
|
args.push(options.url)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { existsSync } from 'node:fs'
|
|||||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron'
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
type BrowserWindowConstructorOptions,
|
||||||
|
ipcMain,
|
||||||
|
protocol,
|
||||||
|
shell
|
||||||
|
} from 'electron'
|
||||||
import log from 'electron-log/main'
|
import log from 'electron-log/main'
|
||||||
import { autoUpdater } from 'electron-updater'
|
import { autoUpdater } from 'electron-updater'
|
||||||
import appIcon from '../../build/icon.png?asset'
|
import appIcon from '../../build/icon.png?asset'
|
||||||
@@ -13,6 +20,7 @@ import { ffmpegManager } from './lib/ffmpeg-manager'
|
|||||||
import { subscriptionManager } from './lib/subscription-manager'
|
import { subscriptionManager } from './lib/subscription-manager'
|
||||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||||
|
import { startExtensionApiServer, stopExtensionApiServer } from './local-api'
|
||||||
import { settingsManager } from './settings'
|
import { settingsManager } from './settings'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
import { applyAutoLaunchSetting } from './utils/auto-launch'
|
import { applyAutoLaunchSetting } from './utils/auto-launch'
|
||||||
@@ -129,6 +137,7 @@ subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
|||||||
export function createWindow(): void {
|
export function createWindow(): void {
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
|
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
|
||||||
|
|
||||||
const windowOptions: BrowserWindowConstructorOptions = {
|
const windowOptions: BrowserWindowConstructorOptions = {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
@@ -168,6 +177,9 @@ export function createWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
if (shouldStartHidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,10 +202,48 @@ export function createWindow(): void {
|
|||||||
flushPendingDeepLinks()
|
flushPendingDeepLinks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Setup error handling for renderer process
|
||||||
|
setupRendererErrorHandling()
|
||||||
|
|
||||||
// Setup download engine event forwarding to renderer
|
// Setup download engine event forwarding to renderer
|
||||||
setupDownloadEvents()
|
setupDownloadEvents()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupRendererErrorHandling(): void {
|
||||||
|
if (!mainWindow) return
|
||||||
|
|
||||||
|
// Handle uncaught exceptions in renderer process
|
||||||
|
mainWindow.webContents.on('unresponsive', () => {
|
||||||
|
log.error('Renderer process became unresponsive')
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.on('responsive', () => {
|
||||||
|
log.info('Renderer process became responsive again')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for renderer errors via IPC
|
||||||
|
ipcMain.on('error:renderer', (_event, errorData) => {
|
||||||
|
log.error('Renderer error received:', errorData)
|
||||||
|
|
||||||
|
// Log detailed error information
|
||||||
|
if (errorData.error) {
|
||||||
|
log.error('Error name:', errorData.error.name)
|
||||||
|
log.error('Error message:', errorData.error.message)
|
||||||
|
if (errorData.error.stack) {
|
||||||
|
log.error('Error stack:', errorData.error.stack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorData.errorInfo?.componentStack) {
|
||||||
|
log.error('Component stack:', errorData.errorInfo.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorData.context) {
|
||||||
|
log.error('Error context:', errorData.context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function setupDownloadEvents(): void {
|
function setupDownloadEvents(): void {
|
||||||
downloadEngine.on('download-started', (id: string) => {
|
downloadEngine.on('download-started', (id: string) => {
|
||||||
mainWindow?.webContents.send('download:started', id)
|
mainWindow?.webContents.send('download:started', id)
|
||||||
@@ -314,14 +364,6 @@ function initAutoUpdater(): void {
|
|||||||
autoUpdater.on('update-downloaded', (info) => {
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
log.info('Update downloaded:', info.version)
|
log.info('Update downloaded:', info.version)
|
||||||
mainWindow?.webContents.send('update:downloaded', info)
|
mainWindow?.webContents.send('update:downloaded', info)
|
||||||
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.webContents.send('update:show-notification', {
|
|
||||||
title: 'Update Ready',
|
|
||||||
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
|
|
||||||
icon: 'app-icon'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
log.info('Auto-updater initialized successfully')
|
log.info('Auto-updater initialized successfully')
|
||||||
@@ -381,6 +423,17 @@ app.whenReady().then(async () => {
|
|||||||
// and ignore CommandOrControl + R in production.
|
// and ignore CommandOrControl + R in production.
|
||||||
app.on('browser-window-created', (_, window) => {
|
app.on('browser-window-created', (_, window) => {
|
||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
|
||||||
|
// Enable F12 to toggle DevTools in both development and production
|
||||||
|
window.webContents.on('before-input-event', (_, input) => {
|
||||||
|
if (input.key === 'F12') {
|
||||||
|
if (window.webContents.isDevToolsOpened()) {
|
||||||
|
window.webContents.closeDevTools()
|
||||||
|
} else {
|
||||||
|
window.webContents.openDevTools()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// IPC services are automatically registered by electron-ipc-decorator when imported
|
// IPC services are automatically registered by electron-ipc-decorator when imported
|
||||||
@@ -404,6 +457,8 @@ app.whenReady().then(async () => {
|
|||||||
log.error('Failed to initialize yt-dlp:', error)
|
log.error('Failed to initialize yt-dlp:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await startExtensionApiServer()
|
||||||
|
|
||||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||||
|
|
||||||
@@ -451,6 +506,7 @@ app.on('window-all-closed', () => {
|
|||||||
// Cleanup tray on quit
|
// Cleanup tray on quit
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
destroyTray()
|
destroyTray()
|
||||||
|
void stopExtensionApiServer()
|
||||||
})
|
})
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
// In this file you can include the rest of your app's specific main process
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
|
import { createServices, type MergeIpcService } from 'electron-ipc-decorator'
|
||||||
import { AppService } from './services/app-service'
|
import { AppService } from './services/app-service'
|
||||||
|
import { BrowserCookiesService } from './services/browser-cookies-service'
|
||||||
import { DownloadService } from './services/download-service'
|
import { DownloadService } from './services/download-service'
|
||||||
import { FileSystemService } from './services/file-system-service'
|
import { FileSystemService } from './services/file-system-service'
|
||||||
import { HistoryService } from './services/history-service'
|
import { HistoryService } from './services/history-service'
|
||||||
@@ -12,6 +13,7 @@ import { WindowService } from './services/window-service'
|
|||||||
// Create services with automatic type inference
|
// Create services with automatic type inference
|
||||||
export const services = createServices([
|
export const services = createServices([
|
||||||
AppService,
|
AppService,
|
||||||
|
BrowserCookiesService,
|
||||||
DownloadService,
|
DownloadService,
|
||||||
FileSystemService,
|
FileSystemService,
|
||||||
HistoryService,
|
HistoryService,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { app, BrowserWindow, dialog } from 'electron'
|
import { app, BrowserWindow, dialog } from 'electron'
|
||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import { scopedLoggers } from '../../utils/logger'
|
||||||
|
|
||||||
class AppService extends IpcService {
|
class AppService extends IpcService {
|
||||||
static readonly groupName = 'app'
|
static readonly groupName = 'app'
|
||||||
@@ -48,7 +49,7 @@ class AppService extends IpcService {
|
|||||||
const base64 = buffer.toString('base64')
|
const base64 = buffer.toString('base64')
|
||||||
return `data:${contentType};base64,${base64}`
|
return `data:${contentType};base64,${base64}`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch site icon:', error)
|
scopedLoggers.system.error('Failed to fetch site icon:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
270
src/main/ipc/services/browser-cookies-service.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import { resolvePathWithHome } from '../../utils/path-helpers'
|
||||||
|
|
||||||
|
class BrowserCookiesService extends IpcService {
|
||||||
|
static readonly groupName = 'browserCookies'
|
||||||
|
|
||||||
|
private buildValidationResult(valid: boolean, reason?: string) {
|
||||||
|
if (valid) {
|
||||||
|
return { valid }
|
||||||
|
}
|
||||||
|
return { valid, reason }
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDirectory(target: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(target).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickFirstDirectory(paths: string[]): string {
|
||||||
|
for (const candidate of paths) {
|
||||||
|
if (this.isDirectory(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeProfileInput(value: string): string {
|
||||||
|
return value.trim().replace(/^['"]|['"]$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBrowserProfileBaseDirs(platform: string, homeDir: string, browser: string): string[] {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
if (browser === 'edge') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Local', 'Microsoft', 'Edge', 'User Data')]
|
||||||
|
}
|
||||||
|
if (browser === 'chrome') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Local', 'Google', 'Chrome', 'User Data')]
|
||||||
|
}
|
||||||
|
if (browser === 'chromium') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Local', 'Chromium', 'User Data')]
|
||||||
|
}
|
||||||
|
if (browser === 'brave') {
|
||||||
|
return [
|
||||||
|
path.join(homeDir, 'AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (browser === 'vivaldi') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Local', 'Vivaldi', 'User Data')]
|
||||||
|
}
|
||||||
|
if (browser === 'whale') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Local', 'Naver', 'Whale', 'User Data')]
|
||||||
|
}
|
||||||
|
if (browser === 'opera') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable')]
|
||||||
|
}
|
||||||
|
if (browser === 'firefox') {
|
||||||
|
return [path.join(homeDir, 'AppData', 'Roaming', 'Mozilla', 'Firefox', 'Profiles')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
if (browser === 'edge') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Application Support', 'Microsoft Edge')]
|
||||||
|
}
|
||||||
|
if (browser === 'chrome') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Application Support', 'Google', 'Chrome')]
|
||||||
|
}
|
||||||
|
if (browser === 'chromium') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Application Support', 'Chromium')]
|
||||||
|
}
|
||||||
|
if (browser === 'brave') {
|
||||||
|
return [
|
||||||
|
path.join(homeDir, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (browser === 'vivaldi') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Application Support', 'Vivaldi')]
|
||||||
|
}
|
||||||
|
if (browser === 'whale') {
|
||||||
|
return [
|
||||||
|
path.join(homeDir, 'Library', 'Application Support', 'Whale'),
|
||||||
|
path.join(homeDir, 'Library', 'Application Support', 'Naver Whale')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (browser === 'opera') {
|
||||||
|
return [
|
||||||
|
path.join(homeDir, 'Library', 'Application Support', 'com.operasoftware.Opera'),
|
||||||
|
path.join(homeDir, 'Library', 'Application Support', 'Opera Software', 'Opera Stable')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (browser === 'firefox') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Application Support', 'Firefox', 'Profiles')]
|
||||||
|
}
|
||||||
|
if (browser === 'safari') {
|
||||||
|
return [path.join(homeDir, 'Library', 'Safari')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
if (browser === 'edge') {
|
||||||
|
return [path.join(homeDir, '.config', 'microsoft-edge')]
|
||||||
|
}
|
||||||
|
if (browser === 'chrome') {
|
||||||
|
return [path.join(homeDir, '.config', 'google-chrome')]
|
||||||
|
}
|
||||||
|
if (browser === 'chromium') {
|
||||||
|
return [path.join(homeDir, '.config', 'chromium')]
|
||||||
|
}
|
||||||
|
if (browser === 'brave') {
|
||||||
|
return [path.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser')]
|
||||||
|
}
|
||||||
|
if (browser === 'vivaldi') {
|
||||||
|
return [path.join(homeDir, '.config', 'vivaldi')]
|
||||||
|
}
|
||||||
|
if (browser === 'whale') {
|
||||||
|
return [path.join(homeDir, '.config', 'naver-whale')]
|
||||||
|
}
|
||||||
|
if (browser === 'opera') {
|
||||||
|
return [path.join(homeDir, '.config', 'opera')]
|
||||||
|
}
|
||||||
|
if (browser === 'firefox') {
|
||||||
|
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'freebsd') {
|
||||||
|
if (browser === 'firefox') {
|
||||||
|
return [path.join(homeDir, '.mozilla', 'firefox')]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultProfilePath(baseDirs: string[], browser: string): string {
|
||||||
|
const base = baseDirs[0]
|
||||||
|
if (!base) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser === 'firefox' || browser === 'safari' || browser === 'opera') {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(base, 'Default')
|
||||||
|
}
|
||||||
|
|
||||||
|
private findFirefoxProfilePath(profilesDir: string): string {
|
||||||
|
if (!this.isDirectory(profilesDir)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(profilesDir, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) => entry.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
|
||||||
|
const preferred =
|
||||||
|
entries.find((name) => name.endsWith('.default-release')) ??
|
||||||
|
entries.find((name) => name.endsWith('.default')) ??
|
||||||
|
entries[0]
|
||||||
|
|
||||||
|
return preferred ? path.join(profilesDir, preferred) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
getBrowserProfilePath(_context: IpcContext, browser: string): string {
|
||||||
|
if (!browser || browser === 'none') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const homeDir = os.homedir()
|
||||||
|
const platform = os.platform()
|
||||||
|
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||||
|
const fallbackPath = this.getDefaultProfilePath(baseDirs, browser)
|
||||||
|
|
||||||
|
if (browser === 'firefox') {
|
||||||
|
const profilesDir = baseDirs[0]
|
||||||
|
const profilePath = profilesDir ? this.findFirefoxProfilePath(profilesDir) : ''
|
||||||
|
return profilePath || fallbackPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser === 'safari') {
|
||||||
|
const safariPath = baseDirs[0]
|
||||||
|
if (safariPath && this.isDirectory(safariPath)) {
|
||||||
|
return safariPath
|
||||||
|
}
|
||||||
|
return fallbackPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseDirs.length === 0) {
|
||||||
|
return fallbackPath
|
||||||
|
}
|
||||||
|
|
||||||
|
let detectedPath = ''
|
||||||
|
for (const baseDir of baseDirs) {
|
||||||
|
if (!baseDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const candidates =
|
||||||
|
browser === 'opera'
|
||||||
|
? [baseDir, path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||||
|
: [path.join(baseDir, 'Default'), path.join(baseDir, 'Profile 1')]
|
||||||
|
detectedPath = this.pickFirstDirectory(candidates)
|
||||||
|
if (detectedPath) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return detectedPath || fallbackPath
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
validateBrowserProfilePath(
|
||||||
|
_context: IpcContext,
|
||||||
|
browser: string,
|
||||||
|
profilePath: string
|
||||||
|
): { valid: boolean; reason?: string } {
|
||||||
|
if (!browser || browser === 'none') {
|
||||||
|
return this.buildValidationResult(false, 'browserUnsupported')
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInput = this.normalizeProfileInput(profilePath)
|
||||||
|
if (!normalizedInput) {
|
||||||
|
return this.buildValidationResult(false, 'empty')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedInput = resolvePathWithHome(normalizedInput)
|
||||||
|
if (resolvedInput && this.isDirectory(resolvedInput)) {
|
||||||
|
return this.buildValidationResult(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikePath =
|
||||||
|
resolvedInput &&
|
||||||
|
(path.isAbsolute(resolvedInput) ||
|
||||||
|
resolvedInput.includes('/') ||
|
||||||
|
resolvedInput.includes('\\'))
|
||||||
|
if (looksLikePath) {
|
||||||
|
return this.buildValidationResult(false, 'pathNotFound')
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = os.platform()
|
||||||
|
const homeDir = os.homedir()
|
||||||
|
const baseDirs = this.getBrowserProfileBaseDirs(platform, homeDir, browser)
|
||||||
|
if (baseDirs.length === 0) {
|
||||||
|
return this.buildValidationResult(false, 'browserUnsupported')
|
||||||
|
}
|
||||||
|
for (const baseDir of baseDirs) {
|
||||||
|
if (!baseDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const candidate = path.join(baseDir, normalizedInput)
|
||||||
|
if (this.isDirectory(candidate)) {
|
||||||
|
return this.buildValidationResult(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.buildValidationResult(false, 'profileNotFound')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BrowserCookiesService }
|
||||||
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
|
|||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { clipboard, dialog, shell } from 'electron'
|
import { clipboard, dialog, shell } from 'electron'
|
||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import { scopedLoggers } from '../../utils/logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ class FileSystemService extends IpcService {
|
|||||||
return xdgPath
|
return xdgPath
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
|
scopedLoggers.system.warn(
|
||||||
|
'Unable to resolve XDG download directory, falling back to default:',
|
||||||
|
error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +79,7 @@ class FileSystemService extends IpcService {
|
|||||||
if (stats?.isDirectory()) {
|
if (stats?.isDirectory()) {
|
||||||
const result = await shell.openPath(normalizedPath)
|
const result = await shell.openPath(normalizedPath)
|
||||||
if (result) {
|
if (result) {
|
||||||
console.error('Failed to open directory:', result)
|
scopedLoggers.system.error('Failed to open directory:', result)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -88,16 +92,16 @@ class FileSystemService extends IpcService {
|
|||||||
if (parentStats?.isDirectory()) {
|
if (parentStats?.isDirectory()) {
|
||||||
const result = await shell.openPath(parentDirectory)
|
const result = await shell.openPath(parentDirectory)
|
||||||
if (result) {
|
if (result) {
|
||||||
console.error('Failed to open parent directory:', result)
|
scopedLoggers.system.error('Failed to open parent directory:', result)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('File or directory does not exist:', normalizedPath)
|
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open file location:', error)
|
scopedLoggers.system.error('Failed to open file location:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +126,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy file to clipboard:', error)
|
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,7 @@ class FileSystemService extends IpcService {
|
|||||||
await shell.openExternal(url)
|
await shell.openExternal(url)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open external URL:', error)
|
scopedLoggers.system.error('Failed to open external URL:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +170,10 @@ class FileSystemService extends IpcService {
|
|||||||
])
|
])
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
|
scopedLoggers.system.error(
|
||||||
|
'PowerShell clipboard copy failed, falling back to manual buffer:',
|
||||||
|
error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const winPath = resolvedPath.replace(/\//g, '\\')
|
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||||
@@ -194,7 +201,10 @@ class FileSystemService extends IpcService {
|
|||||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
|
scopedLoggers.system.error(
|
||||||
|
'osascript clipboard copy failed, falling back to manual buffer:',
|
||||||
|
error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = [
|
const entries = [
|
||||||
@@ -243,7 +253,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return stats?.isFile() ?? false
|
return stats?.isFile() ?? false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check file existence:', error)
|
scopedLoggers.system.error('Failed to check file existence:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +305,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete file:', error)
|
scopedLoggers.system.error('Failed to delete file:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
import type { AppSettings } from '../../../shared/types'
|
import type { AppSettings } from '../../../shared/types'
|
||||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
|
||||||
import { settingsManager } from '../../settings'
|
import { settingsManager } from '../../settings'
|
||||||
import { updateTrayMenu } from '../../tray'
|
import { updateTrayMenu } from '../../tray'
|
||||||
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
|
import { applyAutoLaunchSetting } from '../../utils/auto-launch'
|
||||||
@@ -29,10 +28,6 @@ class SettingsService extends IpcService {
|
|||||||
if (key === 'launchAtLogin') {
|
if (key === 'launchAtLogin') {
|
||||||
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
|
applyAutoLaunchSetting(value as AppSettings['launchAtLogin'])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'subscriptionCheckIntervalHours') {
|
|
||||||
subscriptionScheduler.refreshInterval()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
@@ -55,10 +50,6 @@ class SettingsService extends IpcService {
|
|||||||
if (typeof settings.launchAtLogin === 'boolean') {
|
if (typeof settings.launchAtLogin === 'boolean') {
|
||||||
applyAutoLaunchSetting(settings.launchAtLogin)
|
applyAutoLaunchSetting(settings.launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
|
||||||
subscriptionScheduler.refreshInterval()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
@@ -66,7 +57,6 @@ class SettingsService extends IpcService {
|
|||||||
settingsManager.reset()
|
settingsManager.reset()
|
||||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||||
subscriptionScheduler.refreshInterval()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ interface DownloadProcess {
|
|||||||
process: YTDlpEventEmitter
|
process: YTDlpEventEmitter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYtDlpCommand = (args: string[]): string => {
|
||||||
|
const quoted = args.map((arg) => {
|
||||||
|
if (arg === '') {
|
||||||
|
return '""'
|
||||||
|
}
|
||||||
|
if (/[\s"'\\]/.test(arg)) {
|
||||||
|
return `"${arg.replace(/(["\\])/g, '\\$1')}"`
|
||||||
|
}
|
||||||
|
return arg
|
||||||
|
})
|
||||||
|
return `yt-dlp ${quoted.join(' ')}`
|
||||||
|
}
|
||||||
|
|
||||||
const ensureDirectoryExists = (dir?: string): void => {
|
const ensureDirectoryExists = (dir?: string): void => {
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
return
|
return
|
||||||
@@ -145,6 +158,13 @@ const resolveHistoryDownloadPath = (
|
|||||||
return path.join(basePath, templateDir)
|
return path.join(basePath, templateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendJsRuntimeArgs = (args: string[]): void => {
|
||||||
|
const runtimeArgs = ytdlpManager.getJsRuntimeArgs()
|
||||||
|
if (runtimeArgs.length > 0) {
|
||||||
|
args.push(...runtimeArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DownloadEngine extends EventEmitter {
|
class DownloadEngine extends EventEmitter {
|
||||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||||
private queue: DownloadQueue
|
private queue: DownloadQueue
|
||||||
@@ -194,6 +214,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -290,6 +311,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
type RawPlaylistEntry = {
|
type RawPlaylistEntry = {
|
||||||
@@ -511,7 +533,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
startDownload(id: string, options: DownloadOptions): void {
|
startDownload(id: string, options: DownloadOptions): void {
|
||||||
if (this.activeDownloads.has(id)) {
|
if (this.activeDownloads.has(id)) {
|
||||||
console.warn(`Download ${id} is already active`)
|
scopedLoggers.engine.warn(`Download ${id} is already active`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +666,12 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
const args = buildDownloadArgs(
|
||||||
|
options,
|
||||||
|
resolvedDownloadPath,
|
||||||
|
settings,
|
||||||
|
ytdlpManager.getJsRuntimeArgs()
|
||||||
|
)
|
||||||
|
|
||||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||||
if (!rawPath) {
|
if (!rawPath) {
|
||||||
@@ -718,6 +745,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--ffmpeg-location', ffmpegPath)
|
args.push('--ffmpeg-location', ffmpegPath)
|
||||||
args.push(urlArg)
|
args.push(urlArg)
|
||||||
|
|
||||||
|
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const ytdlpProcess = ytdlp.exec(args, {
|
const ytdlpProcess = ytdlp.exec(args, {
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { execSync } from 'node:child_process'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
class FfmpegManager {
|
class FfmpegManager {
|
||||||
private ffmpegPath: string | null = null
|
private ffmpegPath: string | null = null
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.ffmpegPath = await this.findFfmpegBinary()
|
this.ffmpegPath = await this.findFfmpegBinary()
|
||||||
console.log('ffmpeg initialized at:', this.ffmpegPath)
|
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
getPath(): string {
|
getPath(): string {
|
||||||
@@ -30,7 +31,7 @@ class FfmpegManager {
|
|||||||
const resourceCandidates: string[] = []
|
const resourceCandidates: string[] = []
|
||||||
|
|
||||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
||||||
console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
||||||
return process.env.FFMPEG_PATH
|
return process.env.FFMPEG_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +51,13 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
fs.chmodSync(fullPath, 0o755)
|
fs.chmodSync(fullPath, 0o755)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to set executable permission on ffmpeg binary:', error)
|
scopedLoggers.engine.warn(
|
||||||
|
'Failed to set executable permission on ffmpeg binary:',
|
||||||
|
error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Using bundled ffmpeg:', fullPath)
|
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
|
||||||
return fullPath
|
return fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ class FfmpegManager {
|
|||||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
||||||
for (const candidate of commonPaths) {
|
for (const candidate of commonPaths) {
|
||||||
if (fs.existsSync(candidate)) {
|
if (fs.existsSync(candidate)) {
|
||||||
console.log('Using system ffmpeg:', candidate)
|
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,7 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||||
if (systemPath && fs.existsSync(systemPath)) {
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
console.log('Using system ffmpeg:', systemPath)
|
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
||||||
return systemPath
|
return systemPath
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -84,7 +88,7 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||||
if (output && fs.existsSync(output)) {
|
if (output && fs.existsSync(output)) {
|
||||||
console.log('Using system ffmpeg:', output)
|
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type ParserItem = {
|
|||||||
isoDate?: string
|
isoDate?: string
|
||||||
pubDate?: string
|
pubDate?: string
|
||||||
youtubeId?: string
|
youtubeId?: string
|
||||||
|
content?: string
|
||||||
|
contentSnippet?: string
|
||||||
|
contentEncoded?: string
|
||||||
|
summary?: string
|
||||||
|
description?: string
|
||||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||||
@@ -41,24 +46,19 @@ type FeedItem = {
|
|||||||
thumbnail?: string
|
thumbnail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = new Parser<{ item: ParserItem }>({
|
const parser = new Parser<Record<string, never>, ParserItem>({
|
||||||
customFields: {
|
customFields: {
|
||||||
item: [
|
item: [
|
||||||
['yt:videoId', 'youtubeId'],
|
['yt:videoId', 'youtubeId'],
|
||||||
['media:thumbnail', 'mediaThumbnail'],
|
['media:thumbnail', 'mediaThumbnail'],
|
||||||
['media:content', 'mediaContent'],
|
['media:content', 'mediaContent'],
|
||||||
['enclosure', 'enclosure']
|
['enclosure', 'enclosure'],
|
||||||
|
['content:encoded', 'contentEncoded'],
|
||||||
|
['description', 'description']
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const clampIntervalHours = (value: number | undefined): number => {
|
|
||||||
if (!value || Number.isNaN(value)) {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
return Math.min(24, Math.max(1, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||||
return `sub_${base}`
|
return `sub_${base}`
|
||||||
@@ -167,7 +167,7 @@ export class SubscriptionScheduler extends EventEmitter {
|
|||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
}
|
}
|
||||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
const intervalHours = 3 // Default check interval: 3 hours
|
||||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||||
this.timer = setTimeout(() => {
|
this.timer = setTimeout(() => {
|
||||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||||
@@ -240,13 +240,18 @@ export class SubscriptionScheduler extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestItem = normalizedItems[0]
|
const latestItem = normalizedItems[0]
|
||||||
|
const coverUrl = this.resolveSubscriptionCover(
|
||||||
|
feed,
|
||||||
|
normalizedItems,
|
||||||
|
feedItems as ParserItem[]
|
||||||
|
)
|
||||||
subscriptionManager.update(subscription.id, {
|
subscriptionManager.update(subscription.id, {
|
||||||
status: 'up-to-date',
|
status: 'up-to-date',
|
||||||
lastSuccessAt: Date.now(),
|
lastSuccessAt: Date.now(),
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
coverUrl: coverUrl ?? subscription.coverUrl,
|
||||||
title:
|
title:
|
||||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||||
? feed.title.trim()
|
? feed.title.trim()
|
||||||
@@ -366,6 +371,74 @@ export class SubscriptionScheduler extends EventEmitter {
|
|||||||
return mediaContent.url as string | undefined
|
return mediaContent.url as string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to parse an image from HTML fields
|
||||||
|
const htmlCandidates = [
|
||||||
|
item.content,
|
||||||
|
item.contentEncoded,
|
||||||
|
item.description,
|
||||||
|
item.summary,
|
||||||
|
item.contentSnippet
|
||||||
|
]
|
||||||
|
for (const html of htmlCandidates) {
|
||||||
|
const imageUrl = this.extractImageFromHtml(html)
|
||||||
|
if (imageUrl) {
|
||||||
|
return imageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSubscriptionCover(
|
||||||
|
feed: Parser.Output<ParserItem>,
|
||||||
|
items: FeedItem[],
|
||||||
|
rawItems: ParserItem[]
|
||||||
|
): string | undefined {
|
||||||
|
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
|
||||||
|
if (feedImageUrl) {
|
||||||
|
return feedImageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
|
||||||
|
if (itunesImageUrl) {
|
||||||
|
return itunesImageUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
|
||||||
|
if (itemThumbnail) {
|
||||||
|
return itemThumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of rawItems) {
|
||||||
|
const thumbnail = this.resolveThumbnail(item)
|
||||||
|
if (thumbnail) {
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractImageFromHtml(html?: string): string | undefined {
|
||||||
|
if (!html) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcMatch = html.match(
|
||||||
|
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
|
||||||
|
)
|
||||||
|
if (srcMatch?.[2]) {
|
||||||
|
return srcMatch[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
|
||||||
|
if (srcsetMatch?.[2]) {
|
||||||
|
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
|
||||||
|
if (firstCandidate) {
|
||||||
|
return firstCandidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fsPromises from 'node:fs/promises'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export class ThumbnailCache {
|
|||||||
await fsPromises.writeFile(finalPath, buffer)
|
await fsPromises.writeFile(finalPath, buffer)
|
||||||
return this.toAppProtocolUrl(finalPath)
|
return this.toAppProtocolUrl(finalPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cache thumbnail:', error)
|
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type YTDlpWrap from 'yt-dlp-wrap-plus'
|
import type YTDlpWrap from 'yt-dlp-wrap-plus'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
|
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
|
||||||
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
||||||
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
|
|||||||
class YtDlpManager {
|
class YtDlpManager {
|
||||||
private ytdlpPath: string | null = null
|
private ytdlpPath: string | null = null
|
||||||
private ytdlpInstance: YTDlpWrapInstance | null = null
|
private ytdlpInstance: YTDlpWrapInstance | null = null
|
||||||
|
private jsRuntimeArgs: string[] = []
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
||||||
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
|
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
|
||||||
console.log('yt-dlp initialized at:', this.ytdlpPath)
|
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
|
||||||
|
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstance(): YTDlpWrapInstance {
|
getInstance(): YTDlpWrapInstance {
|
||||||
@@ -33,6 +36,10 @@ class YtDlpManager {
|
|||||||
return this.ytdlpPath
|
return this.ytdlpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getJsRuntimeArgs(): string[] {
|
||||||
|
return [...this.jsRuntimeArgs]
|
||||||
|
}
|
||||||
|
|
||||||
private getResourcesPath(): string {
|
private getResourcesPath(): string {
|
||||||
// In development, read from project root's resources
|
// In development, read from project root's resources
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
@@ -57,7 +64,7 @@ class YtDlpManager {
|
|||||||
|
|
||||||
// Check environment variable first
|
// Check environment variable first
|
||||||
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
||||||
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
|
||||||
return process.env.YTDLP_PATH
|
return process.env.YTDLP_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +72,13 @@ class YtDlpManager {
|
|||||||
const resourcesPath = this.getResourcesPath()
|
const resourcesPath = this.getResourcesPath()
|
||||||
const bundledPath = path.join(resourcesPath, bundledName)
|
const bundledPath = path.join(resourcesPath, bundledName)
|
||||||
if (fs.existsSync(bundledPath)) {
|
if (fs.existsSync(bundledPath)) {
|
||||||
console.log('Using bundled yt-dlp:', bundledPath)
|
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
|
||||||
// Make executable on Unix-like systems if needed
|
// Make executable on Unix-like systems if needed
|
||||||
if (platform !== 'win32') {
|
if (platform !== 'win32') {
|
||||||
try {
|
try {
|
||||||
fs.chmodSync(bundledPath, 0o755)
|
fs.chmodSync(bundledPath, 0o755)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to set executable permission:', error)
|
scopedLoggers.engine.warn('Failed to set executable permission:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bundledPath
|
return bundledPath
|
||||||
@@ -82,7 +89,7 @@ class YtDlpManager {
|
|||||||
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
||||||
for (const p of possiblePaths) {
|
for (const p of possiblePaths) {
|
||||||
if (fs.existsSync(p)) {
|
if (fs.existsSync(p)) {
|
||||||
console.log('Using system yt-dlp:', p)
|
scopedLoggers.engine.info('Using system yt-dlp:', p)
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +100,7 @@ class YtDlpManager {
|
|||||||
try {
|
try {
|
||||||
const systemPath = execSync('which yt-dlp').toString().trim()
|
const systemPath = execSync('which yt-dlp').toString().trim()
|
||||||
if (systemPath && fs.existsSync(systemPath)) {
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
console.log('Using system yt-dlp:', systemPath)
|
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
|
||||||
return systemPath
|
return systemPath
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -115,6 +122,92 @@ class YtDlpManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveJsRuntimeArgs(): string[] {
|
||||||
|
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
|
||||||
|
if (!runtime || runtime === 'none') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimePath = this.resolveJsRuntimePath(runtime)
|
||||||
|
if (runtimePath) {
|
||||||
|
return ['--js-runtimes', `${runtime}:${runtimePath}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.YTDLP_JS_RUNTIME) {
|
||||||
|
scopedLoggers.engine.warn(
|
||||||
|
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
scopedLoggers.engine.warn(
|
||||||
|
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveJsRuntimePath(runtime: string): string | null {
|
||||||
|
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
|
||||||
|
if (envPath && fs.existsSync(envPath)) {
|
||||||
|
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
|
||||||
|
return envPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = os.platform()
|
||||||
|
const resourcesPath = this.getResourcesPath()
|
||||||
|
const resourceCandidates: string[] = []
|
||||||
|
|
||||||
|
if (runtime === 'deno') {
|
||||||
|
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
|
||||||
|
} else if (runtime === 'node') {
|
||||||
|
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
|
||||||
|
} else if (runtime === 'bun') {
|
||||||
|
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
|
||||||
|
} else if (runtime === 'quickjs') {
|
||||||
|
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
|
||||||
|
} else {
|
||||||
|
resourceCandidates.push(runtime)
|
||||||
|
if (platform === 'win32' && !runtime.endsWith('.exe')) {
|
||||||
|
resourceCandidates.push(`${runtime}.exe`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
|
||||||
|
return fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
|
||||||
|
if (output && fs.existsSync(output)) {
|
||||||
|
scopedLoggers.engine.info('Using system JS runtime:', output)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const systemPath = execSync(`which ${runtime}`).toString().trim()
|
||||||
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
|
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
|
||||||
|
return systemPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// Runtime not found in PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Removed runtime download/update to avoid network dependency in production builds
|
// Removed runtime download/update to avoid network dependency in production builds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
src/main/local-api.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import http from 'node:http'
|
||||||
|
import type { AddressInfo } from 'node:net'
|
||||||
|
|
||||||
|
import log from 'electron-log/main'
|
||||||
|
|
||||||
|
import { downloadEngine } from './lib/download-engine'
|
||||||
|
|
||||||
|
const PORT_RANGE_START = 27100
|
||||||
|
const PORT_RANGE_END = 27120
|
||||||
|
const TOKEN_TTL_MS = 60_000
|
||||||
|
|
||||||
|
type TokenRecord = {
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: http.Server | null = null
|
||||||
|
let serverPort: number | null = null
|
||||||
|
const tokens = new Map<string, TokenRecord>()
|
||||||
|
|
||||||
|
const isLoopbackAddress = (address?: string | null): boolean => {
|
||||||
|
if (!address) return false
|
||||||
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeJson = (res: http.ServerResponse, status: number, body: unknown): void => {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type'
|
||||||
|
})
|
||||||
|
res.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeEmpty = (res: http.ServerResponse, status: number): void => {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type'
|
||||||
|
})
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueToken = (): string => {
|
||||||
|
const token = crypto.randomBytes(16).toString('hex')
|
||||||
|
tokens.set(token, { expiresAt: Date.now() + TOKEN_TTL_MS })
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumeToken = (token?: string | null): boolean => {
|
||||||
|
if (!token) return false
|
||||||
|
const record = tokens.get(token)
|
||||||
|
if (!record) return false
|
||||||
|
if (Date.now() > record.expiresAt) {
|
||||||
|
tokens.delete(token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tokens.delete(token)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequest = async (
|
||||||
|
req: http.IncomingMessage,
|
||||||
|
res: http.ServerResponse
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
||||||
|
writeJson(res, 403, { error: 'Forbidden' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
writeEmpty(res, 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.url) {
|
||||||
|
writeJson(res, 400, { error: 'Missing URL' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(req.url, 'http://127.0.0.1')
|
||||||
|
const pathname = requestUrl.pathname
|
||||||
|
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
writeJson(res, 405, { error: 'Method not allowed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/token') {
|
||||||
|
const token = issueToken()
|
||||||
|
writeJson(res, 200, { token, expiresInMs: TOKEN_TTL_MS })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/video-info') {
|
||||||
|
const token = requestUrl.searchParams.get('token')
|
||||||
|
if (!consumeToken(token)) {
|
||||||
|
writeJson(res, 401, { error: 'Invalid token' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = requestUrl.searchParams.get('url')
|
||||||
|
if (!targetUrl || !targetUrl.trim()) {
|
||||||
|
writeJson(res, 400, { error: 'Missing url' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await downloadEngine.getVideoInfo(targetUrl.trim())
|
||||||
|
writeJson(res, 200, {
|
||||||
|
title: info.title,
|
||||||
|
thumbnail: info.thumbnail,
|
||||||
|
duration: info.duration,
|
||||||
|
formats: info.formats ?? []
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||||
|
const details =
|
||||||
|
error instanceof Error
|
||||||
|
? error.stack
|
||||||
|
: typeof error === 'object' && error && 'stderr' in error
|
||||||
|
? String((error as { stderr?: unknown }).stderr ?? '')
|
||||||
|
: undefined
|
||||||
|
writeJson(res, 500, { error: message, details })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/status') {
|
||||||
|
writeJson(res, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(res, 404, { error: 'Not found' })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unhandled request error'
|
||||||
|
writeJson(res, 500, { error: message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startServerOnPort = (port: number): Promise<http.Server> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const httpServer = http.createServer((req, res) => {
|
||||||
|
void handleRequest(req, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
httpServer.once('error', (error) => {
|
||||||
|
httpServer.close()
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
httpServer.listen(port, '127.0.0.1', () => resolve(httpServer))
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function startExtensionApiServer(): Promise<number | null> {
|
||||||
|
if (server && serverPort) {
|
||||||
|
return serverPort
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port += 1) {
|
||||||
|
try {
|
||||||
|
server = await startServerOnPort(port)
|
||||||
|
const address = server.address() as AddressInfo | null
|
||||||
|
serverPort = address?.port ?? port
|
||||||
|
log.info(`Extension API listening on 127.0.0.1:${serverPort}`)
|
||||||
|
return serverPort
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException
|
||||||
|
if (err.code !== 'EADDRINUSE') {
|
||||||
|
log.warn('Extension API failed to start on port:', port, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error(`Extension API failed to bind any port in range ${PORT_RANGE_START}-${PORT_RANGE_END}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopExtensionApiServer(): Promise<void> {
|
||||||
|
if (!server) return
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server?.close(() => resolve())
|
||||||
|
})
|
||||||
|
|
||||||
|
server = null
|
||||||
|
serverPort = null
|
||||||
|
tokens.clear()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { AppSettings } from '../shared/types'
|
import type { AppSettings } from '../shared/types'
|
||||||
import { defaultSettings } from '../shared/types'
|
import { defaultSettings } from '../shared/types'
|
||||||
|
import { scopedLoggers } from './utils/logger'
|
||||||
|
|
||||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||||
const ElectronStore = require('electron-store')
|
const ElectronStore = require('electron-store')
|
||||||
@@ -14,7 +15,7 @@ const ensureDirectoryExists = (dir: string) => {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to ensure download directory:', error)
|
scopedLoggers.system.error('Failed to ensure download directory:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,11 @@ class SettingsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getAll(): AppSettings {
|
getAll(): AppSettings {
|
||||||
return this.store.store
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
downloadPath: DEFAULT_DOWNLOAD_PATH,
|
||||||
|
...this.store.store
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setAll(settings: Partial<AppSettings>): void {
|
setAll(settings: Partial<AppSettings>): void {
|
||||||
@@ -85,7 +90,7 @@ class SettingsManager {
|
|||||||
}
|
}
|
||||||
ensureDirectoryExists(normalizedDownloadPath)
|
ensureDirectoryExists(normalizedDownloadPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to verify download directory:', error)
|
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { ErrorBoundary } from './components/error/ErrorBoundary'
|
||||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||||
import { About } from './pages/About'
|
import { About } from './pages/About'
|
||||||
import { Home } from './pages/Home'
|
import { Home } from './pages/Home'
|
||||||
@@ -16,6 +17,7 @@ import { Settings } from './pages/Settings'
|
|||||||
import { Subscriptions } from './pages/Subscriptions'
|
import { Subscriptions } from './pages/Subscriptions'
|
||||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||||
|
import { updateAvailableAtom, updateReadyAtom } from './store/update'
|
||||||
|
|
||||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||||
|
|
||||||
@@ -51,7 +53,9 @@ function AppContent() {
|
|||||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
const { t } = useTranslation()
|
const setUpdateReady = useSetAtom(updateReadyAtom)
|
||||||
|
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||||
|
const { i18n } = useTranslation()
|
||||||
const updateDownloadInProgressRef = useRef(false)
|
const updateDownloadInProgressRef = useRef(false)
|
||||||
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -168,10 +172,39 @@ function AppContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRestartPrompt = () => {
|
const resetDownloadState = () => {
|
||||||
toast.info(t('about.notifications.restartToUpdate'), {
|
if (updateDownloadInProgressRef.current) {
|
||||||
|
updateDownloadInProgressRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateAvailable = (rawInfo: unknown) => {
|
||||||
|
const info = (rawInfo ?? {}) as { version?: string }
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
||||||
|
const info = (rawInfo ?? {}) as { version?: string }
|
||||||
|
resetDownloadState()
|
||||||
|
setUpdateReady({
|
||||||
|
ready: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: info.version
|
||||||
|
})
|
||||||
|
|
||||||
|
const versionLabel = info?.version ?? ''
|
||||||
|
const downloadedMessage = versionLabel
|
||||||
|
? i18n.t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||||
|
: i18n.t('about.notifications.updateDownloaded')
|
||||||
|
toast.info(downloadedMessage, {
|
||||||
action: {
|
action: {
|
||||||
label: t('about.notifications.restartNowAction'),
|
label: i18n.t('about.notifications.restartNowAction'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
void ipcServices.update.quitAndInstall()
|
void ipcServices.update.quitAndInstall()
|
||||||
}
|
}
|
||||||
@@ -179,31 +212,12 @@ function AppContent() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDownloadState = () => {
|
|
||||||
if (updateDownloadInProgressRef.current) {
|
|
||||||
updateDownloadInProgressRef.current = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 handleUpdateError = (rawMessage: unknown) => {
|
||||||
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
const message = typeof rawMessage === 'string' ? rawMessage : ''
|
||||||
resetDownloadState()
|
resetDownloadState()
|
||||||
|
|
||||||
const errorMessage = message || t('about.notifications.unknownErrorFallback')
|
const errorMessage = message || i18n.t('about.notifications.unknownErrorFallback')
|
||||||
toast.error(t('about.notifications.updateError', { error: errorMessage }))
|
toast.error(i18n.t('about.notifications.updateError', { error: errorMessage }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadProgress = (rawProgress: unknown) => {
|
const handleDownloadProgress = (rawProgress: unknown) => {
|
||||||
@@ -213,37 +227,20 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only listen to update events that should be shown globally
|
// Only listen to update events that should be shown globally
|
||||||
// update:available is handled in About page only
|
// update:available shows a visual indicator in the sidebar
|
||||||
|
ipcEvents.on('update:available', handleUpdateAvailable)
|
||||||
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
|
||||||
ipcEvents.on('update:error', handleUpdateError)
|
ipcEvents.on('update:error', handleUpdateError)
|
||||||
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
ipcEvents.on('update:download-progress', handleDownloadProgress)
|
||||||
ipcEvents.on('update:show-notification', handleUpdateNotification)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
ipcEvents.removeListener('update:available', handleUpdateAvailable)
|
||||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||||
ipcEvents.removeListener('update:error', handleUpdateError)
|
ipcEvents.removeListener('update:error', handleUpdateError)
|
||||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [i18n, setUpdateAvailable, setUpdateReady])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-screen">
|
<div className="flex flex-row h-screen">
|
||||||
@@ -290,11 +287,13 @@ function AppContent() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ErrorBoundary>
|
||||||
<HashRouter>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
<AppContent />
|
<HashRouter>
|
||||||
</HashRouter>
|
<AppContent />
|
||||||
</ThemeProvider>
|
</HashRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
|
|
||||||
import { popularSites } from '@renderer/data/popularSites'
|
import { popularSites } from '@renderer/data/popularSites'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
|
||||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
@@ -77,11 +78,11 @@ const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
|
|||||||
|
|
||||||
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
|
||||||
if (preset === 'worst') {
|
if (preset === 'worst') {
|
||||||
return ['worstaudio']
|
return dedupe(['worstaudio', 'bestaudio'])
|
||||||
}
|
}
|
||||||
|
|
||||||
const abrLimit = qualityPresetToAudioAbr[preset]
|
const abrLimit = qualityPresetToAudioAbr[preset]
|
||||||
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
|
// Prefer audio-only selectors so video+audio merges remain valid.
|
||||||
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,8 +90,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
|
|||||||
const preset = getQualityPreset(settings)
|
const preset = getQualityPreset(settings)
|
||||||
|
|
||||||
if (preset === 'worst') {
|
if (preset === 'worst') {
|
||||||
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
|
// Prefer separate streams, then fall back to single-file selectors.
|
||||||
return 'worstvideo+worstaudio'
|
return 'worstvideo+worstaudio/worst/best'
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxHeight = qualityPresetToVideoHeight[preset]
|
const maxHeight = qualityPresetToVideoHeight[preset]
|
||||||
@@ -113,16 +114,18 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
|
|||||||
combinations.push(video)
|
combinations.push(video)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
|
// Prefer merged formats, then allow 'best' as a compatibility fallback.
|
||||||
combinations.push('bestvideo+bestaudio')
|
combinations.push('bestvideo+bestaudio')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
combinations.push('best')
|
||||||
|
|
||||||
return dedupe(combinations).join('/')
|
return dedupe(combinations).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildAudioFormatPreference = (settings: AppSettings): string => {
|
const buildAudioFormatPreference = (settings: AppSettings): string => {
|
||||||
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
const selectors = buildAudioSelectors(getQualityPreset(settings))
|
||||||
return selectors.join('/')
|
return dedupe([...selectors, 'best']).join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadDialogProps {
|
interface DownloadDialogProps {
|
||||||
@@ -787,11 +790,12 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
type,
|
type,
|
||||||
format:
|
format:
|
||||||
type === 'video'
|
type === 'video'
|
||||||
? videoInfoCardState.selectedVideoFormat
|
? videoInfoCardState.selectedVideoFormat || undefined
|
||||||
: type === 'extract'
|
: type === 'extract'
|
||||||
? undefined
|
? undefined
|
||||||
: videoInfoCardState.selectedAudioFormat,
|
: videoInfoCardState.selectedAudioFormat || undefined,
|
||||||
audioFormat: type === 'video' ? videoInfoCardState.selectedAudioForVideo : undefined,
|
audioFormat:
|
||||||
|
type === 'video' ? videoInfoCardState.selectedAudioForVideo || undefined : undefined,
|
||||||
extractFormat:
|
extractFormat:
|
||||||
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
|
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
|
||||||
extractQuality:
|
extractQuality:
|
||||||
@@ -934,29 +938,33 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
{t('sites.viewAll')}
|
{t('sites.viewAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.oneClickDownload && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-primary">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
|
|
||||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs h-auto"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
onOpenSettings()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('download.goToSettings')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* One-click download indicator */}
|
||||||
|
{settings.oneClickDownload && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-2.5">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||||
|
{onOpenSettings && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-auto"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onOpenSettings()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('download.goToSettings')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
||||||
@@ -1042,23 +1050,41 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advanced Options Content - Single Video */}
|
{/* Advanced Options Content - Single Video */}
|
||||||
{videoInfo && !loading && singleVideoAdvancedOptionsOpen && (
|
{videoInfo && !loading && (
|
||||||
<div className="w-full pt-4 mt-4 border-t">
|
<div
|
||||||
<AdvancedOptions
|
data-state={singleVideoAdvancedOptionsOpen ? 'open' : 'closed'}
|
||||||
startTime={videoInfoCardState.startTime}
|
className={cn(
|
||||||
endTime={videoInfoCardState.endTime}
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
downloadSubs={videoInfoCardState.downloadSubs}
|
singleVideoAdvancedOptionsOpen
|
||||||
onStartTimeChange={(value) =>
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, startTime: value }))
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
}
|
)}
|
||||||
onEndTimeChange={(value) =>
|
aria-hidden={!singleVideoAdvancedOptionsOpen}
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, endTime: value }))
|
>
|
||||||
}
|
<div
|
||||||
onDownloadSubsChange={(value) =>
|
className={cn(
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value }))
|
'min-h-0',
|
||||||
}
|
!singleVideoAdvancedOptionsOpen && 'pointer-events-none'
|
||||||
showAccordion={false}
|
)}
|
||||||
/>
|
>
|
||||||
|
<div className="w-full pt-4 mt-4 border-t">
|
||||||
|
<AdvancedOptions
|
||||||
|
startTime={videoInfoCardState.startTime}
|
||||||
|
endTime={videoInfoCardState.endTime}
|
||||||
|
downloadSubs={videoInfoCardState.downloadSubs}
|
||||||
|
onStartTimeChange={(value) =>
|
||||||
|
setVideoInfoCardState((prev) => ({ ...prev, startTime: value }))
|
||||||
|
}
|
||||||
|
onEndTimeChange={(value) =>
|
||||||
|
setVideoInfoCardState((prev) => ({ ...prev, endTime: value }))
|
||||||
|
}
|
||||||
|
onDownloadSubsChange={(value) =>
|
||||||
|
setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value }))
|
||||||
|
}
|
||||||
|
showAccordion={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,51 +1217,62 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advanced Options Content - Playlist */}
|
{/* Advanced Options Content - Playlist */}
|
||||||
{advancedOptionsOpen && (
|
<div
|
||||||
<div className="w-full pt-4 mt-4 border-t">
|
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||||
<div className="space-y-4">
|
className={cn(
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
<div className="space-y-2">
|
advancedOptionsOpen
|
||||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
<Select
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
value={downloadType}
|
)}
|
||||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
aria-hidden={!advancedOptionsOpen}
|
||||||
disabled={playlistBusy}
|
>
|
||||||
>
|
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||||
<SelectTrigger id={downloadTypeId}>
|
<div className="w-full pt-4 mt-4 border-t">
|
||||||
<SelectValue />
|
<div className="space-y-4">
|
||||||
</SelectTrigger>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<SelectContent>
|
<div className="space-y-2">
|
||||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
<Select
|
||||||
</SelectContent>
|
value={downloadType}
|
||||||
</Select>
|
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||||
</div>
|
disabled={playlistBusy}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={downloadTypeId}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||||
|
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('playlist.range')}</Label>
|
<Label>{t('playlist.range')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
value={startIndex}
|
value={startIndex}
|
||||||
onChange={(e) => setStartIndex(e.target.value)}
|
onChange={(e) => setStartIndex(e.target.value)}
|
||||||
className="text-center"
|
className="text-center"
|
||||||
disabled={playlistBusy}
|
disabled={playlistBusy}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs">-</span>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||||
value={endIndex}
|
value={endIndex}
|
||||||
onChange={(e) => setEndIndex(e.target.value)}
|
onChange={(e) => setEndIndex(e.target.value)}
|
||||||
className="text-center"
|
className="text-center"
|
||||||
disabled={playlistBusy}
|
disabled={playlistBusy}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -400,6 +400,10 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
|||||||
|
|
||||||
const statusIcon = getStatusIcon()
|
const statusIcon = getStatusIcon()
|
||||||
const statusText = getStatusText()
|
const statusText = getStatusText()
|
||||||
|
const progressInfo = download.progress
|
||||||
|
const showInlineProgress = Boolean(
|
||||||
|
progressInfo && download.status !== 'completed' && download.status !== 'error'
|
||||||
|
)
|
||||||
const sourceDisplay =
|
const sourceDisplay =
|
||||||
download.uploader && download.channel && download.uploader !== download.channel
|
download.uploader && download.channel && download.uploader !== download.channel
|
||||||
? `${download.uploader} • ${download.channel}`
|
? `${download.uploader} • ${download.channel}`
|
||||||
@@ -725,6 +729,24 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{showInlineProgress && (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-medium shrink-0">
|
||||||
|
{(progressInfo?.percent ?? 0).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{progressInfo?.downloaded && progressInfo?.total && (
|
||||||
|
<span className="truncate max-w-[120px]">
|
||||||
|
{progressInfo.downloaded} / {progressInfo.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{progressInfo?.currentSpeed && (
|
||||||
|
<span className="truncate max-w-[80px]">{progressInfo.currentSpeed}</span>
|
||||||
|
)}
|
||||||
|
{progressInfo?.eta && (
|
||||||
|
<span className="truncate max-w-[80px]">ETA: {progressInfo.eta}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Timestamp */}
|
{/* Timestamp */}
|
||||||
{timestamp && (
|
{timestamp && (
|
||||||
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
||||||
@@ -898,26 +920,8 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
|||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||||
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
|
<div className="bg-background/60 w-full overflow-hidden">
|
||||||
<Progress value={download.progress.percent} className="h-1 w-full" />
|
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
|
|
||||||
<span className="font-medium shrink-0">
|
|
||||||
{download.progress.percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
|
|
||||||
{download.progress.downloaded && download.progress.total && (
|
|
||||||
<span className="truncate max-w-[100px]">
|
|
||||||
{download.progress.downloaded} / {download.progress.total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{download.progress.currentSpeed && (
|
|
||||||
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
|
|
||||||
)}
|
|
||||||
{download.progress.eta && (
|
|
||||||
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
152
src/renderer/src/components/error/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { logger } from '@renderer/lib/logger'
|
||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
import { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
||||||
|
fallback?: (errorInfo: ErrorInfoType) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
errorInfo: ErrorInfoType | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
errorInfo: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
const errorInfo = {
|
||||||
|
error,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
context: {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: navigator.platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log error details immediately
|
||||||
|
logger.error('ErrorBoundary: getDerivedStateFromError called', {
|
||||||
|
errorName: error.name,
|
||||||
|
errorMessage: error.message,
|
||||||
|
errorStack: error.stack,
|
||||||
|
url: errorInfo.context.url,
|
||||||
|
timestamp: errorInfo.timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
errorInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise<void> {
|
||||||
|
logger.error('ErrorBoundary caught an error:', {
|
||||||
|
errorName: error.name,
|
||||||
|
errorMessage: error.message,
|
||||||
|
errorStack: error.stack,
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
errorInfo: JSON.stringify(errorInfo, null, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get app version if available
|
||||||
|
let appVersion: string | undefined
|
||||||
|
try {
|
||||||
|
if (window?.api && ipcServices?.app) {
|
||||||
|
appVersion = await ipcServices.app.getVersion()
|
||||||
|
logger.info('ErrorBoundary: App version retrieved', { appVersion })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to get app version:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state with component stack and version
|
||||||
|
if (this.state.errorInfo) {
|
||||||
|
this.setState({
|
||||||
|
errorInfo: {
|
||||||
|
...this.state.errorInfo,
|
||||||
|
context: {
|
||||||
|
...this.state.errorInfo.context,
|
||||||
|
version: appVersion
|
||||||
|
},
|
||||||
|
errorInfo: {
|
||||||
|
componentStack: errorInfo.componentStack || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call optional error handler
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send error to main process if available
|
||||||
|
if (window?.api) {
|
||||||
|
try {
|
||||||
|
window.api.send('error:renderer', {
|
||||||
|
error: {
|
||||||
|
name: error.name,
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
},
|
||||||
|
errorInfo: {
|
||||||
|
componentStack: errorInfo.componentStack
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
context: {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: navigator.platform,
|
||||||
|
version: appVersion
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to send error to main process:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = (): void => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
errorInfo: null
|
||||||
|
})
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGoHome = (): void => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
errorInfo: null
|
||||||
|
})
|
||||||
|
window.location.hash = '/'
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError && this.state.errorInfo) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback(this.state.errorInfo)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ErrorPage
|
||||||
|
errorInfo={this.state.errorInfo}
|
||||||
|
onReload={this.handleReload}
|
||||||
|
onGoHome={this.handleGoHome}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/renderer/src/components/error/ErrorPage.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
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 { Textarea } from '@renderer/components/ui/textarea'
|
||||||
|
import { logger } from '@renderer/lib/logger'
|
||||||
|
import { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export interface ErrorInfo {
|
||||||
|
error: Error
|
||||||
|
errorInfo?: {
|
||||||
|
componentStack?: string
|
||||||
|
}
|
||||||
|
timestamp: number
|
||||||
|
context?: {
|
||||||
|
url?: string
|
||||||
|
userAgent?: string
|
||||||
|
platform?: string
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorPageProps {
|
||||||
|
errorInfo: ErrorInfo
|
||||||
|
onReload?: () => void
|
||||||
|
onGoHome?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const errorReport = generateErrorReport(errorInfo)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(errorReport)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success(t('error.copySuccess'))
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to copy error report:', error)
|
||||||
|
toast.error(t('error.copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
if (onReload) {
|
||||||
|
onReload()
|
||||||
|
} else {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-background p-4">
|
||||||
|
<Card className="w-full max-w-3xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-2xl">{t('error.title')}</CardTitle>
|
||||||
|
<CardDescription className="mt-2">{t('error.description')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Error Message */}
|
||||||
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||||
|
<p className="text-sm font-medium text-destructive mb-1">{t('error.message')}</p>
|
||||||
|
<p className="text-sm text-foreground break-words">
|
||||||
|
{errorInfo.error.message || t('error.unknownError')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{onGoHome && (
|
||||||
|
<Button variant="outline" onClick={onGoHome}>
|
||||||
|
<Home className="h-4 w-4 mr-2" />
|
||||||
|
{t('error.goHome')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" onClick={handleReload}>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
{t('error.reload')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCopy}>
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
{copied ? t('error.copied') : t('error.copyReport')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setShowDetails(!showDetails)}>
|
||||||
|
{showDetails ? t('error.hideDetails') : t('error.showDetails')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Details */}
|
||||||
|
{showDetails && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">{t('error.stackTrace')}</p>
|
||||||
|
<ScrollArea className="h-48 rounded-md border bg-muted/50 p-4">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||||
|
{errorInfo.error.stack || t('error.noStackTrace')}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorInfo.errorInfo?.componentStack && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">{t('error.componentStack')}</p>
|
||||||
|
<ScrollArea className="h-32 rounded-md border bg-muted/50 p-4">
|
||||||
|
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||||
|
{errorInfo.errorInfo.componentStack}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium mb-2">{t('error.fullReport')}</p>
|
||||||
|
<Textarea
|
||||||
|
readOnly
|
||||||
|
value={errorReport}
|
||||||
|
className="font-mono text-xs min-h-48"
|
||||||
|
onClick={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
target.select()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div className="rounded-md bg-muted/50 border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('error.helpText')}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateErrorReport(errorInfo: ErrorInfo): string {
|
||||||
|
const lines: string[] = []
|
||||||
|
|
||||||
|
lines.push('=== VidBee Error Report ===')
|
||||||
|
lines.push(`Timestamp: ${new Date(errorInfo.timestamp).toISOString()}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
if (errorInfo.context) {
|
||||||
|
lines.push('--- Context ---')
|
||||||
|
if (errorInfo.context.version) {
|
||||||
|
lines.push(`App Version: ${errorInfo.context.version}`)
|
||||||
|
}
|
||||||
|
if (errorInfo.context.platform) {
|
||||||
|
lines.push(`Platform: ${errorInfo.context.platform}`)
|
||||||
|
}
|
||||||
|
if (errorInfo.context.url) {
|
||||||
|
lines.push(`URL: ${errorInfo.context.url}`)
|
||||||
|
}
|
||||||
|
if (errorInfo.context.userAgent) {
|
||||||
|
lines.push(`User Agent: ${errorInfo.context.userAgent}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('--- Error ---')
|
||||||
|
lines.push(`Name: ${errorInfo.error.name}`)
|
||||||
|
lines.push(`Message: ${errorInfo.error.message}`)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
if (errorInfo.error.stack) {
|
||||||
|
lines.push('--- Stack Trace ---')
|
||||||
|
lines.push(errorInfo.error.stack)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorInfo.errorInfo?.componentStack) {
|
||||||
|
lines.push('--- Component Stack ---')
|
||||||
|
lines.push(errorInfo.errorInfo.componentStack)
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('=== End of Report ===')
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger
|
|
||||||
} from '@renderer/components/ui/accordion'
|
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +12,7 @@ import { Input } from '@renderer/components/ui/input'
|
|||||||
import { Label } from '@renderer/components/ui/label'
|
import { Label } from '@renderer/components/ui/label'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { ipcServices } from '@renderer/lib/ipc'
|
import { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
import { settingsAtom } from '@renderer/store/settings'
|
import { settingsAtom } from '@renderer/store/settings'
|
||||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
||||||
@@ -85,6 +81,8 @@ export function SubscriptionFormDialog({
|
|||||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||||
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||||
const urlInputId = useId()
|
const urlInputId = useId()
|
||||||
|
const advancedOptionsId = useId()
|
||||||
|
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||||
|
|
||||||
// Initialize form values based on mode
|
// Initialize form values based on mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,6 +90,8 @@ export function SubscriptionFormDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAdvancedOptionsOpen(false)
|
||||||
|
|
||||||
if (mode === 'edit' && subscription) {
|
if (mode === 'edit' && subscription) {
|
||||||
setUrl(subscription.feedUrl)
|
setUrl(subscription.feedUrl)
|
||||||
setKeywords(subscription.keywords.join(', '))
|
setKeywords(subscription.keywords.join(', '))
|
||||||
@@ -274,10 +274,16 @@ export function SubscriptionFormDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Accordion type="single" collapsible>
|
<div
|
||||||
<AccordionItem value="advanced">
|
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
className={cn(
|
||||||
<AccordionContent className="space-y-3">
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
|
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||||
|
)}
|
||||||
|
aria-hidden={!advancedOptionsOpen}
|
||||||
|
>
|
||||||
|
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||||
|
<div className="space-y-3 border-t pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||||
@@ -299,17 +305,31 @@ export function SubscriptionFormDialog({
|
|||||||
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</div>
|
||||||
</AccordionItem>
|
</div>
|
||||||
</Accordion>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{mode === 'add' && (
|
<div className="flex items-center justify-between w-full gap-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<div className="flex items-center gap-2">
|
||||||
{t('download.cancel')}
|
<Checkbox
|
||||||
</Button>
|
id={advancedOptionsId}
|
||||||
)}
|
checked={advancedOptionsOpen}
|
||||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
|
||||||
|
{t('advancedOptions.title')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
{mode === 'add' && (
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('download.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
39
src/renderer/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||||
@@ -67,6 +67,8 @@ interface RemoteImageProps {
|
|||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
const IMAGE_LOAD_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
export function RemoteImage({
|
export function RemoteImage({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@@ -91,8 +93,10 @@ export function RemoteImage({
|
|||||||
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
|
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
|
||||||
|
|
||||||
const [isImageLoading, setIsImageLoading] = useState(true)
|
const [isImageLoading, setIsImageLoading] = useState(true)
|
||||||
|
const [timedOutSrc, setTimedOutSrc] = useState<string | null>(null)
|
||||||
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
|
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
|
||||||
const isLoading = isCacheLoading || isImageLoading
|
const hasTimedOut = Boolean(src) && timedOutSrc === src
|
||||||
|
const isLoading = !hasTimedOut && (isCacheLoading || isImageLoading)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageSrc) {
|
if (imageSrc) {
|
||||||
@@ -102,6 +106,19 @@ export function RemoteImage({
|
|||||||
}
|
}
|
||||||
}, [imageSrc])
|
}, [imageSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src || hasTimedOut || !isLoading) return
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setTimedOutSrc(src)
|
||||||
|
setIsImageLoading(false)
|
||||||
|
}, IMAGE_LOAD_TIMEOUT_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [src, hasTimedOut, isLoading])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onLoadingChange?.(isLoading)
|
onLoadingChange?.(isLoading)
|
||||||
}, [isLoading, onLoadingChange])
|
}, [isLoading, onLoadingChange])
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@renderer/components/ui/dropdown-menu'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||||
import { saveSettingAtom } from '@renderer/store/settings'
|
import { useAtom } from 'jotai'
|
||||||
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import '../../assets/title-bar.css'
|
import '../../assets/title-bar.css'
|
||||||
|
import { updateAvailableAtom } from '@renderer/store/update'
|
||||||
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
|
||||||
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
|
||||||
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
|
||||||
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
||||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
|
||||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||||
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||||
@@ -53,10 +44,8 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const saveSetting = useSetAtom(saveSettingAtom)
|
const [updateAvailable] = useAtom(updateAvailableAtom)
|
||||||
|
|
||||||
const languageOptions = languageList
|
|
||||||
|
|
||||||
const navigationItems: NavigationItem[] = [
|
const navigationItems: NavigationItem[] = [
|
||||||
{
|
{
|
||||||
@@ -105,20 +94,6 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const activeLanguageCode = normalizeLanguageCode(i18n.language)
|
|
||||||
const currentLanguage =
|
|
||||||
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
|
||||||
|
|
||||||
const handleLanguageChange = async (value: LanguageCode) => {
|
|
||||||
if (activeLanguageCode === value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveSetting({ key: 'language', value })
|
|
||||||
await i18n.changeLanguage(value)
|
|
||||||
toast.success(t('notifications.settingsSaved'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
|
||||||
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
const isActive = item.id !== 'supported-sites' && currentPage === item.id
|
||||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||||
@@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
{/* Language Selector */}
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<DropdownMenu>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
|
|
||||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>{t('settings.language')}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenuContent side="right" align="end">
|
|
||||||
{languageOptions.map((option) => {
|
|
||||||
const isActive = option.value === currentLanguage.value
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => void handleLanguageChange(option.value)}
|
|
||||||
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
|
|
||||||
aria-current={isActive}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Navigation Items */}
|
{/* Bottom Navigation Items */}
|
||||||
{bottomNavigationItems.map((item) => {
|
{bottomNavigationItems.map((item) => {
|
||||||
const isActive = currentPage === item.id
|
const isActive = currentPage === item.id
|
||||||
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
const IconComponent = isActive ? item.icon.active : item.icon.inactive
|
||||||
|
const showUpdateDot = item.id === 'about' && updateAvailable.available
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||||
@@ -211,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onPageChange(item.id)}
|
onClick={() => onPageChange(item.id)}
|
||||||
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
className={`no-drag rounded-2xl w-12 h-12 relative ${
|
||||||
|
isActive ? 'bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||||
|
{showUpdateDot ? (
|
||||||
|
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
|
|||||||
@@ -73,23 +73,31 @@ export function FormatSelector({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Filter and sort formats
|
// Filter and sort formats
|
||||||
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
|
||||||
const videos = formats.filter(
|
const isVideoFormat = (format: VideoFormat) =>
|
||||||
(f) =>
|
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||||
f.video_ext !== 'none' &&
|
const isAudioFormat = (format: VideoFormat) =>
|
||||||
f.vcodec &&
|
format.acodec &&
|
||||||
f.vcodec !== 'none' &&
|
format.acodec !== 'none' &&
|
||||||
f.protocol !== 'm3u8' &&
|
(format.video_ext === 'none' || !format.video_ext)
|
||||||
f.protocol !== 'm3u8_native'
|
const isHlsFormat = (format: VideoFormat) =>
|
||||||
|
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||||
|
|
||||||
|
const videoCandidates = formats.filter(
|
||||||
|
(format) => isVideoFormat(format) && !isHlsFormat(format)
|
||||||
)
|
)
|
||||||
const audios = formats.filter(
|
const audioCandidates = formats.filter(
|
||||||
(f) =>
|
(format) => isAudioFormat(format) && !isHlsFormat(format)
|
||||||
f.acodec &&
|
|
||||||
f.acodec !== 'none' &&
|
|
||||||
(f.video_ext === 'none' || !f.video_ext) &&
|
|
||||||
f.protocol !== 'm3u8' &&
|
|
||||||
f.protocol !== 'm3u8_native'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const videos =
|
||||||
|
videoCandidates.length > 0
|
||||||
|
? videoCandidates
|
||||||
|
: formats.filter((format) => isVideoFormat(format))
|
||||||
|
const audios =
|
||||||
|
audioCandidates.length > 0
|
||||||
|
? audioCandidates
|
||||||
|
: formats.filter((format) => isAudioFormat(format))
|
||||||
|
|
||||||
// Apply showMoreFormats filter
|
// Apply showMoreFormats filter
|
||||||
const filteredVideos = settings.showMoreFormats
|
const filteredVideos = settings.showMoreFormats
|
||||||
? videos
|
? videos
|
||||||
@@ -99,6 +107,9 @@ export function FormatSelector({
|
|||||||
? audios
|
? audios
|
||||||
: audios.filter((f) => f.ext !== 'webm')
|
: audios.filter((f) => f.ext !== 'webm')
|
||||||
|
|
||||||
|
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
|
||||||
|
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
|
||||||
|
|
||||||
// Sort formats by quality (best first)
|
// Sort formats by quality (best first)
|
||||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||||
// Sort by height (higher is better)
|
// Sort by height (higher is better)
|
||||||
@@ -138,23 +149,34 @@ export function FormatSelector({
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredVideos.sort(sortVideoFormatsByQuality)
|
finalVideos.sort(sortVideoFormatsByQuality)
|
||||||
filteredAudios.sort(sortAudioFormatsByQuality)
|
finalAudios.sort(sortAudioFormatsByQuality)
|
||||||
|
|
||||||
setVideoFormats(filteredVideos)
|
setVideoFormats(finalVideos)
|
||||||
setAudioFormats(filteredAudios)
|
setAudioFormats(finalAudios)
|
||||||
|
|
||||||
// Auto-select best format based on preferences
|
// Auto-select best format based on preferences.
|
||||||
if (filteredVideos.length > 0 && !selectedVideo) {
|
// If no separate audio formats exist, prefer muxed video formats (with audio).
|
||||||
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
|
const videosWithAudio = finalVideos.filter(
|
||||||
|
(format) => format.acodec && format.acodec !== 'none'
|
||||||
|
)
|
||||||
|
const autoVideos =
|
||||||
|
finalAudios.length > 0
|
||||||
|
? finalVideos
|
||||||
|
: videosWithAudio.length > 0
|
||||||
|
? videosWithAudio
|
||||||
|
: finalVideos
|
||||||
|
|
||||||
|
if (autoVideos.length > 0 && !selectedVideo) {
|
||||||
|
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
|
||||||
if (preferred) {
|
if (preferred) {
|
||||||
setSelectedVideo(preferred.format_id)
|
setSelectedVideo(preferred.format_id)
|
||||||
onVideoFormatChange?.(preferred.format_id)
|
onVideoFormatChange?.(preferred.format_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredAudios.length > 0 && !selectedAudio) {
|
if (finalAudios.length > 0 && !selectedAudio) {
|
||||||
const best = filteredAudios[0]
|
const best = finalAudios[0]
|
||||||
setSelectedAudio(best.format_id)
|
setSelectedAudio(best.format_id)
|
||||||
onAudioFormatChange?.(best.format_id)
|
onAudioFormatChange?.(best.format_id)
|
||||||
}
|
}
|
||||||
@@ -215,67 +237,79 @@ export function FormatSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'video') {
|
if (type === 'video') {
|
||||||
|
if (videoFormats.length === 0 && audioFormats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-3">
|
{videoFormats.length > 0 && (
|
||||||
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
|
||||||
value={selectedVideo}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={selectedVideo}
|
||||||
setSelectedVideo(value)
|
onValueChange={(value) => {
|
||||||
onVideoFormatChange?.(value)
|
setSelectedVideo(value)
|
||||||
}}
|
onVideoFormatChange?.(value)
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-11">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent className="max-h-[300px] p-1.5">
|
</SelectTrigger>
|
||||||
{videoFormats.map((format) => (
|
<SelectContent>
|
||||||
<SelectItem
|
{videoFormats.map((format) => (
|
||||||
key={format.format_id}
|
<SelectItem
|
||||||
value={format.format_id}
|
key={format.format_id}
|
||||||
className="cursor-pointer py-2.5"
|
value={format.format_id}
|
||||||
>
|
className="cursor-pointer"
|
||||||
<span className="text-sm">{formatVideoLabel(format)}</span>
|
>
|
||||||
</SelectItem>
|
<span>{formatVideoLabel(format)}</span>
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
{audioFormats.length > 0 && (
|
||||||
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
|
||||||
value={selectedAudio}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={selectedAudio}
|
||||||
setSelectedAudio(value)
|
onValueChange={(value) => {
|
||||||
onAudioFormatChange?.(value)
|
setSelectedAudio(value)
|
||||||
}}
|
onAudioFormatChange?.(value)
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-11">
|
>
|
||||||
<SelectValue />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent className="max-h-[300px] p-1.5">
|
</SelectTrigger>
|
||||||
<SelectItem value="none" className="cursor-pointer py-2.5">
|
<SelectContent>
|
||||||
<span className="text-sm">{t('download.noAudio')}</span>
|
<SelectItem value="none" className="cursor-pointer">
|
||||||
</SelectItem>
|
<span>{t('download.noAudio')}</span>
|
||||||
{audioFormats.map((format) => (
|
|
||||||
<SelectItem
|
|
||||||
key={format.format_id}
|
|
||||||
value={format.format_id}
|
|
||||||
className="cursor-pointer py-2.5"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
{audioFormats.map((format) => (
|
||||||
</SelectContent>
|
<SelectItem
|
||||||
</Select>
|
key={format.format_id}
|
||||||
</div>
|
value={format.format_id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{formatAudioLabel(format)}</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio only
|
// Audio only
|
||||||
|
if (audioFormats.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>
|
||||||
@@ -286,17 +320,13 @@ export function FormatSelector({
|
|||||||
onAudioFormatChange?.(value)
|
onAudioFormatChange?.(value)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-11">
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="max-h-[300px] p-1.5">
|
<SelectContent>
|
||||||
{audioFormats.map((format) => (
|
{audioFormats.map((format) => (
|
||||||
<SelectItem
|
<SelectItem key={format.format_id} value={format.format_id} className="cursor-pointer">
|
||||||
key={format.format_id}
|
<span>{formatAudioLabel(format)}</span>
|
||||||
value={format.format_id}
|
|
||||||
className="cursor-pointer py-2.5"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{formatAudioLabel(format)}</span>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
20
src/renderer/src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Renderer process logger utility
|
||||||
|
* Use electron-log/renderer which automatically forwards logs to main process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import log from 'electron-log/renderer'
|
||||||
|
|
||||||
|
// Export electron-log instance
|
||||||
|
export default log
|
||||||
|
|
||||||
|
// Export commonly used logging methods
|
||||||
|
export const logger = log
|
||||||
|
|
||||||
|
// Predefined scoped loggers
|
||||||
|
export const scopedLoggers = {
|
||||||
|
renderer: log.scope('renderer'),
|
||||||
|
error: log.scope('error'),
|
||||||
|
component: log.scope('component'),
|
||||||
|
api: log.scope('api')
|
||||||
|
}
|
||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
|
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
|
||||||
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
|
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
|
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
|
||||||
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
|
|
||||||
},
|
},
|
||||||
"system": "النظام",
|
"system": "النظام",
|
||||||
"theme": "المظهر",
|
"theme": "المظهر",
|
||||||
@@ -393,7 +392,6 @@
|
|||||||
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
|
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
|
||||||
"downloadDirectory": "مجلد التحميل",
|
"downloadDirectory": "مجلد التحميل",
|
||||||
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
|
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
|
||||||
"checkInterval": "فترة الفحص (ساعات)",
|
|
||||||
"onlyLatest": "تحميل أحدث فيديو فقط",
|
"onlyLatest": "تحميل أحدث فيديو فقط",
|
||||||
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
|
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Mehr Formatoptionen anzeigen",
|
"showMoreFormats": "Mehr Formatoptionen anzeigen",
|
||||||
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
|
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.",
|
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt."
|
||||||
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
|
|
||||||
},
|
},
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"theme": "Design",
|
"theme": "Design",
|
||||||
@@ -393,7 +392,6 @@
|
|||||||
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
|
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
|
||||||
"downloadDirectory": "Download-Verzeichnis",
|
"downloadDirectory": "Download-Verzeichnis",
|
||||||
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
|
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
|
||||||
"checkInterval": "Prüfintervall (Stunden)",
|
|
||||||
"onlyLatest": "Nur das neueste Video herunterladen",
|
"onlyLatest": "Nur das neueste Video herunterladen",
|
||||||
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
|
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"autoUpdateTitle": "Auto updates",
|
"autoUpdateTitle": "Auto updates",
|
||||||
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
||||||
"betaProgramTitle": "Preview channel",
|
"betaProgramTitle": "Preview channel",
|
||||||
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
|
"description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
|
||||||
"followAuthorActions": {
|
"followAuthorActions": {
|
||||||
"follow": "Follow @nexmoex"
|
"follow": "Follow @nexmoex"
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,13 @@
|
|||||||
"documentation": "Help center",
|
"documentation": "Help center",
|
||||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||||
"feedback": "Feedback & issues",
|
"feedback": "Feedback & issues",
|
||||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
|
||||||
|
"githubIssues": "GitHub",
|
||||||
|
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||||
|
"xFeedback": "Twitter",
|
||||||
|
"xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.",
|
||||||
|
"discord": "Discord",
|
||||||
|
"discordDescription": "Join our Discord community for discussions and support.",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"licenseDescription": "Review the open-source license terms.",
|
"licenseDescription": "Review the open-source license terms.",
|
||||||
"website": "Official website",
|
"website": "Official website",
|
||||||
@@ -83,6 +89,7 @@
|
|||||||
"currentLocation": "Current download location - ",
|
"currentLocation": "Current download location - ",
|
||||||
"downloadLocation": "Download location",
|
"downloadLocation": "Download location",
|
||||||
"downloadSubs": "Download subtitles if available",
|
"downloadSubs": "Download subtitles if available",
|
||||||
|
"downloadSubsHint": "Save subtitles as separate files when available",
|
||||||
"end": "End",
|
"end": "End",
|
||||||
"endHint": "If kept empty, it will be downloaded to the end",
|
"endHint": "If kept empty, it will be downloaded to the end",
|
||||||
"endPlaceholder": "10:00",
|
"endPlaceholder": "10:00",
|
||||||
@@ -200,6 +207,25 @@
|
|||||||
"subscription": "Subscription"
|
"subscription": "Subscription"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"error": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "An unexpected error occurred. Please try reloading the application or report this issue if it persists.",
|
||||||
|
"message": "Error Message",
|
||||||
|
"unknownError": "Unknown error occurred",
|
||||||
|
"goHome": "Go Home",
|
||||||
|
"reload": "Reload App",
|
||||||
|
"copyReport": "Copy Error Report",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"copySuccess": "Error report copied to clipboard",
|
||||||
|
"copyFailed": "Failed to copy error report",
|
||||||
|
"showDetails": "Show Details",
|
||||||
|
"hideDetails": "Hide Details",
|
||||||
|
"stackTrace": "Stack Trace",
|
||||||
|
"componentStack": "Component Stack",
|
||||||
|
"noStackTrace": "No stack trace available",
|
||||||
|
"fullReport": "Full Error Report",
|
||||||
|
"helpText": "If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page."
|
||||||
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"clickToCopy": "Click to copy details",
|
"clickToCopy": "Click to copy details",
|
||||||
"clipboardEmpty": "Clipboard is empty",
|
"clipboardEmpty": "Clipboard is empty",
|
||||||
@@ -347,7 +373,15 @@
|
|||||||
"app": "App Settings",
|
"app": "App Settings",
|
||||||
"audio": "Audio Preferences",
|
"audio": "Audio Preferences",
|
||||||
"browserForCookies": "Select browser to use cookies from",
|
"browserForCookies": "Select browser to use cookies from",
|
||||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication",
|
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
|
||||||
|
"browserForCookiesProfile": "Profile name or path",
|
||||||
|
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
|
||||||
|
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
|
||||||
|
"browserForCookiesProfileInvalid": "Profile path is not valid. Choose the profile folder for the selected browser.",
|
||||||
|
"browserForCookiesProfileInvalidPath": "That folder does not exist. Pick an existing profile folder.",
|
||||||
|
"browserForCookiesProfileInvalidProfile": "Profile name not found in the default browser location.",
|
||||||
|
"browserForCookiesProfileInvalidUnsupported": "No default profile location is known for this browser on this platform.",
|
||||||
|
"browserForCookiesProfileInvalidEmpty": "Enter a profile path for the selected browser.",
|
||||||
"cookiesFile": "Cookies file",
|
"cookiesFile": "Cookies file",
|
||||||
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
"cookiesFileDescription": "Netscape formatted cookies file to load for authentication",
|
||||||
"clearCookiesFile": "Clear",
|
"clearCookiesFile": "Clear",
|
||||||
@@ -362,7 +396,10 @@
|
|||||||
"chromium": "Chromium",
|
"chromium": "Chromium",
|
||||||
"edge": "Edge",
|
"edge": "Edge",
|
||||||
"firefox": "Firefox",
|
"firefox": "Firefox",
|
||||||
"safari": "Safari"
|
"opera": "Opera",
|
||||||
|
"safari": "Safari",
|
||||||
|
"vivaldi": "Vivaldi",
|
||||||
|
"whale": "Whale"
|
||||||
},
|
},
|
||||||
"configFile": "Use configuration file",
|
"configFile": "Use configuration file",
|
||||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||||
@@ -375,6 +412,7 @@
|
|||||||
"fileSelectError": "Failed to select file",
|
"fileSelectError": "Failed to select file",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"languageDescription": "Choose your preferred language for the application interface",
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"hideDockIcon": "Hide Dock icon",
|
"hideDockIcon": "Hide Dock icon",
|
||||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||||
@@ -383,6 +421,14 @@
|
|||||||
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
|
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
|
||||||
"enableAnalytics": "Help improve VidBee",
|
"enableAnalytics": "Help improve VidBee",
|
||||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||||
|
"embedChapters": "Embed chapters",
|
||||||
|
"embedChaptersDescription": "Add chapter markers to the file when available",
|
||||||
|
"embedMetadata": "Embed metadata",
|
||||||
|
"embedMetadataDescription": "Write title, artist, and other metadata when available",
|
||||||
|
"embedSubs": "Embed subtitles",
|
||||||
|
"embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)",
|
||||||
|
"embedThumbnail": "Embed thumbnail",
|
||||||
|
"embedThumbnailDescription": "Add the thumbnail as cover art",
|
||||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
@@ -408,8 +454,7 @@
|
|||||||
"showMoreFormats": "Show more format options",
|
"showMoreFormats": "Show more format options",
|
||||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
"filenameDescription": "Pattern used when a subscription does not override its filename."
|
||||||
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
|
||||||
},
|
},
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
@@ -430,7 +475,6 @@
|
|||||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||||
"downloadDirectory": "Download directory",
|
"downloadDirectory": "Download directory",
|
||||||
"filenameTemplate": "Filename template",
|
"filenameTemplate": "Filename template",
|
||||||
"checkInterval": "Check interval (hours)",
|
|
||||||
"onlyLatest": "Download only the latest video",
|
"onlyLatest": "Download only the latest video",
|
||||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Mostrar más opciones de formato",
|
"showMoreFormats": "Mostrar más opciones de formato",
|
||||||
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
|
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.",
|
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo."
|
||||||
"intervalDescription": "Con qué frecuencia VidBee verifica cada feed de suscripción (1-24 horas)."
|
|
||||||
},
|
},
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -393,7 +392,6 @@
|
|||||||
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
|
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
|
||||||
"downloadDirectory": "Directorio de descarga",
|
"downloadDirectory": "Directorio de descarga",
|
||||||
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
|
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
|
||||||
"checkInterval": "Intervalo de verificación (horas)",
|
|
||||||
"onlyLatest": "Descargar solo el último video",
|
"onlyLatest": "Descargar solo el último video",
|
||||||
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
|
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Afficher plus d'options de format",
|
"showMoreFormats": "Afficher plus d'options de format",
|
||||||
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Modèle utilisé lorsqu’un abonnement ne remplace pas son nom de fichier.",
|
"filenameDescription": "Modèle utilisé lorsqu’un abonnement ne remplace pas son nom de fichier."
|
||||||
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
|
|
||||||
},
|
},
|
||||||
"system": "Système",
|
"system": "Système",
|
||||||
"theme": "Thème",
|
"theme": "Thème",
|
||||||
@@ -485,7 +484,6 @@
|
|||||||
"title": "Ajouter un flux RSS"
|
"title": "Ajouter un flux RSS"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "Intervalle de vérification (heures)",
|
|
||||||
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
|
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
|
||||||
"downloadDirectory": "Répertoire de téléchargement",
|
"downloadDirectory": "Répertoire de téléchargement",
|
||||||
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
|
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Tampilkan lebih banyak opsi format",
|
"showMoreFormats": "Tampilkan lebih banyak opsi format",
|
||||||
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
|
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.",
|
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya."
|
||||||
"intervalDescription": "Seberapa sering VidBee memeriksa setiap feed berlangganan (1-24 jam)."
|
|
||||||
},
|
},
|
||||||
"system": "Sistem",
|
"system": "Sistem",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -393,7 +392,6 @@
|
|||||||
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
|
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
|
||||||
"downloadDirectory": "Direktori unduhan",
|
"downloadDirectory": "Direktori unduhan",
|
||||||
"filenameTemplate": "Template nama file (hanya file)",
|
"filenameTemplate": "Template nama file (hanya file)",
|
||||||
"checkInterval": "Interval pemeriksaan (jam)",
|
|
||||||
"onlyLatest": "Unduh hanya video terbaru",
|
"onlyLatest": "Unduh hanya video terbaru",
|
||||||
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
|
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Mostra più opzioni formato",
|
"showMoreFormats": "Mostra più opzioni formato",
|
||||||
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
|
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.",
|
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file."
|
||||||
"intervalDescription": "La frequenza con cui VidBee controlla ciascun feed di abbonamento (1-24 ore)."
|
|
||||||
},
|
},
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -485,7 +484,6 @@
|
|||||||
"title": "Aggiungi RSS"
|
"title": "Aggiungi RSS"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "Intervallo di controllo (ore)",
|
|
||||||
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
|
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
|
||||||
"downloadDirectory": "Scarica la directory",
|
"downloadDirectory": "Scarica la directory",
|
||||||
"filenameTemplate": "Modello nome file (solo file)",
|
"filenameTemplate": "Modello nome file (solo file)",
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
||||||
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
|
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
|
||||||
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 ~ 24 時間)。"
|
|
||||||
},
|
},
|
||||||
"system": "システム",
|
"system": "システム",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
@@ -485,7 +484,6 @@
|
|||||||
"title": "RSSを追加"
|
"title": "RSSを追加"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "チェック間隔(時間)",
|
|
||||||
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
|
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
|
||||||
"downloadDirectory": "ダウンロードディレクトリ",
|
"downloadDirectory": "ダウンロードディレクトリ",
|
||||||
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",
|
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "더 많은 형식 옵션 표시",
|
"showMoreFormats": "더 많은 형식 옵션 표시",
|
||||||
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
|
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다."
|
||||||
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
|
|
||||||
},
|
},
|
||||||
"system": "시스템",
|
"system": "시스템",
|
||||||
"theme": "테마",
|
"theme": "테마",
|
||||||
@@ -485,7 +484,6 @@
|
|||||||
"title": "RSS 추가"
|
"title": "RSS 추가"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "확인 간격(시간)",
|
|
||||||
"description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.",
|
"description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.",
|
||||||
"downloadDirectory": "디렉토리 다운로드",
|
"downloadDirectory": "디렉토리 다운로드",
|
||||||
"filenameTemplate": "파일 이름 템플릿(파일만)",
|
"filenameTemplate": "파일 이름 템플릿(파일만)",
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Mostrar mais opções de formato",
|
"showMoreFormats": "Mostrar mais opções de formato",
|
||||||
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo.",
|
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo."
|
||||||
"intervalDescription": "Com que frequência o VidBee verifica cada feed de assinatura (1 a 24 horas)."
|
|
||||||
},
|
},
|
||||||
"system": "Sistema",
|
"system": "Sistema",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
@@ -485,7 +484,6 @@
|
|||||||
"title": "Adicionar RSS"
|
"title": "Adicionar RSS"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "Intervalo de verificação (horas)",
|
|
||||||
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
|
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
|
||||||
"downloadDirectory": "Baixar diretório",
|
"downloadDirectory": "Baixar diretório",
|
||||||
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",
|
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",
|
||||||
|
|||||||
@@ -371,8 +371,7 @@
|
|||||||
"showMoreFormats": "Показать больше вариантов форматов",
|
"showMoreFormats": "Показать больше вариантов форматов",
|
||||||
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
|
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.",
|
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла."
|
||||||
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
|
|
||||||
},
|
},
|
||||||
"system": "Системная",
|
"system": "Системная",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
@@ -393,7 +392,6 @@
|
|||||||
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
|
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
|
||||||
"downloadDirectory": "Директория загрузки",
|
"downloadDirectory": "Директория загрузки",
|
||||||
"filenameTemplate": "Шаблон имени файла (только файл)",
|
"filenameTemplate": "Шаблон имени файла (только файл)",
|
||||||
"checkInterval": "Интервал проверки (часы)",
|
|
||||||
"onlyLatest": "Загружать только последнее видео",
|
"onlyLatest": "Загружать только последнее видео",
|
||||||
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
|
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -372,8 +372,7 @@
|
|||||||
"showMoreFormats": "顯示更多格式選項",
|
"showMoreFormats": "顯示更多格式選項",
|
||||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
|
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。"
|
||||||
"intervalDescription": "VidBee 檢查每個訂閱源的頻率(1-24 小時)。"
|
|
||||||
},
|
},
|
||||||
"system": "系統",
|
"system": "系統",
|
||||||
"theme": "主題",
|
"theme": "主題",
|
||||||
@@ -486,7 +485,6 @@
|
|||||||
"title": "添加RSS"
|
"title": "添加RSS"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "檢查間隔(小時)",
|
|
||||||
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
|
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
|
||||||
"downloadDirectory": "下載目錄",
|
"downloadDirectory": "下載目錄",
|
||||||
"filenameTemplate": "文件名模板(僅限文件)",
|
"filenameTemplate": "文件名模板(僅限文件)",
|
||||||
|
|||||||
@@ -372,8 +372,7 @@
|
|||||||
"showMoreFormats": "显示更多格式选项",
|
"showMoreFormats": "显示更多格式选项",
|
||||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||||
"subscriptionDefaults": {
|
"subscriptionDefaults": {
|
||||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
|
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。"
|
||||||
"intervalDescription": "VidBee 检查每个订阅源的频率(1-24 小时)。"
|
|
||||||
},
|
},
|
||||||
"system": "系统",
|
"system": "系统",
|
||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
@@ -486,7 +485,6 @@
|
|||||||
"title": "添加RSS"
|
"title": "添加RSS"
|
||||||
},
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"checkInterval": "检查间隔(小时)",
|
|
||||||
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
|
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
|
||||||
"downloadDirectory": "下载目录",
|
"downloadDirectory": "下载目录",
|
||||||
"filenameTemplate": "文件名模板(仅限文件)",
|
"filenameTemplate": "文件名模板(仅限文件)",
|
||||||
|
|||||||
@@ -6,6 +6,82 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
|
import { logger } from './lib/logger'
|
||||||
|
|
||||||
|
// Setup global error handlers
|
||||||
|
setupGlobalErrorHandlers()
|
||||||
|
|
||||||
|
// Get app version asynchronously
|
||||||
|
let appVersion: string | undefined
|
||||||
|
if (window?.api && window.electron?.ipcRenderer) {
|
||||||
|
import('./lib/ipc')
|
||||||
|
.then(({ ipcServices }) => ipcServices.app.getVersion())
|
||||||
|
.then((version) => {
|
||||||
|
appVersion = version
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.warn('Failed to get app version for error reporting:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGlobalErrorHandlers(): void {
|
||||||
|
// Handle uncaught JavaScript errors
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
logger.error('Uncaught error:', event.error)
|
||||||
|
|
||||||
|
if (window?.api) {
|
||||||
|
try {
|
||||||
|
window.api.send('error:renderer', {
|
||||||
|
error: {
|
||||||
|
name: event.error?.name || 'Error',
|
||||||
|
message: event.error?.message || event.message || 'Unknown error',
|
||||||
|
stack: event.error?.stack || event.filename
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
context: {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: navigator.platform,
|
||||||
|
version: appVersion,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to send error to main process:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
logger.error('Unhandled promise rejection:', event.reason)
|
||||||
|
|
||||||
|
if (window?.api) {
|
||||||
|
try {
|
||||||
|
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))
|
||||||
|
|
||||||
|
window.api.send('error:renderer', {
|
||||||
|
error: {
|
||||||
|
name: error.name || 'UnhandledPromiseRejection',
|
||||||
|
message: error.message || String(event.reason),
|
||||||
|
stack: error.stack
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
context: {
|
||||||
|
url: window.location.href,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
platform: navigator.platform,
|
||||||
|
version: appVersion
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to send error to main process:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const rootElement = document.getElementById('root')
|
const rootElement = document.getElementById('root')
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { Badge } from '@renderer/components/ui/badge'
|
import { Badge } from '@renderer/components/ui/badge'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from '@renderer/components/ui/card'
|
|
||||||
import { Progress } from '@renderer/components/ui/progress'
|
import { Progress } from '@renderer/components/ui/progress'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
@@ -17,17 +11,17 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Github,
|
Github,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Mail,
|
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldCheck,
|
|
||||||
Twitter
|
Twitter
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||||
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
import { saveSettingAtom, settingsAtom } from '../store/settings'
|
||||||
|
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
|
||||||
|
|
||||||
interface AboutResource {
|
interface AboutResource {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -45,8 +39,11 @@ type LatestVersionState =
|
|||||||
| null
|
| null
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||||
|
const [updateReady] = useAtom(updateReadyAtom)
|
||||||
|
const [updateAvailableState] = useAtom(updateAvailableAtom)
|
||||||
|
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||||
const [appVersion, setAppVersion] = useState<string>('—')
|
const [appVersion, setAppVersion] = useState<string>('—')
|
||||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||||
@@ -74,6 +71,17 @@ export function About() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!updateAvailableState.available) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLatestVersionState({
|
||||||
|
status: 'available',
|
||||||
|
version: updateAvailableState.version ?? ''
|
||||||
|
})
|
||||||
|
}, [updateAvailableState.available, updateAvailableState.version])
|
||||||
|
|
||||||
// Listen for update events only in About page
|
// Listen for update events only in About page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window?.api) {
|
if (!window?.api) {
|
||||||
@@ -85,11 +93,15 @@ export function About() {
|
|||||||
const versionLabel = info.version ?? ''
|
const versionLabel = info.version ?? ''
|
||||||
|
|
||||||
// Update will be downloaded automatically because autoDownload is enabled in main process
|
// Update will be downloaded automatically because autoDownload is enabled in main process
|
||||||
toast.success(t('about.notifications.updateAvailable', { version: versionLabel }))
|
toast.success(i18n.t('about.notifications.updateAvailable', { version: versionLabel }))
|
||||||
setLatestVersionState({
|
setLatestVersionState({
|
||||||
status: 'available',
|
status: 'available',
|
||||||
version: versionLabel
|
version: versionLabel
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: versionLabel
|
||||||
|
})
|
||||||
// Reset download progress when new update is available
|
// Reset download progress when new update is available
|
||||||
setUpdateDownloadProgress(0)
|
setUpdateDownloadProgress(0)
|
||||||
}
|
}
|
||||||
@@ -115,7 +127,7 @@ export function About() {
|
|||||||
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
|
||||||
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
|
||||||
}
|
}
|
||||||
}, [t])
|
}, [i18n, setUpdateAvailable])
|
||||||
|
|
||||||
const handleSettingChange = async (
|
const handleSettingChange = async (
|
||||||
key: keyof typeof settings,
|
key: keyof typeof settings,
|
||||||
@@ -137,6 +149,10 @@ export function About() {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
version: result.version ?? ''
|
version: result.version ?? ''
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: result.version
|
||||||
|
})
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||||
setLatestVersionState({
|
setLatestVersionState({
|
||||||
@@ -149,6 +165,10 @@ export function About() {
|
|||||||
status: 'uptodate',
|
status: 'uptodate',
|
||||||
version: result.version ?? appVersion
|
version: result.version ?? appVersion
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check for updates:', error)
|
console.error('Failed to check for updates:', error)
|
||||||
@@ -161,6 +181,10 @@ export function About() {
|
|||||||
openShareUrl('https://vidbee.org/download/')
|
openShareUrl('https://vidbee.org/download/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRestartToUpdate = () => {
|
||||||
|
void ipcServices.update.quitAndInstall()
|
||||||
|
}
|
||||||
|
|
||||||
const handleCheckForUpdates = async () => {
|
const handleCheckForUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
toast.info(t('about.notifications.checkingUpdates'))
|
toast.info(t('about.notifications.checkingUpdates'))
|
||||||
@@ -172,6 +196,10 @@ export function About() {
|
|||||||
status: 'available',
|
status: 'available',
|
||||||
version: result.version ?? ''
|
version: result.version ?? ''
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: true,
|
||||||
|
version: result.version
|
||||||
|
})
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.error(t('about.notifications.updateError', { error: result.error }))
|
toast.error(t('about.notifications.updateError', { error: result.error }))
|
||||||
setLatestVersionState({
|
setLatestVersionState({
|
||||||
@@ -184,6 +212,10 @@ export function About() {
|
|||||||
status: 'uptodate',
|
status: 'uptodate',
|
||||||
version: result.version ?? appVersion
|
version: result.version ?? appVersion
|
||||||
})
|
})
|
||||||
|
setUpdateAvailable({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check for updates:', error)
|
console.error('Failed to check for updates:', error)
|
||||||
@@ -196,7 +228,7 @@ export function About() {
|
|||||||
|
|
||||||
const shareLinks = useMemo(() => {
|
const shareLinks = useMemo(() => {
|
||||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||||
const encodedText = encodeURIComponent(t('about.description'))
|
const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||||
@@ -204,13 +236,13 @@ export function About() {
|
|||||||
}
|
}
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const openShareUrl = (url: string) => {
|
const openShareUrl = useCallback((url: string) => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer')
|
window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const handleShareTwitter = () => {
|
const handleShareTwitter = () => {
|
||||||
openShareUrl(shareLinks.twitter)
|
openShareUrl(shareLinks.twitter)
|
||||||
@@ -244,6 +276,41 @@ export function About() {
|
|||||||
? 'text-destructive'
|
? 'text-destructive'
|
||||||
: 'text-muted-foreground'
|
: 'text-muted-foreground'
|
||||||
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
|
||||||
|
const shouldShowCheckUpdates =
|
||||||
|
!updateAvailableState.available && latestVersionState?.status !== 'available'
|
||||||
|
|
||||||
|
const handleXFeedback = useCallback(() => {
|
||||||
|
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
|
||||||
|
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
|
||||||
|
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
|
||||||
|
}, [appVersion, openShareUrl])
|
||||||
|
|
||||||
|
const feedbackResources = useMemo<AboutResource[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
icon: Github,
|
||||||
|
label: t('about.resources.githubIssues'),
|
||||||
|
description: t('about.resources.githubIssuesDescription'),
|
||||||
|
actionLabel: t('about.actions.feedback'),
|
||||||
|
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Twitter,
|
||||||
|
label: t('about.resources.xFeedback'),
|
||||||
|
description: t('about.resources.xFeedbackDescription'),
|
||||||
|
actionLabel: t('about.actions.feedback'),
|
||||||
|
onClick: handleXFeedback
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: MessageCircle,
|
||||||
|
label: t('about.resources.discord'),
|
||||||
|
description: t('about.resources.discordDescription'),
|
||||||
|
actionLabel: t('about.actions.visit'),
|
||||||
|
href: 'https://discord.gg/uBqXV6QPdm'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[t, handleXFeedback]
|
||||||
|
)
|
||||||
|
|
||||||
const aboutResources = useMemo<AboutResource[]>(
|
const aboutResources = useMemo<AboutResource[]>(
|
||||||
() => [
|
() => [
|
||||||
@@ -260,27 +327,6 @@ export function About() {
|
|||||||
description: t('about.resources.changelogDescription'),
|
description: t('about.resources.changelogDescription'),
|
||||||
actionLabel: t('about.actions.view'),
|
actionLabel: t('about.actions.view'),
|
||||||
href: 'https://github.com/nexmoe/VidBee/releases'
|
href: 'https://github.com/nexmoe/VidBee/releases'
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageCircle,
|
|
||||||
label: t('about.resources.feedback'),
|
|
||||||
description: t('about.resources.feedbackDescription'),
|
|
||||||
actionLabel: t('about.actions.feedback'),
|
|
||||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ShieldCheck,
|
|
||||||
label: t('about.resources.license'),
|
|
||||||
description: t('about.resources.licenseDescription'),
|
|
||||||
actionLabel: t('about.actions.view'),
|
|
||||||
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Mail,
|
|
||||||
label: t('about.resources.contact'),
|
|
||||||
description: t('about.resources.contactDescription'),
|
|
||||||
actionLabel: t('about.actions.email'),
|
|
||||||
href: 'mailto:nexmoex@gmail.com'
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
@@ -291,69 +337,89 @@ export function About() {
|
|||||||
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
<div className="container mx-auto max-w-5xl p-6 space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
|
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
|
||||||
<div className="space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||||
</div>
|
<Badge variant="secondary">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{t('about.versionLabel', { version: appVersion })}
|
||||||
<Badge variant="secondary">
|
</Badge>
|
||||||
{t('about.versionLabel', { version: appVersion })}
|
{latestVersionState ? (
|
||||||
</Badge>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{latestVersionState ? (
|
{latestVersionBadgeText ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
||||||
{latestVersionBadgeText ? (
|
) : null}
|
||||||
<Badge variant="outline">{latestVersionBadgeText}</Badge>
|
{latestVersionStatusText ? (
|
||||||
) : null}
|
<span className={`text-sm ${latestVersionStatusClass}`}>
|
||||||
{latestVersionStatusText ? (
|
{latestVersionStatusText}
|
||||||
<span className={`text-sm ${latestVersionStatusClass}`}>
|
</span>
|
||||||
{latestVersionStatusText}
|
) : null}
|
||||||
</span>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{updateDownloadProgress !== null && (
|
|
||||||
<div className="space-y-2 w-full">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{t('about.downloadingUpdate')}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{updateDownloadProgress.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={updateDownloadProgress} className="h-2" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a
|
||||||
|
href="https://github.com/nexmoe/vidbee"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={t('about.actions.openRepo')}
|
||||||
|
>
|
||||||
|
<Github className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{updateReady.ready ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleRestartToUpdate}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t('about.notifications.restartNowAction')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{latestVersionState?.status === 'available' ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleGoToDownload}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
{t('about.actions.goToDownload')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{shouldShowCheckUpdates ? (
|
||||||
|
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t('about.actions.checkUpdates')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="icon" asChild>
|
|
||||||
<a
|
|
||||||
href="https://github.com/nexmoe/vidbee"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
aria-label={t('about.actions.openRepo')}
|
|
||||||
>
|
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
{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')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{updateDownloadProgress !== null && (
|
||||||
|
<div className="flex flex-col gap-3 pt-4">
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('about.downloadingUpdate')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{updateDownloadProgress.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={updateDownloadProgress} className="h-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between gap-4 pt-6">
|
<div className="flex items-center justify-between gap-4 pt-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
|
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
|
||||||
@@ -370,7 +436,6 @@ export function About() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
<CardTitle>{t('about.shareTitle')}</CardTitle>
|
||||||
<CardDescription>{t('about.shareDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
@@ -415,12 +480,47 @@ export function About() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
|
|
||||||
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
|
{/* Feedback section - merged into one row */}
|
||||||
|
<div className="flex items-center justify-between gap-4 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
|
||||||
|
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('about.resources.feedbackDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{feedbackResources.map((resource) => {
|
||||||
|
const Icon = resource.icon
|
||||||
|
return resource.href ? (
|
||||||
|
<Button key={resource.label} variant="outline" size="sm" asChild>
|
||||||
|
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{resource.label}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={resource.label}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={resource.onClick}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{resource.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Other resources */}
|
||||||
{aboutResources.map((resource) => {
|
{aboutResources.map((resource) => {
|
||||||
const Icon = resource.icon
|
const Icon = resource.icon
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,42 +18,75 @@ import {
|
|||||||
} from '@renderer/components/ui/select'
|
} from '@renderer/components/ui/select'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||||
|
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
|
||||||
import type { OneClickQualityPreset } from '@shared/types'
|
import type { OneClickQualityPreset } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
|
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||||
import { useTheme } from 'next-themes'
|
import { useTheme } from 'next-themes'
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { ipcServices } from '../lib/ipc'
|
||||||
|
import { logger } from '../lib/logger'
|
||||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||||
|
|
||||||
const clampSubscriptionInterval = (value: string) => {
|
const normalizeProfileInput = (value: string) => value.trim().replace(/^['"]|['"]$/g, '')
|
||||||
const parsed = Number.parseInt(value, 10)
|
|
||||||
if (Number.isNaN(parsed)) {
|
const parseBrowserCookiesSetting = (value: string | undefined) => {
|
||||||
return 3
|
if (!value || value === 'none') {
|
||||||
|
return { browser: 'none', profile: '' }
|
||||||
}
|
}
|
||||||
return Math.min(24, Math.max(1, parsed))
|
|
||||||
|
const separatorIndex = value.indexOf(':')
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return { browser: value, profile: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = value.slice(0, separatorIndex).trim()
|
||||||
|
const profile = normalizeProfileInput(value.slice(separatorIndex + 1))
|
||||||
|
return { browser: browser || 'none', profile }
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildBrowserCookiesSetting = (browser: string, profile: string) => {
|
||||||
|
const trimmedBrowser = browser.trim()
|
||||||
|
if (!trimmedBrowser || trimmedBrowser === 'none') {
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedProfile = normalizeProfileInput(profile)
|
||||||
|
return trimmedProfile ? `${trimmedBrowser}:${trimmedProfile}` : trimmedBrowser
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation()
|
const { t, i18n: i18nInstance } = useTranslation()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
const [settings, _setSettings] = useAtom(settingsAtom)
|
const [settings, _setSettings] = useAtom(settingsAtom)
|
||||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
const saveSetting = useSetAtom(saveSettingAtom)
|
const saveSetting = useSetAtom(saveSettingAtom)
|
||||||
const [platform, setPlatform] = useState<string>('')
|
const [platform, setPlatform] = useState<string>('')
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('general')
|
||||||
|
const [browserProfileValidation, setBrowserProfileValidation] = useState<{
|
||||||
|
valid: boolean
|
||||||
|
reason?: string
|
||||||
|
}>({ valid: false })
|
||||||
|
const lastAutoDetectBrowser = useRef<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings()
|
try {
|
||||||
|
loadSettings()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Failed to load settings:', error)
|
||||||
|
}
|
||||||
}, [loadSettings])
|
}, [loadSettings])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlatform = async () => {
|
const fetchPlatform = async () => {
|
||||||
try {
|
try {
|
||||||
const { ipcServices } = await import('../lib/ipc')
|
|
||||||
const platformInfo = await ipcServices.app.getPlatform()
|
const platformInfo = await ipcServices.app.getPlatform()
|
||||||
setPlatform(platformInfo)
|
setPlatform(platformInfo)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get platform info:', error)
|
logger.error('Failed to get platform info:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,61 +95,61 @@ export function Settings() {
|
|||||||
|
|
||||||
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
|
const autoLaunchSupported = platform === 'darwin' || platform === 'win32'
|
||||||
|
|
||||||
const handleSettingChange = async (
|
const handleSettingChange = useCallback(
|
||||||
key: keyof typeof settings,
|
async (key: keyof typeof settings, value: (typeof settings)[keyof typeof settings]) => {
|
||||||
value: (typeof settings)[keyof typeof settings]
|
try {
|
||||||
) => {
|
await saveSetting({ key, value })
|
||||||
await saveSetting({ key, value })
|
} catch (error) {
|
||||||
toast.success(t('notifications.settingsSaved'))
|
logger.error('[Settings] Failed to change setting', { key, value, error })
|
||||||
}
|
toast.error(t('settings.saveError') || 'Failed to save setting')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[saveSetting, t]
|
||||||
|
)
|
||||||
|
|
||||||
const handleSelectPath = async () => {
|
const handleSelectPath = async () => {
|
||||||
try {
|
try {
|
||||||
const { ipcServices } = await import('../lib/ipc')
|
|
||||||
const path = await ipcServices.fs.selectDirectory()
|
const path = await ipcServices.fs.selectDirectory()
|
||||||
if (path) {
|
if (path) {
|
||||||
await handleSettingChange('downloadPath', path)
|
await handleSettingChange('downloadPath', path)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to select directory:', error)
|
logger.error('Failed to select directory:', error)
|
||||||
toast.error(t('settings.directorySelectError'))
|
toast.error(t('settings.directorySelectError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectConfigFile = async () => {
|
const handleSelectConfigFile = async () => {
|
||||||
try {
|
try {
|
||||||
const { ipcServices } = await import('../lib/ipc')
|
|
||||||
const path = await ipcServices.fs.selectFile()
|
const path = await ipcServices.fs.selectFile()
|
||||||
if (path) {
|
if (path) {
|
||||||
await handleSettingChange('configPath', path)
|
await handleSettingChange('configPath', path)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to select file:', error)
|
logger.error('Failed to select file:', error)
|
||||||
toast.error(t('settings.fileSelectError'))
|
toast.error(t('settings.fileSelectError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectCookiesFile = async () => {
|
const handleSelectCookiesFile = async () => {
|
||||||
try {
|
try {
|
||||||
const { ipcServices } = await import('../lib/ipc')
|
|
||||||
const path = await ipcServices.fs.selectFile()
|
const path = await ipcServices.fs.selectFile()
|
||||||
if (path) {
|
if (path) {
|
||||||
await handleSettingChange('cookiesPath', path)
|
await handleSettingChange('cookiesPath', path)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to select cookies file:', error)
|
logger.error('Failed to select cookies file:', error)
|
||||||
toast.error(t('settings.fileSelectError'))
|
toast.error(t('settings.fileSelectError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenCookiesFaq = async () => {
|
const handleOpenCookiesFaq = async () => {
|
||||||
try {
|
try {
|
||||||
const { ipcServices } = await import('../lib/ipc')
|
|
||||||
await ipcServices.fs.openExternal(
|
await ipcServices.fs.openExternal(
|
||||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open cookies FAQ:', error)
|
logger.error('Failed to open cookies FAQ:', error)
|
||||||
toast.error(t('settings.openLinkError'))
|
toast.error(t('settings.openLinkError'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +164,111 @@ export function Settings() {
|
|||||||
await handleSettingChange('theme', value)
|
await handleSettingChange('theme', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const languageOptions = languageList
|
||||||
|
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
|
||||||
|
const currentLanguage =
|
||||||
|
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
|
||||||
|
const parsedBrowserCookies = parseBrowserCookiesSetting(settings.browserForCookies)
|
||||||
|
const browserForCookiesValue = parsedBrowserCookies.browser
|
||||||
|
const browserCookiesProfileValue = parsedBrowserCookies.profile
|
||||||
|
const normalizedBrowserCookiesSetting = buildBrowserCookiesSetting(
|
||||||
|
browserForCookiesValue,
|
||||||
|
browserCookiesProfileValue
|
||||||
|
)
|
||||||
|
const hasBrowserProfileValue = browserCookiesProfileValue.trim().length > 0
|
||||||
|
const showBrowserProfileCheck = hasBrowserProfileValue && browserProfileValidation.valid
|
||||||
|
const showBrowserProfileWarning = hasBrowserProfileValue && !browserProfileValidation.valid
|
||||||
|
const getBrowserProfileWarningMessage = (reason?: string) => {
|
||||||
|
switch (reason) {
|
||||||
|
case 'pathNotFound':
|
||||||
|
return t('settings.browserForCookiesProfileInvalidPath')
|
||||||
|
case 'profileNotFound':
|
||||||
|
return t('settings.browserForCookiesProfileInvalidProfile')
|
||||||
|
case 'browserUnsupported':
|
||||||
|
return t('settings.browserForCookiesProfileInvalidUnsupported')
|
||||||
|
case 'empty':
|
||||||
|
return t('settings.browserForCookiesProfileInvalidEmpty')
|
||||||
|
default:
|
||||||
|
return t('settings.browserForCookiesProfileInvalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.browserForCookies !== normalizedBrowserCookiesSetting) {
|
||||||
|
void handleSettingChange('browserForCookies', normalizedBrowserCookiesSetting)
|
||||||
|
}
|
||||||
|
}, [handleSettingChange, normalizedBrowserCookiesSetting, settings.browserForCookies])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const browserChanged = lastAutoDetectBrowser.current !== browserForCookiesValue
|
||||||
|
const shouldAutoDetect =
|
||||||
|
browserForCookiesValue !== 'none' && (browserChanged || !browserCookiesProfileValue)
|
||||||
|
|
||||||
|
if (!shouldAutoDetect) {
|
||||||
|
lastAutoDetectBrowser.current = browserForCookiesValue
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectProfilePath = async () => {
|
||||||
|
try {
|
||||||
|
const detectedPath =
|
||||||
|
await ipcServices.browserCookies.getBrowserProfilePath(browserForCookiesValue)
|
||||||
|
const nextProfileValue = detectedPath || ''
|
||||||
|
if (nextProfileValue !== browserCookiesProfileValue) {
|
||||||
|
const nextValue = buildBrowserCookiesSetting(browserForCookiesValue, nextProfileValue)
|
||||||
|
await handleSettingChange('browserForCookies', nextValue)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Failed to detect browser profile path:', error)
|
||||||
|
} finally {
|
||||||
|
lastAutoDetectBrowser.current = browserForCookiesValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void detectProfilePath()
|
||||||
|
}, [browserForCookiesValue, browserCookiesProfileValue, handleSettingChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (browserForCookiesValue === 'none' || !hasBrowserProfileValue) {
|
||||||
|
setBrowserProfileValidation({ valid: false, reason: 'empty' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isActive = true
|
||||||
|
|
||||||
|
const validateProfilePath = async () => {
|
||||||
|
try {
|
||||||
|
const result = await ipcServices.browserCookies.validateBrowserProfilePath(
|
||||||
|
browserForCookiesValue,
|
||||||
|
browserCookiesProfileValue
|
||||||
|
)
|
||||||
|
if (isActive) {
|
||||||
|
setBrowserProfileValidation(result)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isActive) {
|
||||||
|
setBrowserProfileValidation({ valid: false, reason: 'pathNotFound' })
|
||||||
|
}
|
||||||
|
logger.error('[Settings] Failed to validate browser profile path:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateProfilePath()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false
|
||||||
|
}
|
||||||
|
}, [browserForCookiesValue, browserCookiesProfileValue, hasBrowserProfileValue])
|
||||||
|
|
||||||
|
const handleLanguageChange = async (value: LanguageCode) => {
|
||||||
|
if (activeLanguageCode === value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveSetting({ key: 'language', value })
|
||||||
|
await i18nInstance.changeLanguage(value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-background">
|
<div className="h-full bg-background">
|
||||||
<div className="container mx-auto max-w-4xl p-6 space-y-6">
|
<div className="container mx-auto max-w-4xl p-6 space-y-6">
|
||||||
@@ -139,7 +277,12 @@ export function Settings() {
|
|||||||
<p className="text-muted-foreground">{t('settings.description')}</p>
|
<p className="text-muted-foreground">{t('settings.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="general">
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setActiveTab(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
|
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
|
||||||
@@ -185,6 +328,53 @@ export function Settings() {
|
|||||||
</Select>
|
</Select>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.language')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Select
|
||||||
|
value={currentLanguage.value}
|
||||||
|
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder={currentLanguage.name}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`${currentLanguage.flag} rounded-xs text-base`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
|
||||||
|
</div>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{languageOptions.map((option) => {
|
||||||
|
const isActive = option.value === currentLanguage.value
|
||||||
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={isActive ? 'font-semibold bg-muted' : undefined}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -316,8 +506,14 @@ export function Settings() {
|
|||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Switch
|
<Switch
|
||||||
checked={settings.showMoreFormats}
|
checked={settings.showMoreFormats ?? false}
|
||||||
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
|
onCheckedChange={(value) => {
|
||||||
|
try {
|
||||||
|
handleSettingChange('showMoreFormats', value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error toggling showMoreFormats:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -326,31 +522,92 @@ export function Settings() {
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
|
<ItemTitle>{t('settings.embedSubs')}</ItemTitle>
|
||||||
<ItemDescription>
|
<ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>
|
||||||
{t('settings.subscriptionDefaults.intervalDescription')}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Input
|
<Switch
|
||||||
type="number"
|
checked={settings.embedSubs ?? false}
|
||||||
min={1}
|
onCheckedChange={(value) => {
|
||||||
max={24}
|
try {
|
||||||
defaultValue={settings.subscriptionCheckIntervalHours}
|
handleSettingChange('embedSubs', value)
|
||||||
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
|
} catch (error) {
|
||||||
onBlur={(event) =>
|
logger.error('[Settings] Error toggling embedSubs:', error)
|
||||||
void handleSettingChange(
|
}
|
||||||
'subscriptionCheckIntervalHours',
|
}}
|
||||||
clampSubscriptionInterval(event.target.value)
|
/>
|
||||||
)
|
</ItemActions>
|
||||||
}
|
</Item>
|
||||||
className="w-24"
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
|
{platform !== 'darwin' && (
|
||||||
|
<>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Switch
|
||||||
|
checked={settings.embedThumbnail ?? false}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
try {
|
||||||
|
handleSettingChange('embedThumbnail', value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.embedMetadata')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Switch
|
||||||
|
checked={settings.embedMetadata ?? false}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
try {
|
||||||
|
handleSettingChange('embedMetadata', value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error toggling embedMetadata:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
<ItemSeparator />
|
<ItemSeparator />
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.embedChapters')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Switch
|
||||||
|
checked={settings.embedChapters ?? true}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
try {
|
||||||
|
handleSettingChange('embedChapters', value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error toggling embedChapters:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||||
@@ -359,23 +616,45 @@ export function Settings() {
|
|||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Select
|
{(() => {
|
||||||
value={settings.maxConcurrentDownloads.toString()}
|
try {
|
||||||
onValueChange={(value) =>
|
const maxConcurrent = settings.maxConcurrentDownloads ?? 5
|
||||||
handleSettingChange('maxConcurrentDownloads', Number(value))
|
const maxConcurrentStr = maxConcurrent.toString()
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={maxConcurrentStr}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
try {
|
||||||
|
const numValue = Number(value)
|
||||||
|
handleSettingChange('maxConcurrentDownloads', numValue)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
'[Settings] Error changing max concurrent downloads:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||||
|
<SelectItem key={num} value={num.toString()}>
|
||||||
|
{num}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
'[Settings] Error rendering max concurrent downloads select:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return <div>Error loading max concurrent downloads setting</div>
|
||||||
}
|
}
|
||||||
>
|
})()}
|
||||||
<SelectTrigger className="w-20">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
|
||||||
<SelectItem key={num} value={num.toString()}>
|
|
||||||
{num}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
@@ -387,34 +666,28 @@ export function Settings() {
|
|||||||
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Input
|
{(() => {
|
||||||
placeholder={t('settings.proxyPlaceholder')}
|
try {
|
||||||
value={settings.proxy}
|
const proxyValue = settings.proxy ?? ''
|
||||||
onChange={(e) => handleSettingChange('proxy', e.target.value)}
|
return (
|
||||||
className="w-64"
|
<Input
|
||||||
/>
|
placeholder={t('settings.proxyPlaceholder')}
|
||||||
</ItemActions>
|
value={proxyValue}
|
||||||
</Item>
|
onChange={(e) => {
|
||||||
|
try {
|
||||||
<ItemSeparator />
|
handleSettingChange('proxy', e.target.value)
|
||||||
|
} catch (error) {
|
||||||
<Item variant="muted">
|
logger.error('[Settings] Error changing proxy:', error)
|
||||||
<ItemContent>
|
}
|
||||||
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
}}
|
||||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
className="w-64"
|
||||||
</ItemContent>
|
/>
|
||||||
<ItemActions>
|
)
|
||||||
<div className="flex gap-2 w-full max-w-md">
|
} catch (error) {
|
||||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
logger.error('[Settings] Error rendering proxy input:', error)
|
||||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
return <div>Error loading proxy setting</div>
|
||||||
<Button
|
}
|
||||||
variant="secondary"
|
})()}
|
||||||
onClick={() => void handleSettingChange('configPath', '')}
|
|
||||||
disabled={!settings.configPath}
|
|
||||||
>
|
|
||||||
{t('settings.clearConfigFile')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -426,27 +699,126 @@ export function Settings() {
|
|||||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Select
|
{(() => {
|
||||||
value={settings.browserForCookies}
|
try {
|
||||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
return (
|
||||||
>
|
<Select
|
||||||
<SelectTrigger className="w-32">
|
value={browserForCookiesValue}
|
||||||
<SelectValue />
|
onValueChange={(value) => {
|
||||||
</SelectTrigger>
|
try {
|
||||||
<SelectContent>
|
const nextValue = buildBrowserCookiesSetting(value, '')
|
||||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
handleSettingChange('browserForCookies', nextValue)
|
||||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
} catch (error) {
|
||||||
<SelectItem value="chromium">
|
logger.error('[Settings] Error changing browser for cookies:', error)
|
||||||
{t('settings.browserOptions.chromium')}
|
}
|
||||||
</SelectItem>
|
}}
|
||||||
<SelectItem value="firefox">
|
>
|
||||||
{t('settings.browserOptions.firefox')}
|
<SelectTrigger className="w-32">
|
||||||
</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="chrome">
|
||||||
</Select>
|
{t('settings.browserOptions.chrome')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="chromium">
|
||||||
|
{t('settings.browserOptions.chromium')}
|
||||||
|
</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>
|
||||||
|
<SelectItem value="opera">
|
||||||
|
{t('settings.browserOptions.opera')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="vivaldi">
|
||||||
|
{t('settings.browserOptions.vivaldi')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="whale">
|
||||||
|
{t('settings.browserOptions.whale')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error rendering browser for cookies select:', error)
|
||||||
|
return <div>Error loading browser for cookies setting</div>
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent className="basis-full">
|
||||||
|
<ItemTitle>{t('settings.browserForCookiesProfile')}</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{t('settings.browserForCookiesProfileDescription')}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions className="basis-full">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.browserForCookiesProfilePlaceholder')}
|
||||||
|
value={browserCookiesProfileValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
try {
|
||||||
|
const newProfileValue = event.target.value
|
||||||
|
const nextValue = buildBrowserCookiesSetting(
|
||||||
|
browserForCookiesValue,
|
||||||
|
newProfileValue
|
||||||
|
)
|
||||||
|
handleSettingChange('browserForCookies', nextValue)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
'[Settings] Error changing browser cookies profile:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={browserForCookiesValue === 'none'}
|
||||||
|
className="w-full pr-10"
|
||||||
|
/>
|
||||||
|
{showBrowserProfileCheck ? (
|
||||||
|
<CheckCircle2
|
||||||
|
className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-emerald-500"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showBrowserProfileWarning ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="absolute right-3 top-1/2 inline-flex h-4 w-4 -translate-y-1/2 items-center justify-center text-amber-500">
|
||||||
|
<AlertTriangle className="h-4 w-4" aria-hidden />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{getBrowserProfileWarningMessage(browserProfileValidation.reason)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
'[Settings] Error rendering browser cookies profile input:',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return <div>Error loading browser cookies profile setting</div>
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
@@ -458,17 +830,35 @@ export function Settings() {
|
|||||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<div className="flex gap-2 w-full max-w-md">
|
{(() => {
|
||||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
try {
|
||||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
const cookiesPathValue = settings.cookiesPath ?? ''
|
||||||
<Button
|
return (
|
||||||
variant="secondary"
|
<div className="flex gap-2 w-full max-w-md">
|
||||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
<Input value={cookiesPathValue} readOnly className="flex-1" />
|
||||||
disabled={!settings.cookiesPath}
|
<Button onClick={handleSelectCookiesFile}>
|
||||||
>
|
{t('settings.selectPath')}
|
||||||
{t('settings.clearCookiesFile')}
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
void handleSettingChange('cookiesPath', '')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error clearing cookies path:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!cookiesPathValue}
|
||||||
|
>
|
||||||
|
{t('settings.clearCookiesFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error rendering cookies file input:', error)
|
||||||
|
return <div>Error loading cookies file setting</div>
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
@@ -477,12 +867,10 @@ export function Settings() {
|
|||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||||
<ItemDescription>
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground text-sm leading-normal">
|
||||||
<ul className="list-disc list-inside space-y-1">
|
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
<li>{t('settings.cookiesHelpFile')}</li>
|
||||||
<li>{t('settings.cookiesHelpFile')}</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||||
@@ -490,6 +878,46 @@ export function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.configFile')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const configPathValue = settings.configPath ?? ''
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 w-full max-w-md">
|
||||||
|
<Input value={configPathValue} readOnly className="flex-1" />
|
||||||
|
<Button onClick={handleSelectConfigFile}>
|
||||||
|
{t('settings.selectPath')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
void handleSettingChange('configPath', '')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error clearing config path:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!configPathValue}
|
||||||
|
>
|
||||||
|
{t('settings.clearConfigFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error rendering config file input:', error)
|
||||||
|
return <div>Error loading config file setting</div>
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -499,10 +927,26 @@ export function Settings() {
|
|||||||
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemActions>
|
<ItemActions>
|
||||||
<Switch
|
{(() => {
|
||||||
checked={settings.enableAnalytics}
|
try {
|
||||||
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
const analyticsValue = settings.enableAnalytics ?? true
|
||||||
/>
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={analyticsValue}
|
||||||
|
onCheckedChange={(value) => {
|
||||||
|
try {
|
||||||
|
handleSettingChange('enableAnalytics', value)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error changing enable analytics:', error)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Settings] Error rendering enable analytics switch:', error)
|
||||||
|
return <div>Error loading enable analytics setting</div>
|
||||||
|
}
|
||||||
|
})()}
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
21
src/renderer/src/store/update.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { atom } from 'jotai'
|
||||||
|
|
||||||
|
type UpdateReadyState = {
|
||||||
|
ready: boolean
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateAvailableState = {
|
||||||
|
available: boolean
|
||||||
|
version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateReadyAtom = atom<UpdateReadyState>({
|
||||||
|
ready: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateAvailableAtom = atom<UpdateAvailableState>({
|
||||||
|
available: false,
|
||||||
|
version: undefined
|
||||||
|
})
|
||||||
@@ -270,8 +270,11 @@ export interface AppSettings {
|
|||||||
launchAtLogin: boolean
|
launchAtLogin: boolean
|
||||||
autoUpdate: boolean
|
autoUpdate: boolean
|
||||||
subscriptionOnlyLatestDefault: boolean
|
subscriptionOnlyLatestDefault: boolean
|
||||||
subscriptionCheckIntervalHours: number
|
|
||||||
enableAnalytics: boolean
|
enableAnalytics: boolean
|
||||||
|
embedSubs: boolean
|
||||||
|
embedThumbnail: boolean
|
||||||
|
embedMetadata: boolean
|
||||||
|
embedChapters: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
||||||
@@ -295,6 +298,9 @@ export const defaultSettings: AppSettings = {
|
|||||||
launchAtLogin: false,
|
launchAtLogin: false,
|
||||||
autoUpdate: true,
|
autoUpdate: true,
|
||||||
subscriptionOnlyLatestDefault: true,
|
subscriptionOnlyLatestDefault: true,
|
||||||
subscriptionCheckIntervalHours: 3,
|
enableAnalytics: true,
|
||||||
enableAnalytics: true
|
embedSubs: true,
|
||||||
|
embedThumbnail: true,
|
||||||
|
embedMetadata: true,
|
||||||
|
embedChapters: true
|
||||||
}
|
}
|
||||||
|
|||||||