Compare commits
55 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 | ||
|
|
e7627e1132 | ||
|
|
d7b131d795 | ||
|
|
74dbb223ce | ||
|
|
11a931f865 | ||
|
|
79a78d4322 | ||
|
|
a1c44baf65 | ||
|
|
86e6b93476 | ||
|
|
dbcd77963f | ||
|
|
58a4d2da36 | ||
|
|
db16d5d051 | ||
|
|
41f40b6e71 | ||
|
|
1ee717888f | ||
|
|
c7815eb81a | ||
|
|
3946b5ef2f | ||
|
|
ef99eb1f59 |
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"
|
||||||
|
|||||||
6
.gitignore
vendored
@@ -1,7 +1,9 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
/dist
|
||||||
out
|
out
|
||||||
|
.conductor/
|
||||||
|
.wxt
|
||||||
|
.output
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.log*
|
*.log*
|
||||||
|
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
@@ -25,5 +25,8 @@
|
|||||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||||
],
|
],
|
||||||
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
||||||
"i18n-ally.keystyle": "nested"
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
52
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>
|
||||||
@@ -19,7 +20,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs.
|
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
|
||||||
|
|
||||||
## 👋🏻 Getting Started
|
## 👋🏻 Getting Started
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -43,6 +40,35 @@ xattr -rd com.apple.quarantine /Applications/VidBee.app/
|
|||||||
|
|
||||||
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
This command removes the quarantine attribute that macOS applies to applications downloaded from the internet, allowing VidBee to run properly without the "file is damaged" error.
|
||||||
|
|
||||||
|
### 🌐 Browser Script (Quick Download)
|
||||||
|
|
||||||
|
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
|
||||||
|
1. Install a userscript manager extension:
|
||||||
|
- [Tampermonkey](https://www.tampermonkey.net/) (Recommended)
|
||||||
|
- [Violentmonkey](https://violentmonkey.github.io/)
|
||||||
|
- [Greasemonkey](https://www.greasespot.net/)
|
||||||
|
|
||||||
|
2. Install the VidBee Quick Download script:
|
||||||
|
- [Install from Greasy Fork](https://greasyfork.org/zh-CN/scripts/559595-vidbee-quick-download)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
After installation, when you visit supported video websites (YouTube, Bilibili, TikTok, Instagram, Twitter, etc.), a download button will appear on the page. Click the button to quickly send the video URL to VidBee desktop app for downloading.
|
||||||
|
|
||||||
|
**Supported Sites:**
|
||||||
|
|
||||||
|
The script works on popular video platforms including:
|
||||||
|
|
||||||
|
- YouTube, Bilibili, TikTok, Vimeo, Dailymotion
|
||||||
|
- Twitch, Twitter/X, Instagram, Facebook
|
||||||
|
- Reddit, SoundCloud, Niconico, Kick
|
||||||
|
- Bandcamp, Mixcloud, and more
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
|
||||||
@@ -70,6 +96,10 @@ Modern, clean interface with intuitive operations. One-click pause/resume/retry,
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 📡 RSS Auto Download
|
||||||
|
|
||||||
|
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
|
||||||
|
|
||||||
## 🌐 Supported Sites
|
## 🌐 Supported Sites
|
||||||
|
|
||||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||||
|
|||||||
13
biome.json
@@ -39,7 +39,18 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
|
"includes": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
"**/*.js",
|
||||||
|
"**/*.jsx",
|
||||||
|
"**/*.json",
|
||||||
|
"!monkey/dist",
|
||||||
|
"!dist",
|
||||||
|
"!out",
|
||||||
|
"!build"
|
||||||
|
],
|
||||||
|
"experimentalScannerIgnores": ["monkey/dist/**", "dist/**", "out/**", "build/**"]
|
||||||
},
|
},
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
6
conductor.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
|
||||||
|
"run": "pnpm run dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,23 @@ appId: com.vidbee
|
|||||||
productName: VidBee
|
productName: VidBee
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
afterPack: build/after-pack.cjs
|
||||||
|
protocols:
|
||||||
|
- name: VidBee
|
||||||
|
schemes:
|
||||||
|
- 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:
|
||||||
@@ -19,8 +29,10 @@ nsis:
|
|||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
createDesktopShortcut: always
|
createDesktopShortcut: always
|
||||||
mac:
|
mac:
|
||||||
|
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
|
||||||
@@ -45,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']
|
||||||
|
}
|
||||||
|
})
|
||||||
23
monkey/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name vidbee-quick-download
|
||||||
|
// @namespace vidbee
|
||||||
|
// @version 0.0.1
|
||||||
|
// @icon https://vidbee.org/favicon.svg
|
||||||
|
// @match https://www.youtube.com/*
|
||||||
|
// @match https://youtube.com/*
|
||||||
|
// @match https://music.youtube.com/*
|
||||||
|
// @match https://www.bilibili.com/*
|
||||||
|
// @match https://bilibili.com/*
|
||||||
|
// @match https://www.tiktok.com/*
|
||||||
|
// @match https://tiktok.com/*
|
||||||
|
// @match https://vimeo.com/*
|
||||||
|
// @match https://www.vimeo.com/*
|
||||||
|
// @match https://www.dailymotion.com/*
|
||||||
|
// @match https://dailymotion.com/*
|
||||||
|
// @match https://www.twitch.tv/*
|
||||||
|
// @match https://twitch.tv/*
|
||||||
|
// @match https://twitter.com/*
|
||||||
|
// @match https://www.twitter.com/*
|
||||||
|
// @match https://x.com/*
|
||||||
|
// @match https://www.x.com/*
|
||||||
|
// @match https://www.instagram.com/*
|
||||||
|
// @match https://instagram.com/*
|
||||||
|
// @match https://www.facebook.com/*
|
||||||
|
// @match https://facebook.com/*
|
||||||
|
// @match https://fb.com/*
|
||||||
|
// @match https://www.fb.com/*
|
||||||
|
// @match https://www.reddit.com/*
|
||||||
|
// @match https://reddit.com/*
|
||||||
|
// @match https://soundcloud.com/*
|
||||||
|
// @match https://www.soundcloud.com/*
|
||||||
|
// @match https://www.nicovideo.jp/*
|
||||||
|
// @match https://nicovideo.jp/*
|
||||||
|
// @match https://kick.com/*
|
||||||
|
// @match https://www.kick.com/*
|
||||||
|
// @match https://bandcamp.com/*
|
||||||
|
// @match https://*.bandcamp.com/*
|
||||||
|
// @match https://www.mixcloud.com/*
|
||||||
|
// @match https://mixcloud.com/*
|
||||||
|
// @grant GM_addStyle
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));};
|
||||||
|
|
||||||
|
const styleCss = '.vidbee-download-container{position:fixed;bottom:16px;right:16px;z-index:9999;transition:all .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:#00000080;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fffc;border:1px solid rgba(255,255,255,.1);border-radius:50%;box-shadow:0 2px 8px #0003;cursor:pointer;font-size:0;transition:all .2s ease;opacity:.6;overflow:visible}.vidbee-download-button:hover{opacity:1;background:#000000b3;border-color:#fff3;box-shadow:0 4px 12px #0000004d;transform:scale(1.1)}.vidbee-download-button:active{transform:scale(.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:#000000e6;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:500;white-space:nowrap;border-radius:4px;box-shadow:0 2px 8px #0000004d;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(-50%) translate(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:#000000e6}.vidbee-download-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(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:#ff4d4de6;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;box-shadow:0 2px 4px #0000004d;cursor:pointer;font-size:0;transition:all .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:#ff4d4d;transform:scale(1.15);box-shadow:0 3px 6px #0006}.vidbee-close-button:active{transform:scale(.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%) translate(4px)}.vidbee-close-button .vidbee-tooltip:after{left:100%;top:50%;right:auto;transform:translateY(-50%);border-left-color:#000000e6;border-top-color:transparent}.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}';
|
||||||
|
importCSS(styleCss);
|
||||||
|
function getVideoUrl() {
|
||||||
|
return window.location.href;
|
||||||
|
}
|
||||||
|
function hideButtonTemporarily() {
|
||||||
|
const container = document.getElementById("vidbee-download-btn");
|
||||||
|
if (container) {
|
||||||
|
container.classList.add("vidbee-hidden");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (container) {
|
||||||
|
container.classList.remove("vidbee-hidden");
|
||||||
|
}
|
||||||
|
}, 5e3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createVidBeeButton() {
|
||||||
|
if (document.getElementById("vidbee-download-btn")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const videoUrl = getVideoUrl();
|
||||||
|
if (!videoUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = "vidbee-download-btn";
|
||||||
|
container.className = "vidbee-download-container";
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "vidbee-download-button";
|
||||||
|
button.setAttribute("aria-label", "Download with VidBee");
|
||||||
|
button.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||||
|
`;
|
||||||
|
const closeButton = document.createElement("button");
|
||||||
|
closeButton.className = "vidbee-close-button";
|
||||||
|
closeButton.setAttribute("aria-label", "Hide button");
|
||||||
|
closeButton.innerHTML = `
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="vidbee-tooltip">Hide</span>
|
||||||
|
`;
|
||||||
|
closeButton.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
hideButtonTemporarily();
|
||||||
|
});
|
||||||
|
let clickTimer = null;
|
||||||
|
let clickCount = 0;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
clickCount++;
|
||||||
|
if (clickCount === 1) {
|
||||||
|
clickTimer = window.setTimeout(() => {
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`;
|
||||||
|
window.location.href = vidbeeUrl;
|
||||||
|
clickCount = 0;
|
||||||
|
}, 300);
|
||||||
|
} else if (clickCount === 2) {
|
||||||
|
if (clickTimer !== null) {
|
||||||
|
clearTimeout(clickTimer);
|
||||||
|
}
|
||||||
|
clickCount = 0;
|
||||||
|
hideButtonTemporarily();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(button);
|
||||||
|
container.appendChild(closeButton);
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
function init() {
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", createVidBeeButton);
|
||||||
|
} else {
|
||||||
|
createVidBeeButton();
|
||||||
|
}
|
||||||
|
let lastUrl = location.href;
|
||||||
|
let urlCheckTimer = null;
|
||||||
|
const checkUrlChange = () => {
|
||||||
|
const currentUrl = location.href;
|
||||||
|
if (currentUrl !== lastUrl) {
|
||||||
|
lastUrl = currentUrl;
|
||||||
|
const oldButton = document.getElementById("vidbee-download-btn");
|
||||||
|
if (oldButton) {
|
||||||
|
oldButton.remove();
|
||||||
|
}
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const delay = hostname.includes("bilibili.com") ? 800 : 500;
|
||||||
|
setTimeout(createVidBeeButton, delay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new MutationObserver(() => {
|
||||||
|
if (urlCheckTimer !== null) {
|
||||||
|
clearTimeout(urlCheckTimer);
|
||||||
|
}
|
||||||
|
urlCheckTimer = window.setTimeout(checkUrlChange, 100);
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
setTimeout(checkUrlChange, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
|
||||||
|
})();
|
||||||
16
monkey/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "vidbee-quick-download",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^7.1.3",
|
||||||
|
"vite-plugin-monkey": "^7.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
943
monkey/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.2
|
||||||
|
version: 5.9.3
|
||||||
|
vite:
|
||||||
|
specifier: ^7.1.3
|
||||||
|
version: 7.3.0
|
||||||
|
vite-plugin-monkey:
|
||||||
|
specifier: ^7.1.1
|
||||||
|
version: 7.1.8(postcss@8.5.6)(vite@7.3.0)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.2':
|
||||||
|
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.2':
|
||||||
|
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.2':
|
||||||
|
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.2':
|
||||||
|
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.2':
|
||||||
|
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.2':
|
||||||
|
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.2':
|
||||||
|
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||||
|
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||||
|
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||||
|
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||||
|
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||||
|
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||||
|
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||||
|
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||||
|
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||||
|
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||||
|
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||||
|
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/estree@1.0.8':
|
||||||
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
acorn-walk@8.3.4:
|
||||||
|
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
acorn@8.15.0:
|
||||||
|
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
balanced-match@1.0.2:
|
||||||
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
brace-expansion@1.1.12:
|
||||||
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
concat-map@0.0.1:
|
||||||
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
cuint@0.2.2:
|
||||||
|
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1:
|
||||||
|
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
default-browser@5.4.0:
|
||||||
|
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0:
|
||||||
|
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
dom-serializer@2.0.0:
|
||||||
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
|
domelementtype@2.3.0:
|
||||||
|
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||||
|
|
||||||
|
domhandler@5.0.3:
|
||||||
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
domutils@3.2.2:
|
||||||
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
|
entities@4.5.0:
|
||||||
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
esbuild@0.27.2:
|
||||||
|
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fdir@6.5.0:
|
||||||
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
picomatch: ^3 || ^4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
picomatch:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
htmlparser2@10.0.0:
|
||||||
|
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||||
|
|
||||||
|
import-meta-resolve@4.2.0:
|
||||||
|
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
|
||||||
|
|
||||||
|
is-docker@3.0.0:
|
||||||
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
is-wsl@3.1.0:
|
||||||
|
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
isexe@2.0.0:
|
||||||
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
make-dir@3.1.0:
|
||||||
|
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
mime@2.5.2:
|
||||||
|
resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
minimatch@3.0.8:
|
||||||
|
resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==}
|
||||||
|
|
||||||
|
mrmime@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
nanoid@3.3.11:
|
||||||
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
open@10.2.0:
|
||||||
|
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
path-key@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@4.0.3:
|
||||||
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
postcss-url@10.1.3:
|
||||||
|
resolution: {integrity: sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
peerDependencies:
|
||||||
|
postcss: ^8.0.0
|
||||||
|
|
||||||
|
postcss@8.5.6:
|
||||||
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rollup@4.54.0:
|
||||||
|
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
run-applescript@7.1.0:
|
||||||
|
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
semver@6.3.1:
|
||||||
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shebang-regex@3.0.0:
|
||||||
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
systemjs@6.15.1:
|
||||||
|
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
vite-plugin-monkey@7.1.8:
|
||||||
|
resolution: {integrity: sha512-FGz1jlHcodt+L120UcHLiN0jXqQZdZHxLuHcGQg8Zjas6BulxLM1oiGUg3M95HbjYVFH6ygrOJoZ5YCYu1purw==}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^6.0.0 || ^7.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vite@7.3.0:
|
||||||
|
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^20.19.0 || >=22.12.0
|
||||||
|
jiti: '>=1.21.0'
|
||||||
|
less: ^4.0.0
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: ^1.70.0
|
||||||
|
sass-embedded: ^1.70.0
|
||||||
|
stylus: '>=0.54.8'
|
||||||
|
sugarss: ^5.0.0
|
||||||
|
terser: ^5.16.0
|
||||||
|
tsx: ^4.8.1
|
||||||
|
yaml: ^2.4.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
jiti:
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
tsx:
|
||||||
|
optional: true
|
||||||
|
yaml:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
wsl-utils@0.1.0:
|
||||||
|
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
xxhashjs@0.2.2:
|
||||||
|
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.2':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
acorn-walk@8.3.4:
|
||||||
|
dependencies:
|
||||||
|
acorn: 8.15.0
|
||||||
|
|
||||||
|
acorn@8.15.0: {}
|
||||||
|
|
||||||
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
brace-expansion@1.1.12:
|
||||||
|
dependencies:
|
||||||
|
balanced-match: 1.0.2
|
||||||
|
concat-map: 0.0.1
|
||||||
|
|
||||||
|
bundle-name@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
run-applescript: 7.1.0
|
||||||
|
|
||||||
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
dependencies:
|
||||||
|
path-key: 3.1.1
|
||||||
|
shebang-command: 2.0.0
|
||||||
|
which: 2.0.2
|
||||||
|
|
||||||
|
cuint@0.2.2: {}
|
||||||
|
|
||||||
|
default-browser-id@5.0.1: {}
|
||||||
|
|
||||||
|
default-browser@5.4.0:
|
||||||
|
dependencies:
|
||||||
|
bundle-name: 4.1.0
|
||||||
|
default-browser-id: 5.0.1
|
||||||
|
|
||||||
|
define-lazy-prop@3.0.0: {}
|
||||||
|
|
||||||
|
dom-serializer@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
entities: 4.5.0
|
||||||
|
|
||||||
|
domelementtype@2.3.0: {}
|
||||||
|
|
||||||
|
domhandler@5.0.3:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
domutils@3.2.2:
|
||||||
|
dependencies:
|
||||||
|
dom-serializer: 2.0.0
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
|
||||||
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
esbuild@0.27.2:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.2
|
||||||
|
'@esbuild/android-arm': 0.27.2
|
||||||
|
'@esbuild/android-arm64': 0.27.2
|
||||||
|
'@esbuild/android-x64': 0.27.2
|
||||||
|
'@esbuild/darwin-arm64': 0.27.2
|
||||||
|
'@esbuild/darwin-x64': 0.27.2
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.2
|
||||||
|
'@esbuild/freebsd-x64': 0.27.2
|
||||||
|
'@esbuild/linux-arm': 0.27.2
|
||||||
|
'@esbuild/linux-arm64': 0.27.2
|
||||||
|
'@esbuild/linux-ia32': 0.27.2
|
||||||
|
'@esbuild/linux-loong64': 0.27.2
|
||||||
|
'@esbuild/linux-mips64el': 0.27.2
|
||||||
|
'@esbuild/linux-ppc64': 0.27.2
|
||||||
|
'@esbuild/linux-riscv64': 0.27.2
|
||||||
|
'@esbuild/linux-s390x': 0.27.2
|
||||||
|
'@esbuild/linux-x64': 0.27.2
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/netbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.2
|
||||||
|
'@esbuild/openbsd-x64': 0.27.2
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.2
|
||||||
|
'@esbuild/sunos-x64': 0.27.2
|
||||||
|
'@esbuild/win32-arm64': 0.27.2
|
||||||
|
'@esbuild/win32-ia32': 0.27.2
|
||||||
|
'@esbuild/win32-x64': 0.27.2
|
||||||
|
|
||||||
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
|
optionalDependencies:
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
htmlparser2@10.0.0:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
|
import-meta-resolve@4.2.0: {}
|
||||||
|
|
||||||
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
|
is-inside-container@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
is-docker: 3.0.0
|
||||||
|
|
||||||
|
is-wsl@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
|
||||||
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
make-dir@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
semver: 6.3.1
|
||||||
|
|
||||||
|
mime@2.5.2: {}
|
||||||
|
|
||||||
|
minimatch@3.0.8:
|
||||||
|
dependencies:
|
||||||
|
brace-expansion: 1.1.12
|
||||||
|
|
||||||
|
mrmime@2.0.1: {}
|
||||||
|
|
||||||
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
open@10.2.0:
|
||||||
|
dependencies:
|
||||||
|
default-browser: 5.4.0
|
||||||
|
define-lazy-prop: 3.0.0
|
||||||
|
is-inside-container: 1.0.0
|
||||||
|
wsl-utils: 0.1.0
|
||||||
|
|
||||||
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
postcss-url@10.1.3(postcss@8.5.6):
|
||||||
|
dependencies:
|
||||||
|
make-dir: 3.1.0
|
||||||
|
mime: 2.5.2
|
||||||
|
minimatch: 3.0.8
|
||||||
|
postcss: 8.5.6
|
||||||
|
xxhashjs: 0.2.2
|
||||||
|
|
||||||
|
postcss@8.5.6:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.11
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rollup@4.54.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.54.0
|
||||||
|
'@rollup/rollup-android-arm64': 4.54.0
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.54.0
|
||||||
|
'@rollup/rollup-darwin-x64': 4.54.0
|
||||||
|
'@rollup/rollup-freebsd-arm64': 4.54.0
|
||||||
|
'@rollup/rollup-freebsd-x64': 4.54.0
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.54.0
|
||||||
|
'@rollup/rollup-linux-loong64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-riscv64-musl': 4.54.0
|
||||||
|
'@rollup/rollup-linux-s390x-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.54.0
|
||||||
|
'@rollup/rollup-openharmony-arm64': 4.54.0
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.54.0
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.54.0
|
||||||
|
'@rollup/rollup-win32-x64-gnu': 4.54.0
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.54.0
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
run-applescript@7.1.0: {}
|
||||||
|
|
||||||
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
shebang-command@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
shebang-regex: 3.0.0
|
||||||
|
|
||||||
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
systemjs@6.15.1: {}
|
||||||
|
|
||||||
|
tinyglobby@0.2.15:
|
||||||
|
dependencies:
|
||||||
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
vite-plugin-monkey@7.1.8(postcss@8.5.6)(vite@7.3.0):
|
||||||
|
dependencies:
|
||||||
|
acorn: 8.15.0
|
||||||
|
acorn-walk: 8.3.4
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
htmlparser2: 10.0.0
|
||||||
|
import-meta-resolve: 4.2.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
mrmime: 2.0.1
|
||||||
|
open: 10.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
postcss-url: 10.1.3(postcss@8.5.6)
|
||||||
|
systemjs: 6.15.1
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 7.3.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- postcss
|
||||||
|
|
||||||
|
vite@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.27.2
|
||||||
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
|
picomatch: 4.0.3
|
||||||
|
postcss: 8.5.6
|
||||||
|
rollup: 4.54.0
|
||||||
|
tinyglobby: 0.2.15
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
which@2.0.2:
|
||||||
|
dependencies:
|
||||||
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wsl-utils@0.1.0:
|
||||||
|
dependencies:
|
||||||
|
is-wsl: 3.1.0
|
||||||
|
|
||||||
|
xxhashjs@0.2.2:
|
||||||
|
dependencies:
|
||||||
|
cuint: 0.2.2
|
||||||
146
monkey/src/main.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import './style.css'
|
||||||
|
|
||||||
|
// Get current video URL
|
||||||
|
// yt-dlp can handle all URLs directly, so we just return the current URL
|
||||||
|
function getVideoUrl(): string | null {
|
||||||
|
return window.location.href
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily hide button
|
||||||
|
function hideButtonTemporarily(): void {
|
||||||
|
const container = document.getElementById('vidbee-download-btn')
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('vidbee-hidden')
|
||||||
|
// Auto restore after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
if (container) {
|
||||||
|
container.classList.remove('vidbee-hidden')
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create VidBee download button
|
||||||
|
function createVidBeeButton(): void {
|
||||||
|
// Check if button already exists
|
||||||
|
if (document.getElementById('vidbee-download-btn')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoUrl = getVideoUrl()
|
||||||
|
if (!videoUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create button container
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.id = 'vidbee-download-btn'
|
||||||
|
container.className = 'vidbee-download-container'
|
||||||
|
|
||||||
|
// Create main download button
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.className = 'vidbee-download-button'
|
||||||
|
button.setAttribute('aria-label', 'Download with VidBee')
|
||||||
|
button.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Create close button
|
||||||
|
const closeButton = document.createElement('button')
|
||||||
|
closeButton.className = 'vidbee-close-button'
|
||||||
|
closeButton.setAttribute('aria-label', 'Hide button')
|
||||||
|
closeButton.innerHTML = `
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
<span class="vidbee-tooltip">Hide</span>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Handle close button click - temporarily hide
|
||||||
|
closeButton.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
hideButtonTemporarily()
|
||||||
|
})
|
||||||
|
|
||||||
|
let clickTimer: number | null = null
|
||||||
|
let clickCount = 0
|
||||||
|
|
||||||
|
// Handle main button click event - single click for download
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
clickCount++
|
||||||
|
|
||||||
|
if (clickCount === 1) {
|
||||||
|
clickTimer = window.setTimeout(() => {
|
||||||
|
// Single click - trigger download
|
||||||
|
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||||
|
window.location.href = vidbeeUrl
|
||||||
|
clickCount = 0
|
||||||
|
}, 300)
|
||||||
|
} else if (clickCount === 2) {
|
||||||
|
// Double click - temporarily hide
|
||||||
|
if (clickTimer !== null) {
|
||||||
|
clearTimeout(clickTimer)
|
||||||
|
}
|
||||||
|
clickCount = 0
|
||||||
|
hideButtonTemporarily()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assemble container
|
||||||
|
container.appendChild(button)
|
||||||
|
container.appendChild(closeButton)
|
||||||
|
|
||||||
|
// Insert container directly to body (fixed position)
|
||||||
|
document.body.appendChild(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when page loads
|
||||||
|
function init(): void {
|
||||||
|
// Wait for page to fully load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', createVidBeeButton)
|
||||||
|
} else {
|
||||||
|
createVidBeeButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SPA navigation (when navigating between videos on sites like YouTube, Bilibili, etc.)
|
||||||
|
let lastUrl = location.href
|
||||||
|
let urlCheckTimer: number | null = null
|
||||||
|
|
||||||
|
const checkUrlChange = () => {
|
||||||
|
const currentUrl = location.href
|
||||||
|
if (currentUrl !== lastUrl) {
|
||||||
|
lastUrl = currentUrl
|
||||||
|
// Remove old button and create new one
|
||||||
|
const oldButton = document.getElementById('vidbee-download-btn')
|
||||||
|
if (oldButton) {
|
||||||
|
oldButton.remove()
|
||||||
|
}
|
||||||
|
// Wait a bit for the page to update (different sites have different update speeds)
|
||||||
|
const hostname = window.location.hostname
|
||||||
|
const delay = hostname.includes('bilibili.com') ? 800 : 500
|
||||||
|
setTimeout(createVidBeeButton, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use MutationObserver for DOM changes (works for most SPA sites)
|
||||||
|
new MutationObserver(() => {
|
||||||
|
if (urlCheckTimer !== null) {
|
||||||
|
clearTimeout(urlCheckTimer)
|
||||||
|
}
|
||||||
|
urlCheckTimer = window.setTimeout(checkUrlChange, 100)
|
||||||
|
}).observe(document.body, { childList: true, subtree: true })
|
||||||
|
|
||||||
|
// Also listen to popstate for browser navigation
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
setTimeout(checkUrlChange, 300)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
169
monkey/src/style.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* VidBee Download Button Container */
|
||||||
|
.vidbee-download-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hidden state */
|
||||||
|
.vidbee-download-container.vidbee-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Download Button */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close Button - Hidden by default */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show close button on container hover */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Close button tooltip positioning */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
4
monkey/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-monkey/client" />
|
||||||
|
//// <reference types="vite-plugin-monkey/global" />
|
||||||
|
/// <reference types="vite-plugin-monkey/style" />
|
||||||
24
monkey/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
monkey/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
67
monkey/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import monkey from 'vite-plugin-monkey'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
monkey({
|
||||||
|
entry: 'src/main.ts',
|
||||||
|
userscript: {
|
||||||
|
icon: 'https://vidbee.org/favicon.svg',
|
||||||
|
namespace: 'vidbee',
|
||||||
|
match: [
|
||||||
|
// YouTube
|
||||||
|
'https://www.youtube.com/*',
|
||||||
|
'https://youtube.com/*',
|
||||||
|
'https://music.youtube.com/*',
|
||||||
|
// Bilibili (哔哩哔哩)
|
||||||
|
'https://www.bilibili.com/*',
|
||||||
|
'https://bilibili.com/*',
|
||||||
|
// TikTok
|
||||||
|
'https://www.tiktok.com/*',
|
||||||
|
'https://tiktok.com/*',
|
||||||
|
// Vimeo
|
||||||
|
'https://vimeo.com/*',
|
||||||
|
'https://www.vimeo.com/*',
|
||||||
|
// Dailymotion
|
||||||
|
'https://www.dailymotion.com/*',
|
||||||
|
'https://dailymotion.com/*',
|
||||||
|
// Twitch
|
||||||
|
'https://www.twitch.tv/*',
|
||||||
|
'https://twitch.tv/*',
|
||||||
|
// Twitter/X
|
||||||
|
'https://twitter.com/*',
|
||||||
|
'https://www.twitter.com/*',
|
||||||
|
'https://x.com/*',
|
||||||
|
'https://www.x.com/*',
|
||||||
|
// Instagram
|
||||||
|
'https://www.instagram.com/*',
|
||||||
|
'https://instagram.com/*',
|
||||||
|
// Facebook
|
||||||
|
'https://www.facebook.com/*',
|
||||||
|
'https://facebook.com/*',
|
||||||
|
'https://fb.com/*',
|
||||||
|
'https://www.fb.com/*',
|
||||||
|
// Reddit
|
||||||
|
'https://www.reddit.com/*',
|
||||||
|
'https://reddit.com/*',
|
||||||
|
// SoundCloud
|
||||||
|
'https://soundcloud.com/*',
|
||||||
|
'https://www.soundcloud.com/*',
|
||||||
|
// NicoNico
|
||||||
|
'https://www.nicovideo.jp/*',
|
||||||
|
'https://nicovideo.jp/*',
|
||||||
|
// Kick
|
||||||
|
'https://kick.com/*',
|
||||||
|
'https://www.kick.com/*',
|
||||||
|
// Bandcamp
|
||||||
|
'https://bandcamp.com/*',
|
||||||
|
'https://*.bandcamp.com/*',
|
||||||
|
// Mixcloud
|
||||||
|
'https://www.mixcloud.com/*',
|
||||||
|
'https://mixcloud.com/*'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
12
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vidbee",
|
"name": "vidbee",
|
||||||
"version": "1.0.1",
|
"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.
|
||||||
|
|||||||
BIN
screenshots/browser-script.png
Normal file
|
After Width: | Height: | Size: 438 KiB |
@@ -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,16 @@
|
|||||||
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 =
|
||||||
|
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||||
|
|
||||||
// Platform configuration
|
// Platform configuration
|
||||||
const PLATFORM_CONFIG = {
|
const PLATFORM_CONFIG = {
|
||||||
@@ -27,7 +30,12 @@ const PLATFORM_CONFIG = {
|
|||||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
||||||
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||||
output: 'ffmpeg.exe',
|
output: 'ffmpeg.exe',
|
||||||
extract: 'unzip'
|
extract: 'unzip',
|
||||||
|
release: {
|
||||||
|
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||||
|
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||||
|
binaryName: 'ffmpeg.exe'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
darwin: {
|
darwin: {
|
||||||
@@ -41,13 +49,21 @@ const PLATFORM_CONFIG = {
|
|||||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||||
innerPath: 'ffmpeg/ffmpeg',
|
innerPath: 'ffmpeg/ffmpeg',
|
||||||
output: 'ffmpeg_macos',
|
output: 'ffmpeg_macos',
|
||||||
extract: 'unzip'
|
extract: 'unzip',
|
||||||
|
release: {
|
||||||
|
repo: 'eko5624/mpv-mac',
|
||||||
|
assetPattern: /ffmpeg-arm64.*\.zip$/i
|
||||||
|
}
|
||||||
},
|
},
|
||||||
x64: {
|
x64: {
|
||||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
||||||
innerPath: 'ffmpeg/ffmpeg',
|
innerPath: 'ffmpeg/ffmpeg',
|
||||||
output: 'ffmpeg_macos',
|
output: 'ffmpeg_macos',
|
||||||
extract: 'unzip'
|
extract: 'unzip',
|
||||||
|
release: {
|
||||||
|
repo: 'eko5624/mpv-mac',
|
||||||
|
assetPattern: /ffmpeg-x86_64.*\.zip$/i
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -60,7 +76,12 @@ const PLATFORM_CONFIG = {
|
|||||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
||||||
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||||
output: 'ffmpeg_linux',
|
output: 'ffmpeg_linux',
|
||||||
extract: 'tar'
|
extract: 'tar',
|
||||||
|
release: {
|
||||||
|
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||||
|
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||||
|
binaryName: 'ffmpeg'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,40 +104,180 @@ 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
|
||||||
|
|
||||||
|
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||||
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
|
// Handle redirect
|
||||||
|
file.close()
|
||||||
|
safeUnlink(dest)
|
||||||
|
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) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': 'vidbee-setup',
|
||||||
|
Accept: 'application/vnd.github+json'
|
||||||
|
}
|
||||||
|
if (GITHUB_TOKEN) {
|
||||||
|
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
|
||||||
|
}
|
||||||
|
|
||||||
protocol
|
protocol
|
||||||
.get(url, (response) => {
|
.get(url, { headers }, (response) => {
|
||||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||||
// Handle redirect
|
return fetchJson(response.headers.location).then(resolve).catch(reject)
|
||||||
file.close()
|
|
||||||
fs.unlinkSync(dest)
|
|
||||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (response.statusCode !== 200) {
|
||||||
file.close()
|
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
|
||||||
fs.unlinkSync(dest)
|
|
||||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response.pipe(file)
|
let body = ''
|
||||||
file.on('finish', () => {
|
response.on('data', (chunk) => {
|
||||||
file.close()
|
body += chunk
|
||||||
resolve()
|
})
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.on('error', (err) => {
|
.on('error', (err) => {
|
||||||
file.close()
|
|
||||||
fs.unlinkSync(dest)
|
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferFfmpegInnerPath(assetName, binaryName) {
|
||||||
|
if (!assetName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return `${match[1]}/bin/${binaryName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveReleaseAsset(release) {
|
||||||
|
if (!release) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
|
||||||
|
if (repoCandidates.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError
|
||||||
|
for (const repo of repoCandidates) {
|
||||||
|
try {
|
||||||
|
const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
|
||||||
|
const assets = Array.isArray(data.assets) ? data.assets : []
|
||||||
|
const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
|
||||||
|
if (match?.browser_download_url) {
|
||||||
|
return { name: match.name, url: match.browser_download_url }
|
||||||
|
}
|
||||||
|
lastError = new Error(`No matching assets found in ${repo}`)
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function extractZip(zipPath, extractDir) {
|
function extractZip(zipPath, extractDir) {
|
||||||
const platform = os.platform()
|
const platform = os.platform()
|
||||||
ensureDir(extractDir)
|
ensureDir(extractDir)
|
||||||
@@ -158,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
|
||||||
}
|
}
|
||||||
@@ -173,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)) {
|
||||||
@@ -186,10 +413,14 @@ async function downloadYtDlp(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFfmpegWindows(config) {
|
async function downloadFfmpegWindows(config) {
|
||||||
const { url, innerPath, output } = config.ffmpeg
|
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -197,9 +428,26 @@ async function downloadFfmpegWindows(config) {
|
|||||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
|
let downloadUrl = fallbackUrl
|
||||||
|
let innerPath = fallbackInnerPath
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveReleaseAsset(release)
|
||||||
|
if (resolved) {
|
||||||
|
downloadUrl = resolved.url
|
||||||
|
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
|
||||||
|
if (inferred) {
|
||||||
|
innerPath = inferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -209,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
|
||||||
@@ -229,10 +482,14 @@ async function downloadFfmpegMac(config) {
|
|||||||
throw new Error(`Unsupported architecture: ${arch}`)
|
throw new Error(`Unsupported architecture: ${arch}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url, innerPath, output } = ffmpegConfig
|
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -240,9 +497,21 @@ async function downloadFfmpegMac(config) {
|
|||||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
|
let downloadUrl = fallbackUrl
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveReleaseAsset(release)
|
||||||
|
if (resolved) {
|
||||||
|
downloadUrl = resolved.url
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, tempZip)
|
await downloadFileWithRetry(downloadUrl, tempZip)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractZip(tempZip, extractDir)
|
extractZip(tempZip, extractDir)
|
||||||
|
|
||||||
@@ -253,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
|
||||||
@@ -266,10 +540,14 @@ async function downloadFfmpegMac(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFfmpegLinux(config) {
|
async function downloadFfmpegLinux(config) {
|
||||||
const { url, innerPath, output } = config.ffmpeg
|
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -277,9 +555,26 @@ async function downloadFfmpegLinux(config) {
|
|||||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||||
|
let downloadUrl = fallbackUrl
|
||||||
|
let innerPath = fallbackInnerPath
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveReleaseAsset(release)
|
||||||
|
if (resolved) {
|
||||||
|
downloadUrl = resolved.url
|
||||||
|
const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
|
||||||
|
if (inferred) {
|
||||||
|
innerPath = inferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadFile(url, tempTar)
|
await downloadFileWithRetry(downloadUrl, tempTar)
|
||||||
log('Extracting ffmpeg...', 'info')
|
log('Extracting ffmpeg...', 'info')
|
||||||
extractTarXz(tempTar, extractDir)
|
extractTarXz(tempTar, extractDir)
|
||||||
|
|
||||||
@@ -290,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
|
||||||
@@ -302,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()
|
||||||
@@ -319,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
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||||
|
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||||
|
|
||||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||||
const trimmed = template.trim()
|
const trimmed = template.trim()
|
||||||
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
if (!trimmed) {
|
||||||
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
return '%(title)s via VidBee.%(ext)s'
|
||||||
|
}
|
||||||
|
const normalized = trimmed.replace(/\\/g, '/')
|
||||||
|
const safeParts = normalized
|
||||||
|
.split('/')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part !== '' && part !== '.' && part !== '..')
|
||||||
|
.map((part) => part.replace(/[<>:"|?*]/g, '-').replace(/[. ]+$/g, ''))
|
||||||
|
.filter((part) => part !== '')
|
||||||
|
return safeParts.length === 0 ? '%(title)s via VidBee.%(ext)s' : safeParts.join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
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
|
||||||
}
|
}
|
||||||
@@ -20,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}`
|
||||||
}
|
}
|
||||||
@@ -51,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')
|
||||||
@@ -80,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(
|
||||||
@@ -115,8 +145,13 @@ export const buildDownloadArgs = (
|
|||||||
args.push('--proxy', settings.proxy)
|
args.push('--proxy', settings.proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.configPath) {
|
const configPath = resolvePathWithHome(settings.configPath)
|
||||||
args.push('--config-location', settings.configPath)
|
if (configPath) {
|
||||||
|
args.push('--config-location', configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsRuntimeArgs.length > 0) {
|
||||||
|
args.push(...jsRuntimeArgs)
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push(options.url)
|
args.push(options.url)
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { join } from 'node:path'
|
import { existsSync } from 'node:fs'
|
||||||
|
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||||
import { app, BrowserWindow, type BrowserWindowConstructorOptions, shell } from 'electron'
|
import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
|
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'
|
||||||
@@ -11,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'
|
||||||
@@ -22,8 +32,103 @@ log.initialize()
|
|||||||
// Configure logger settings
|
// Configure logger settings
|
||||||
configureLogger()
|
configureLogger()
|
||||||
|
|
||||||
|
const RENDERER_DIST_PATH = join(__dirname, '../renderer')
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{
|
||||||
|
scheme: APP_PROTOCOL,
|
||||||
|
privileges: {
|
||||||
|
secure: true,
|
||||||
|
standard: true,
|
||||||
|
supportFetchAPI: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
|
interface DeepLinkData {
|
||||||
|
url: string
|
||||||
|
type: 'single' | 'playlist'
|
||||||
|
}
|
||||||
|
const pendingDeepLinkUrls: DeepLinkData[] = []
|
||||||
|
let isRendererReady = false
|
||||||
|
|
||||||
|
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl)
|
||||||
|
if (parsed.protocol !== `${APP_PROTOCOL}:`) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname
|
||||||
|
const path = parsed.pathname.replace(/^\/+/, '')
|
||||||
|
const isDownloadLink = host === 'download' || path.startsWith('download')
|
||||||
|
if (!isDownloadLink) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = parsed.searchParams.get('url')
|
||||||
|
if (!targetUrl || !targetUrl.trim()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeParam = parsed.searchParams.get('type')
|
||||||
|
const type = typeParam === 'playlist' ? 'playlist' : 'single'
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: targetUrl.trim(),
|
||||||
|
type
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('Failed to parse deep link:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliverDeepLink = (data: DeepLinkData): void => {
|
||||||
|
if (!mainWindow || !isRendererReady) {
|
||||||
|
pendingDeepLinkUrls.push(data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore()
|
||||||
|
}
|
||||||
|
if (!mainWindow.isVisible()) {
|
||||||
|
mainWindow.show()
|
||||||
|
}
|
||||||
|
mainWindow.focus()
|
||||||
|
mainWindow.webContents.send('download:deeplink', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushPendingDeepLinks = (): void => {
|
||||||
|
if (!mainWindow || !isRendererReady || pendingDeepLinkUrls.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = pendingDeepLinkUrls.splice(0, pendingDeepLinkUrls.length)
|
||||||
|
for (const data of pending) {
|
||||||
|
mainWindow.webContents.send('download:deeplink', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeepLinkUrl = (rawUrl: string): void => {
|
||||||
|
const data = parseDownloadDeepLink(rawUrl)
|
||||||
|
if (!data) {
|
||||||
|
log.warn('Ignored unsupported deep link:', rawUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deliverDeepLink(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeepLinkArgv = (argv: string[]): void => {
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg.startsWith(`${APP_PROTOCOL}://`)) {
|
||||||
|
handleDeepLinkUrl(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||||
@@ -32,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,
|
||||||
@@ -71,6 +177,9 @@ export function createWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
if (shouldStartHidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,17 +193,57 @@ export function createWindow(): void {
|
|||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadURL(`${APP_PROTOCOL_SCHEME}renderer/index.html`)
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.webContents.on('did-finish-load', () => {
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||||
|
isRendererReady = true
|
||||||
|
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)
|
||||||
@@ -117,6 +266,66 @@ function setupDownloadEvents(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeRequestPath(requestUrl: URL): string {
|
||||||
|
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
|
||||||
|
const trimmedLeading = rawPath.replace(/^\/+/, '')
|
||||||
|
const cleaned = trimmedLeading.replace(/\/+$/, '')
|
||||||
|
return cleaned || 'index.html'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinBase(targetPath: string, basePath: string): boolean {
|
||||||
|
const relativePath = relative(basePath, targetPath)
|
||||||
|
return !relativePath.startsWith('..') && !isAbsolute(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVidbeeFilePath(requestUrl: URL, userDataPath: string): string | null {
|
||||||
|
const sanitizedPath = sanitizeRequestPath(requestUrl)
|
||||||
|
const [rootSegment, ...restSegments] = sanitizedPath.split('/')
|
||||||
|
const rendererPath = restSegments.join('/') || 'index.html'
|
||||||
|
|
||||||
|
if (rootSegment === 'renderer') {
|
||||||
|
const rendererTarget = resolve(RENDERER_DIST_PATH, rendererPath)
|
||||||
|
|
||||||
|
if (isWithinBase(rendererTarget, RENDERER_DIST_PATH) && existsSync(rendererTarget)) {
|
||||||
|
return rendererTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDataTarget = resolve(userDataPath, sanitizedPath)
|
||||||
|
|
||||||
|
if (isWithinBase(userDataTarget, userDataPath) && existsSync(userDataTarget)) {
|
||||||
|
return userDataTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendererFallback = resolve(RENDERER_DIST_PATH, sanitizedPath)
|
||||||
|
|
||||||
|
if (isWithinBase(rendererFallback, RENDERER_DIST_PATH) && existsSync(rendererFallback)) {
|
||||||
|
return rendererFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerVidbeeProtocol(): void {
|
||||||
|
try {
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
protocol.registerFileProtocol(APP_PROTOCOL, (request, callback) => {
|
||||||
|
const requestUrl = new URL(request.url)
|
||||||
|
const filePath = resolveVidbeeFilePath(requestUrl, userDataPath)
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
log.error(`File not found for ${request.url}`)
|
||||||
|
callback({ error: -6 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(filePath)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to register ${APP_PROTOCOL} protocol:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initAutoUpdater(): void {
|
function initAutoUpdater(): void {
|
||||||
try {
|
try {
|
||||||
log.info('Initializing auto-updater...')
|
log.info('Initializing auto-updater...')
|
||||||
@@ -155,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')
|
||||||
@@ -182,6 +383,28 @@ function initAutoUpdater(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
handleDeepLinkArgv(argv)
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) {
|
||||||
|
mainWindow.restore()
|
||||||
|
}
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('open-url', (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
handleDeepLinkUrl(url)
|
||||||
|
})
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
// This method will be called when Electron has finished
|
||||||
// initialization and is ready to create browser windows.
|
// initialization and is ready to create browser windows.
|
||||||
// Some APIs can only be used after this event occurs.
|
// Some APIs can only be used after this event occurs.
|
||||||
@@ -189,10 +412,28 @@ app.whenReady().then(async () => {
|
|||||||
// Set app user model id for windows
|
// Set app user model id for windows
|
||||||
electronApp.setAppUserModelId('com.vidbee')
|
electronApp.setAppUserModelId('com.vidbee')
|
||||||
|
|
||||||
|
registerVidbeeProtocol()
|
||||||
|
|
||||||
|
const registered = app.setAsDefaultProtocolClient(APP_PROTOCOL)
|
||||||
|
if (!registered) {
|
||||||
|
log.warn(`Failed to register ${APP_PROTOCOL} protocol handler`)
|
||||||
|
}
|
||||||
|
|
||||||
// Default open or close DevTools by F12 in development
|
// Default open or close DevTools by F12 in development
|
||||||
// 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
|
||||||
@@ -216,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'))
|
||||||
|
|
||||||
@@ -228,6 +471,8 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
subscriptionScheduler.start()
|
subscriptionScheduler.start()
|
||||||
|
|
||||||
|
handleDeepLinkArgv(process.argv)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// On macOS it's common to re-create a window in the app when the
|
||||||
// dock icon is clicked and there are no other windows open.
|
// dock icon is clicked and there are no other windows open.
|
||||||
@@ -261,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
|
|||||||
return historyManager.removeHistoryItem(id)
|
return historyManager.removeHistoryItem(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
removeHistoryItems(_context: IpcContext, ids: string[]): number {
|
||||||
|
return historyManager.removeHistoryItems(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
|
||||||
|
return historyManager.removeHistoryByPlaylistId(playlistId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
clearHistory(_context: IpcContext): void {
|
||||||
|
historyManager.clearHistory()
|
||||||
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
getHistoryCount(_context: IpcContext): {
|
getHistoryCount(_context: IpcContext): {
|
||||||
active: number
|
active: number
|
||||||
|
|||||||
@@ -1,7 +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 { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
|
||||||
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'
|
||||||
@@ -12,20 +10,12 @@ class SettingsService extends IpcService {
|
|||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||||
const value = settingsManager.get(key)
|
return settingsManager.get(key)
|
||||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
|
||||||
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
settingsManager.set(key, value)
|
||||||
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
|
||||||
} else {
|
|
||||||
settingsManager.set(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'language') {
|
if (key === 'language') {
|
||||||
updateTrayMenu()
|
updateTrayMenu()
|
||||||
@@ -38,30 +28,15 @@ 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()
|
||||||
getAll(_context: IpcContext): AppSettings {
|
getAll(_context: IpcContext): AppSettings {
|
||||||
const settings = settingsManager.getAll()
|
return settingsManager.getAll()
|
||||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
|
||||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
|
||||||
settings.subscriptionFilenameTemplate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return settings
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
|
||||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
|
||||||
settings.subscriptionFilenameTemplate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
settingsManager.setAll(settings)
|
settingsManager.setAll(settings)
|
||||||
|
|
||||||
if (settings.language) {
|
if (settings.language) {
|
||||||
@@ -75,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()
|
||||||
@@ -86,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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from 'node:path'
|
||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
import type {
|
import type {
|
||||||
SubscriptionCreatePayload,
|
SubscriptionCreatePayload,
|
||||||
@@ -5,6 +6,7 @@ import type {
|
|||||||
SubscriptionRule,
|
SubscriptionRule,
|
||||||
SubscriptionUpdatePayload
|
SubscriptionUpdatePayload
|
||||||
} from '../../../shared/types'
|
} from '../../../shared/types'
|
||||||
|
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
|
||||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||||
@@ -112,6 +114,7 @@ class SubscriptionService extends IpcService {
|
|||||||
): Promise<SubscriptionRule> {
|
): Promise<SubscriptionRule> {
|
||||||
const resolved = resolveFeedFromInput(options.url)
|
const resolved = resolveFeedFromInput(options.url)
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
|
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||||
const payload: SubscriptionCreatePayload = {
|
const payload: SubscriptionCreatePayload = {
|
||||||
sourceUrl: resolved.sourceUrl,
|
sourceUrl: resolved.sourceUrl,
|
||||||
feedUrl: resolved.feedUrl,
|
feedUrl: resolved.feedUrl,
|
||||||
@@ -120,9 +123,9 @@ class SubscriptionService extends IpcService {
|
|||||||
tags: options.tags,
|
tags: options.tags,
|
||||||
onlyDownloadLatest:
|
onlyDownloadLatest:
|
||||||
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||||
downloadDirectory: options.downloadDirectory || settings.downloadPath,
|
downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,
|
||||||
namingTemplate: sanitizeFilenameTemplate(
|
namingTemplate: sanitizeFilenameTemplate(
|
||||||
options.namingTemplate || settings.subscriptionFilenameTemplate
|
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||||
),
|
),
|
||||||
enabled: options.enabled ?? true
|
enabled: options.enabled ?? true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||||
import type {
|
import type {
|
||||||
@@ -12,7 +13,11 @@ import type {
|
|||||||
VideoFormat,
|
VideoFormat,
|
||||||
VideoInfo
|
VideoInfo
|
||||||
} from '../../shared/types'
|
} from '../../shared/types'
|
||||||
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
|
import {
|
||||||
|
buildDownloadArgs,
|
||||||
|
resolveVideoFormatSelector,
|
||||||
|
sanitizeFilenameTemplate
|
||||||
|
} from '../download-engine/args-builder'
|
||||||
import {
|
import {
|
||||||
findFormatByIdCandidates,
|
findFormatByIdCandidates,
|
||||||
parseSizeToBytes,
|
parseSizeToBytes,
|
||||||
@@ -20,6 +25,7 @@ import {
|
|||||||
} from '../download-engine/format-utils'
|
} from '../download-engine/format-utils'
|
||||||
import { settingsManager } from '../settings'
|
import { settingsManager } from '../settings'
|
||||||
import { scopedLoggers } from '../utils/logger'
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||||
import { DownloadQueue } from './download-queue'
|
import { DownloadQueue } from './download-queue'
|
||||||
import { ffmpegManager } from './ffmpeg-manager'
|
import { ffmpegManager } from './ffmpeg-manager'
|
||||||
import { historyManager } from './history-manager'
|
import { historyManager } from './history-manager'
|
||||||
@@ -30,6 +36,135 @@ 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 => {
|
||||||
|
if (!dir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.download.error('Failed to ensure download directory:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeFolderName = (value: string, fallback: string): string => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
const sanitized = trimmed
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
return sanitized || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLikelyChannelUrl = (url: string): boolean => {
|
||||||
|
const normalized = url.toLowerCase()
|
||||||
|
if (normalized.includes('list=')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return /youtube\.com\/(channel\/|c\/|user\/|@)/.test(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAutoPlaylistDownloadPath = (
|
||||||
|
basePath: string,
|
||||||
|
info: PlaylistInfo,
|
||||||
|
url: string
|
||||||
|
): string => {
|
||||||
|
const kindFolder = isLikelyChannelUrl(url) ? 'Channels' : 'Playlists'
|
||||||
|
const title = sanitizeFolderName(
|
||||||
|
info.title || (kindFolder === 'Channels' ? 'Channel' : 'Playlist'),
|
||||||
|
kindFolder === 'Channels' ? 'Channel' : 'Playlist'
|
||||||
|
)
|
||||||
|
return path.join(basePath, kindFolder, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAutoVideoDownloadPath = (basePath: string, info?: VideoInfo): string => {
|
||||||
|
const root = path.join(basePath, 'Videos')
|
||||||
|
if (!info) {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
const label = info.uploader?.trim() || info.title?.trim()
|
||||||
|
if (!label) {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
return path.join(root, sanitizeFolderName(label, 'Video'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeTemplateValue = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/[. ]+$/g, '')
|
||||||
|
|
||||||
|
const resolveTemplateToken = (token: string, info?: VideoInfo): string | undefined => {
|
||||||
|
if (!info) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
switch (token) {
|
||||||
|
case 'uploader':
|
||||||
|
return info.uploader
|
||||||
|
case 'title':
|
||||||
|
return info.title
|
||||||
|
case 'id':
|
||||||
|
return info.id
|
||||||
|
case 'channel':
|
||||||
|
return info.uploader
|
||||||
|
case 'extractor':
|
||||||
|
return info.extractor_key
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHistoryDownloadPath = (
|
||||||
|
basePath: string,
|
||||||
|
filenameTemplate?: string,
|
||||||
|
info?: VideoInfo
|
||||||
|
): string => {
|
||||||
|
if (!filenameTemplate?.trim()) {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
const safeTemplate = sanitizeFilenameTemplate(filenameTemplate)
|
||||||
|
const resolvedTemplate = safeTemplate.replace(/%\(([^)]+)\)s/g, (match, token) => {
|
||||||
|
const value = resolveTemplateToken(token, info)
|
||||||
|
if (!value) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return sanitizeTemplateValue(value)
|
||||||
|
})
|
||||||
|
const templateDir = path.posix.dirname(resolvedTemplate)
|
||||||
|
if (templateDir === '.' || templateDir === '/') {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
if (/%\([^)]+\)s/.test(templateDir)) {
|
||||||
|
return basePath
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -74,10 +209,12 @@ class DownloadEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add config file if configured
|
// Add config file if configured
|
||||||
if (settings.configPath) {
|
const configPath = resolvePathWithHome(settings.configPath)
|
||||||
args.push('--config-location', `"${settings.configPath}"`)
|
if (configPath) {
|
||||||
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -169,10 +306,12 @@ class DownloadEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add config file if configured
|
// Add config file if configured
|
||||||
if (settings.configPath) {
|
const configPath = resolvePathWithHome(settings.configPath)
|
||||||
args.push('--config-location', `"${settings.configPath}"`)
|
if (configPath) {
|
||||||
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
type RawPlaylistEntry = {
|
type RawPlaylistEntry = {
|
||||||
@@ -313,6 +452,10 @@ class DownloadEngine extends EventEmitter {
|
|||||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||||
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
|
const resolvedDownloadPath =
|
||||||
|
options.customDownloadPath?.trim() ||
|
||||||
|
resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)
|
||||||
|
ensureDirectoryExists(resolvedDownloadPath)
|
||||||
|
|
||||||
const selectedEntries = rawEntries.filter((entry) => {
|
const selectedEntries = rawEntries.filter((entry) => {
|
||||||
if (!entry.url) {
|
if (!entry.url) {
|
||||||
@@ -336,7 +479,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
url: entry.url,
|
url: entry.url,
|
||||||
type: options.type,
|
type: options.type,
|
||||||
format: options.format,
|
format: options.format,
|
||||||
audioFormat: options.type === 'audio' ? options.format : undefined
|
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||||
|
customDownloadPath: resolvedDownloadPath
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
@@ -367,7 +511,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
title: entry.title,
|
title: entry.title,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
downloadedAt: createdAt,
|
downloadedAt: createdAt,
|
||||||
downloadPath: settings.downloadPath,
|
downloadPath: resolvedDownloadPath,
|
||||||
playlistId: groupId,
|
playlistId: groupId,
|
||||||
playlistTitle: playlistInfo.title,
|
playlistTitle: playlistInfo.title,
|
||||||
playlistIndex: entry.index,
|
playlistIndex: entry.index,
|
||||||
@@ -389,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +541,12 @@ class DownloadEngine extends EventEmitter {
|
|||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||||
const origin = options.origin ?? 'manual'
|
const origin = options.origin ?? 'manual'
|
||||||
|
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||||
|
targetDownloadPath,
|
||||||
|
options.customFilenameTemplate
|
||||||
|
)
|
||||||
|
ensureDirectoryExists(targetDownloadPath)
|
||||||
|
ensureDirectoryExists(historyDownloadPath)
|
||||||
|
|
||||||
const item: DownloadItem = {
|
const item: DownloadItem = {
|
||||||
id,
|
id,
|
||||||
@@ -416,7 +566,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
downloadedAt: createdAt,
|
downloadedAt: createdAt,
|
||||||
downloadPath: targetDownloadPath,
|
downloadPath: historyDownloadPath,
|
||||||
tags: options.tags,
|
tags: options.tags,
|
||||||
origin,
|
origin,
|
||||||
subscriptionId: options.subscriptionId
|
subscriptionId: options.subscriptionId
|
||||||
@@ -428,7 +578,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
const ytdlp = ytdlpManager.getInstance()
|
const ytdlp = ytdlpManager.getInstance()
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const defaultDownloadPath = settings.downloadPath
|
const defaultDownloadPath = settings.downloadPath
|
||||||
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||||
|
|
||||||
// Set environment variables for proper encoding on Windows
|
// Set environment variables for proper encoding on Windows
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -479,6 +629,19 @@ class DownloadEngine extends EventEmitter {
|
|||||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!options.customDownloadPath?.trim()) {
|
||||||
|
resolvedDownloadPath = resolveAutoVideoDownloadPath(defaultDownloadPath, videoInfo)
|
||||||
|
options.customDownloadPath = resolvedDownloadPath
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||||
|
resolvedDownloadPath,
|
||||||
|
options.customFilenameTemplate,
|
||||||
|
videoInfo
|
||||||
|
)
|
||||||
|
ensureDirectoryExists(historyDownloadPath)
|
||||||
|
this.upsertHistoryEntry(id, options, { downloadPath: historyDownloadPath })
|
||||||
|
|
||||||
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
const applySelectedFormat = (formatId: string | undefined): boolean => {
|
||||||
if (!formatId) {
|
if (!formatId) {
|
||||||
return false
|
return false
|
||||||
@@ -503,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) {
|
||||||
@@ -577,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) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import DatabaseConstructor from 'better-sqlite3'
|
import DatabaseConstructor from 'better-sqlite3'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, inArray, sql } from 'drizzle-orm'
|
||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
@@ -494,6 +494,61 @@ class HistoryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeHistoryItems(ids: string[]): number {
|
||||||
|
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
|
||||||
|
if (uniqueIds.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let removedCount = 0
|
||||||
|
try {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const result = database
|
||||||
|
.delete(downloadHistoryTable)
|
||||||
|
.where(inArray(downloadHistoryTable.id, uniqueIds))
|
||||||
|
.run()
|
||||||
|
for (const id of uniqueIds) {
|
||||||
|
if (this.history.delete(id)) {
|
||||||
|
removedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((result.changes ?? 0) > removedCount) {
|
||||||
|
removedCount = result.changes ?? removedCount
|
||||||
|
}
|
||||||
|
return removedCount
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
|
||||||
|
return removedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeHistoryByPlaylistId(playlistId: string): number {
|
||||||
|
const normalized = playlistId.trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
let removedCount = 0
|
||||||
|
try {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const result = database
|
||||||
|
.delete(downloadHistoryTable)
|
||||||
|
.where(eq(downloadHistoryTable.playlistId, normalized))
|
||||||
|
.run()
|
||||||
|
for (const [id, item] of this.history.entries()) {
|
||||||
|
if (item.playlistId === normalized) {
|
||||||
|
this.history.delete(id)
|
||||||
|
removedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((result.changes ?? 0) > removedCount) {
|
||||||
|
removedCount = result.changes ?? removedCount
|
||||||
|
}
|
||||||
|
return removedCount
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
|
||||||
|
return removedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearHistory(): void {
|
clearHistory(): void {
|
||||||
try {
|
try {
|
||||||
const database = this.getDatabase()
|
const database = this.getDatabase()
|
||||||
|
|||||||
@@ -493,7 +493,9 @@ export class SubscriptionManager extends EventEmitter {
|
|||||||
|
|
||||||
private migrateLegacyStore(): void {
|
private migrateLegacyStore(): void {
|
||||||
try {
|
try {
|
||||||
const LegacyStore = require('electron-store')
|
const ElectronStore = require('electron-store')
|
||||||
|
// Access the default export
|
||||||
|
const LegacyStore = ElectronStore.default || ElectronStore
|
||||||
const store = new LegacyStore({
|
const store = new LegacyStore({
|
||||||
name: 'subscriptions',
|
name: 'subscriptions',
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|||||||
import log from 'electron-log/main'
|
import log from 'electron-log/main'
|
||||||
import Parser from 'rss-parser'
|
import Parser from 'rss-parser'
|
||||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||||
|
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
|
||||||
import { settingsManager } from '../settings'
|
import { settingsManager } from '../settings'
|
||||||
import { downloadEngine } from './download-engine'
|
import { downloadEngine } from './download-engine'
|
||||||
import { historyManager } from './history-manager'
|
import { historyManager } from './history-manager'
|
||||||
@@ -18,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 }
|
||||||
@@ -40,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}`
|
||||||
@@ -166,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())
|
||||||
@@ -239,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()
|
||||||
@@ -365,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +480,7 @@ export class SubscriptionScheduler extends EventEmitter {
|
|||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||||
const namingTemplate =
|
const namingTemplate =
|
||||||
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
|
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||||
ensureDirectoryExists(downloadDirectory)
|
ensureDirectoryExists(downloadDirectory)
|
||||||
|
|
||||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import crypto from 'node:crypto'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import fsPromises from 'node:fs/promises'
|
import fsPromises from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
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'])
|
||||||
|
|
||||||
@@ -37,7 +38,11 @@ export class ThumbnailCache {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalUrl.startsWith('file://') || originalUrl.startsWith('data:')) {
|
if (
|
||||||
|
originalUrl.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||||
|
originalUrl.startsWith('file://') ||
|
||||||
|
originalUrl.startsWith('data:')
|
||||||
|
) {
|
||||||
return originalUrl
|
return originalUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +64,7 @@ export class ThumbnailCache {
|
|||||||
|
|
||||||
const existingPath = await this.findExistingPath(basePath, defaultExtension)
|
const existingPath = await this.findExistingPath(basePath, defaultExtension)
|
||||||
if (existingPath) {
|
if (existingPath) {
|
||||||
return pathToFileURL(existingPath).toString()
|
return this.toAppProtocolUrl(existingPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(originalUrl)
|
const response = await fetch(originalUrl)
|
||||||
@@ -75,9 +80,9 @@ export class ThumbnailCache {
|
|||||||
const finalPath = `${basePath}${extension}`
|
const finalPath = `${basePath}${extension}`
|
||||||
|
|
||||||
await fsPromises.writeFile(finalPath, buffer)
|
await fsPromises.writeFile(finalPath, buffer)
|
||||||
return pathToFileURL(finalPath).toString()
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +137,13 @@ export class ThumbnailCache {
|
|||||||
const basePath = path.join(cacheDir, `${hash}`)
|
const basePath = path.join(cacheDir, `${hash}`)
|
||||||
return { basePath, defaultExtension: extension }
|
return { basePath, defaultExtension: extension }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toAppProtocolUrl(filePath: string): string {
|
||||||
|
const userDataPath = app.getPath('userData')
|
||||||
|
const relativePath = path.relative(userDataPath, filePath).replace(/\\/g, '/')
|
||||||
|
|
||||||
|
return `${APP_PROTOCOL_SCHEME}${relativePath}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const thumbnailCache = new ThumbnailCache()
|
export const thumbnailCache = new ThumbnailCache()
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -10,12 +11,11 @@ const ElectronStore = require('electron-store')
|
|||||||
const Store = ElectronStore.default || ElectronStore
|
const Store = ElectronStore.default || ElectronStore
|
||||||
|
|
||||||
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
||||||
|
|
||||||
const ensureDirectoryExists = (dir: string) => {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,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 {
|
||||||
@@ -86,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/main/utils/path-helpers.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
export const resolvePathWithHome = (rawPath?: string | null): string | undefined => {
|
||||||
|
const trimmed = rawPath?.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === '~') {
|
||||||
|
return os.homedir()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
||||||
|
return path.join(os.homedir(), trimmed.slice(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>VidBee</title>
|
<title>VidBee</title>
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: vidbee: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -5,35 +5,99 @@ import { TitleBar } from '@renderer/components/ui/title-bar'
|
|||||||
import type { SubscriptionRule } from '@shared/types'
|
import type { SubscriptionRule } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
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 { 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'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
import { Subscriptions } from './pages/Subscriptions'
|
import { Subscriptions } from './pages/Subscriptions'
|
||||||
import { SupportedSites } from './pages/SupportedSites'
|
|
||||||
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' | 'sites'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||||
|
|
||||||
|
const pageToPath: Record<Page, string> = {
|
||||||
|
home: '/',
|
||||||
|
subscriptions: '/subscriptions',
|
||||||
|
settings: '/settings',
|
||||||
|
about: '/about'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePathname = (pathname: string): string => {
|
||||||
|
const trimmed = pathname.replace(/\/+$/, '')
|
||||||
|
return trimmed === '' ? '/' : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathToPage = (pathname: string): Page => {
|
||||||
|
const normalized = normalizePathname(pathname)
|
||||||
|
switch (normalized) {
|
||||||
|
case '/subscriptions':
|
||||||
|
return 'subscriptions'
|
||||||
|
case '/settings':
|
||||||
|
return 'settings'
|
||||||
|
case '/about':
|
||||||
|
return 'about'
|
||||||
|
default:
|
||||||
|
return 'home'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
|
||||||
const [platform, setPlatform] = useState<string>('')
|
const [platform, setPlatform] = useState<string>('')
|
||||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||||
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 location = useLocation()
|
||||||
|
const currentPage = pathToPage(location.pathname)
|
||||||
|
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: Page) => {
|
||||||
|
const targetPath = pageToPath[page] ?? '/'
|
||||||
|
if (normalizePathname(location.pathname) !== targetPath) {
|
||||||
|
navigate(targetPath)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[location.pathname, navigate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenSupportedSites = () => {
|
||||||
|
window.open(supportedSitesUrl, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}, [loadSettings])
|
}, [loadSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDeepLink = (rawUrl: unknown) => {
|
||||||
|
const url = typeof rawUrl === 'string' ? rawUrl.trim() : ''
|
||||||
|
if (!url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Switch to home page to show download dialog
|
||||||
|
handlePageChange('home')
|
||||||
|
// The DownloadDialog component will handle opening the dialog and parsing the video
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcEvents.on('download:deeplink', handleDeepLink)
|
||||||
|
return () => {
|
||||||
|
ipcEvents.removeListener('download:deeplink', handleDeepLink)
|
||||||
|
}
|
||||||
|
}, [handlePageChange])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSubscriptions()
|
loadSubscriptions()
|
||||||
|
|
||||||
@@ -108,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()
|
||||||
}
|
}
|
||||||
@@ -119,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) => {
|
||||||
@@ -153,69 +227,29 @@ 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])
|
||||||
|
|
||||||
const renderPage = () => {
|
|
||||||
switch (currentPage) {
|
|
||||||
case 'home':
|
|
||||||
return (
|
|
||||||
<Home
|
|
||||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
|
||||||
onOpenSettings={() => setCurrentPage('settings')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
case 'settings':
|
|
||||||
return <Settings />
|
|
||||||
case 'subscriptions':
|
|
||||||
return <Subscriptions />
|
|
||||||
case 'about':
|
|
||||||
return <About />
|
|
||||||
case 'sites':
|
|
||||||
return <SupportedSites />
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<Home
|
|
||||||
onOpenSupportedSites={() => setCurrentPage('sites')}
|
|
||||||
onOpenSettings={() => setCurrentPage('settings')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row h-screen">
|
<div className="flex flex-row h-screen">
|
||||||
{/* Sidebar Navigation */}
|
{/* Sidebar Navigation */}
|
||||||
<Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
|
<Sidebar
|
||||||
|
currentPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onOpenSupportedSites={handleOpenSupportedSites}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||||
@@ -226,8 +260,22 @@ function AppContent() {
|
|||||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<div className="w-full overflow-hidden" style={{ maxWidth: '100%' }}>
|
<div className="w-full h-full flex flex-col min-h-0" style={{ maxWidth: '100%' }}>
|
||||||
{renderPage()}
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Home
|
||||||
|
onOpenSupportedSites={handleOpenSupportedSites}
|
||||||
|
onOpenSettings={() => handlePageChange('settings')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/about" element={<About />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</main>
|
</main>
|
||||||
@@ -239,9 +287,13 @@ function AppContent() {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ErrorBoundary>
|
||||||
<AppContent />
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
</ThemeProvider>
|
<HashRouter>
|
||||||
|
<AppContent />
|
||||||
|
</HashRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
--font-sans: Open Sans, sans-serif;
|
--font-sans: Open Sans, sans-serif;
|
||||||
--font-serif: Georgia, serif;
|
--font-serif: Georgia, serif;
|
||||||
--font-mono: Menlo, monospace;
|
--font-mono: Menlo, monospace;
|
||||||
--radius: 1.3rem;
|
--radius: 0.625rem;
|
||||||
--shadow-x: 0px;
|
--shadow-x: 0px;
|
||||||
--shadow-y: 2px;
|
--shadow-y: 2px;
|
||||||
--shadow-blur: 0px;
|
--shadow-blur: 0px;
|
||||||
@@ -89,21 +89,6 @@
|
|||||||
--font-sans: Open Sans, sans-serif;
|
--font-sans: Open Sans, sans-serif;
|
||||||
--font-serif: Georgia, serif;
|
--font-serif: Georgia, serif;
|
||||||
--font-mono: Menlo, monospace;
|
--font-mono: Menlo, monospace;
|
||||||
--radius: 1.3rem;
|
|
||||||
--shadow-x: 0px;
|
|
||||||
--shadow-y: 2px;
|
|
||||||
--shadow-blur: 0px;
|
|
||||||
--shadow-spread: 0px;
|
|
||||||
--shadow-opacity: 0;
|
|
||||||
--shadow-color: #1da1f2;
|
|
||||||
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -145,16 +130,10 @@
|
|||||||
--font-serif: var(--font-serif);
|
--font-serif: var(--font-serif);
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
--shadow-2xs: var(--shadow-2xs);
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
--shadow-xs: var(--shadow-xs);
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
--shadow-sm: var(--shadow-sm);
|
|
||||||
--shadow: var(--shadow);
|
|
||||||
--shadow-md: var(--shadow-md);
|
|
||||||
--shadow-lg: var(--shadow-lg);
|
|
||||||
--shadow-xl: var(--shadow-xl);
|
|
||||||
--shadow-2xl: var(--shadow-2xl);
|
|
||||||
}
|
}
|
||||||
|
|||||||
1386
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
@@ -1,16 +1,23 @@
|
|||||||
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 { Checkbox } from '@renderer/components/ui/checkbox'
|
||||||
import { Progress } from '@renderer/components/ui/progress'
|
import { Progress } from '@renderer/components/ui/progress'
|
||||||
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '@renderer/components/ui/sheet'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Copy,
|
Copy,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -150,6 +157,8 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
|||||||
|
|
||||||
interface DownloadItemProps {
|
interface DownloadItemProps {
|
||||||
download: DownloadRecord
|
download: DownloadRecord
|
||||||
|
isSelected?: boolean
|
||||||
|
onToggleSelect?: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type MetadataDetail = {
|
type MetadataDetail = {
|
||||||
@@ -184,7 +193,6 @@ const formatDateShort = (timestamp?: number) => {
|
|||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
return date.toLocaleString(undefined, {
|
return date.toLocaleString(undefined, {
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -192,7 +200,7 @@ const formatDateShort = (timestamp?: number) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadItem({ download }: DownloadItemProps) {
|
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const settings = useAtomValue(settingsAtom)
|
const settings = useAtomValue(settingsAtom)
|
||||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||||
@@ -201,18 +209,15 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
const isSubscriptionDownload = download.origin === 'subscription'
|
const isSubscriptionDownload = download.origin === 'subscription'
|
||||||
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
const actionsContainerClass =
|
||||||
const actionsContainerBaseClass =
|
'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity'
|
||||||
'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
|
|
||||||
const actionsContainerClass = showActionsWithoutHover
|
|
||||||
? actionsContainerBaseClass
|
|
||||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
|
||||||
const resolvedExtension = resolveDownloadExtension(download)
|
const resolvedExtension = resolveDownloadExtension(download)
|
||||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||||
|
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||||
|
|
||||||
// Track if the file exists
|
// Track if the file exists
|
||||||
const [fileExists, setFileExists] = useState(false)
|
const [fileExists, setFileExists] = useState(false)
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
|
||||||
// Check if file exists when download data changes
|
// Check if file exists when download data changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -395,6 +400,10 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
|
|
||||||
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}`
|
||||||
@@ -494,7 +503,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
href={download.url}
|
href={download.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="wrap-break-word text-primary hover:underline"
|
className="relative z-20 wrap-break-word text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{download.url}
|
{download.url}
|
||||||
</a>
|
</a>
|
||||||
@@ -638,25 +647,68 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
|
|
||||||
const hasMetadataDetails = metadataDetails.length > 0
|
const hasMetadataDetails = metadataDetails.length > 0
|
||||||
|
|
||||||
|
const isSelectedHistory = selectionEnabled && isSelected
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative w-full max-w-full overflow-hidden">
|
<div
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
className={`group relative w-full max-w-full overflow-hidden rounded-lg border border-transparent transition-colors ${
|
||||||
|
isSelectedHistory ? 'bg-primary/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelectedHistory && (
|
||||||
|
<div className="absolute left-0 top-0 h-full w-1 bg-primary/70" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-3 ${
|
||||||
|
selectionEnabled ? 'cursor-pointer' : ''
|
||||||
|
}`}
|
||||||
|
{...(selectionEnabled
|
||||||
|
? {
|
||||||
|
onClick: () => onToggleSelect?.(download.id),
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggleSelect?.(download.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
role: 'button',
|
||||||
|
tabIndex: 0,
|
||||||
|
'aria-label': t('history.selectItem')
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="shrink-0 overflow-hidden rounded-md border border-border/60 bg-background/60 w-32 h-20">
|
<div className="relative z-20 shrink-0 overflow-hidden rounded-lg border border-border/60 bg-background/60 w-20 h-14 pointer-events-none">
|
||||||
|
{selectionEnabled && (
|
||||||
|
<div
|
||||||
|
className={`absolute left-1 top-1 z-30 rounded-md transition pointer-events-auto ${
|
||||||
|
isSelected
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={Boolean(isSelected)}
|
||||||
|
onCheckedChange={() => onToggleSelect?.(download.id)}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
aria-label={t('history.selectItem')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<RemoteImage
|
<RemoteImage
|
||||||
src={download.thumbnail}
|
src={download.thumbnail}
|
||||||
alt={download.title}
|
alt={download.title}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
fallbackIcon={<Play className="h-6 w-6" />}
|
fallbackIcon={<Play className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-full space-y-1.5 overflow-hidden pointer-events-none">
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
<div className="flex w-full flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-full space-y-1 overflow-hidden">
|
||||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-1.5">
|
||||||
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
<p className="flex-1 wrap-break-word text-sm font-medium line-clamp-1">
|
||||||
{download.title}
|
{download.title}
|
||||||
</p>
|
</p>
|
||||||
{isSubscriptionDownload && (
|
{isSubscriptionDownload && (
|
||||||
@@ -665,103 +717,82 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
{(statusIcon || statusText) && (
|
{/* Status */}
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
{statusIcon && (
|
||||||
{statusIcon}
|
|
||||||
<span>{statusText}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{timestamp ? (
|
|
||||||
<span className="truncate text-muted-foreground/80">
|
|
||||||
{formatDateShort(timestamp)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
|
||||||
{/* Source link */}
|
|
||||||
{(sourceDisplay || download.url) &&
|
|
||||||
(download.url ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
href={download.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="max-w-[180px] truncate hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{sourceDisplay || download.url}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-xs wrap-break-word">
|
|
||||||
<p>{download.url}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<span className="truncate">{sourceDisplay}</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Playlist info */}
|
|
||||||
{download.playlistId && (
|
|
||||||
<>
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
|
||||||
{t('playlist.badgeLabel')}
|
|
||||||
</Badge>
|
|
||||||
<span className="max-w-[200px] truncate">
|
|
||||||
{download.playlistTitle || t('playlist.untitled')}
|
|
||||||
{download.playlistIndex !== undefined &&
|
|
||||||
download.playlistSize !== undefined &&
|
|
||||||
` (${download.playlistIndex}/${download.playlistSize})`}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Quality badge */}
|
|
||||||
{qualityLabel && (
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
|
||||||
{qualityLabel}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File size */}
|
|
||||||
{inlineFileSize && <span>{inlineFileSize}</span>}
|
|
||||||
|
|
||||||
{/* Details toggle */}
|
|
||||||
{hasMetadataDetails && (
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<div className="flex items-center shrink-0">{statusIcon}</div>
|
||||||
variant={detailsOpen ? 'default' : 'ghost'}
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDetailsOpen((prev) => !prev)}
|
|
||||||
>
|
|
||||||
{detailsOpen ? (
|
|
||||||
<ChevronUp className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
|
<p>{statusText}</p>
|
||||||
</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 && (
|
||||||
|
<span className="truncate shrink-0">{formatDateShort(timestamp)}</span>
|
||||||
|
)}
|
||||||
|
{/* Quality */}
|
||||||
|
{qualityLabel && (
|
||||||
|
<>
|
||||||
|
{(statusIcon || timestamp) && (
|
||||||
|
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0">{qualityLabel}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* File size */}
|
||||||
|
{inlineFileSize && (
|
||||||
|
<>
|
||||||
|
{(statusIcon || timestamp || qualityLabel) && (
|
||||||
|
<span className="text-muted-foreground/60 shrink-0">•</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0">{inlineFileSize}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{detailsOpen && hasMetadataDetails && (
|
|
||||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 bg-muted/30 p-3 text-xs">
|
|
||||||
{metadataDetails.map((item, index) => (
|
|
||||||
<div key={`${item.label}-${index}`} className="flex gap-3">
|
|
||||||
<span className="w-24 shrink-0 text-muted-foreground">{item.label}</span>
|
|
||||||
<span className="flex-1 wrap-break-word text-foreground">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={actionsContainerClass}>
|
<div className={`${actionsContainerClass} pointer-events-auto`}>
|
||||||
|
{/* Info button - show details in sheet */}
|
||||||
|
{hasMetadataDetails && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setSheetOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t('download.showDetails')}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{isHistory ? (
|
{isHistory ? (
|
||||||
<>
|
<>
|
||||||
{download.status === 'completed' && (
|
{download.status === 'completed' && (
|
||||||
@@ -771,8 +802,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleCopyToClipboard}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleCopyToClipboard()
|
||||||
|
}}
|
||||||
disabled={!canCopyToClipboard()}
|
disabled={!canCopyToClipboard()}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -787,8 +821,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleOpenFolder}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleOpenFolder()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -804,8 +841,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleRemoveHistory}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveHistory()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -824,8 +864,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleCopyToClipboard}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleCopyToClipboard()
|
||||||
|
}}
|
||||||
disabled={!canCopyToClipboard()}
|
disabled={!canCopyToClipboard()}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -840,8 +883,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleOpenFolder}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleOpenFolder()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -858,8 +904,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 shrink-0"
|
className="h-8 w-8 shrink-0 rounded-full"
|
||||||
onClick={handleCancel}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleCancel()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -871,26 +920,8 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||||
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
|
<div className="bg-background/60 w-full overflow-hidden">
|
||||||
<Progress value={download.progress.percent} className="h-1.5 w-full" />
|
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
|
|
||||||
<span className="font-medium shrink-0">
|
|
||||||
{download.progress.percent.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1">
|
|
||||||
{download.progress.downloaded && download.progress.total && (
|
|
||||||
<span className="truncate max-w-[100px]">
|
|
||||||
{download.progress.downloaded} / {download.progress.total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{download.progress.currentSpeed && (
|
|
||||||
<span className="truncate max-w-[80px]">{download.progress.currentSpeed}</span>
|
|
||||||
)}
|
|
||||||
{download.progress.eta && (
|
|
||||||
<span className="truncate max-w-[80px]">ETA: {download.progress.eta}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -902,6 +933,32 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Video Details Sheet */}
|
||||||
|
{hasMetadataDetails && (
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col p-0">
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
|
<SheetTitle className="line-clamp-2">{download.title}</SheetTitle>
|
||||||
|
<SheetDescription>{t('download.videoInfo')}</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{metadataDetails.map((item, index) => (
|
||||||
|
<div key={`${item.label}-${index}`} className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<div className="text-sm text-foreground break-words">{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { DownloadRecord } from '../../store/downloads'
|
import type { DownloadRecord } from '../../store/downloads'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { Progress } from '../ui/progress'
|
||||||
import { DownloadItem } from './DownloadItem'
|
import { DownloadItem } from './DownloadItem'
|
||||||
|
|
||||||
interface PlaylistDownloadGroupProps {
|
interface PlaylistDownloadGroupProps {
|
||||||
@@ -7,15 +11,50 @@ interface PlaylistDownloadGroupProps {
|
|||||||
title: string
|
title: string
|
||||||
records: DownloadRecord[]
|
records: DownloadRecord[]
|
||||||
totalCount: number
|
totalCount: number
|
||||||
|
selectedIds?: Set<string>
|
||||||
|
onToggleSelect?: (id: string) => void
|
||||||
|
onDeletePlaylist?: (playlistId: string, title: string, ids: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY_PREFIX = 'playlist_expanded_'
|
||||||
|
|
||||||
|
const getStorageKey = (groupId: string): string => {
|
||||||
|
return `${STORAGE_KEY_PREFIX}${groupId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadExpandedState = (groupId: string): boolean => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(getStorageKey(groupId))
|
||||||
|
return stored === 'true'
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load playlist expanded state:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveExpandedState = (groupId: string, isExpanded: boolean): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(getStorageKey(groupId), String(isExpanded))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save playlist expanded state:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistDownloadGroup({
|
export function PlaylistDownloadGroup({
|
||||||
groupId,
|
groupId,
|
||||||
title,
|
title,
|
||||||
records,
|
records,
|
||||||
totalCount
|
totalCount,
|
||||||
|
selectedIds,
|
||||||
|
onToggleSelect,
|
||||||
|
onDeletePlaylist
|
||||||
}: PlaylistDownloadGroupProps) {
|
}: PlaylistDownloadGroupProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [isExpanded, setIsExpanded] = useState(() => loadExpandedState(groupId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveExpandedState(groupId, isExpanded)
|
||||||
|
}, [groupId, isExpanded])
|
||||||
|
|
||||||
const completedCount = records.filter((record) => record.status === 'completed').length
|
const completedCount = records.filter((record) => record.status === 'completed').length
|
||||||
const errorCount = records.filter((record) => record.status === 'error').length
|
const errorCount = records.filter((record) => record.status === 'error').length
|
||||||
@@ -24,35 +63,108 @@ export function PlaylistDownloadGroup({
|
|||||||
).length
|
).length
|
||||||
|
|
||||||
const displayTitle = title || t('playlist.untitled')
|
const displayTitle = title || t('playlist.untitled')
|
||||||
|
const historyRecords = records.filter((record) => record.entryType === 'history')
|
||||||
|
const canDeletePlaylist = historyRecords.length > 0 && Boolean(onDeletePlaylist)
|
||||||
|
const toggleLabel = isExpanded ? t('playlist.groupCollapse') : t('playlist.groupExpand')
|
||||||
|
const totalProgress = records.reduce((acc, record) => {
|
||||||
|
if (record.status === 'completed') {
|
||||||
|
return acc + 1
|
||||||
|
}
|
||||||
|
if (record.progress?.percent && record.progress.percent > 0) {
|
||||||
|
return acc + Math.min(record.progress.percent, 100) / 100
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, 0)
|
||||||
|
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
|
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<button
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
|
type="button"
|
||||||
<p className="text-xs text-muted-foreground">
|
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
|
||||||
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
</p>
|
aria-expanded={isExpanded}
|
||||||
</div>
|
aria-label={toggleLabel}
|
||||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
title={toggleLabel}
|
||||||
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
|
>
|
||||||
{errorCount > 0 && (
|
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||||
<span className="text-destructive">
|
{isExpanded ? (
|
||||||
{t('playlist.groupErrors', { count: errorCount })}
|
<ChevronDown className="h-5 w-5" />
|
||||||
</span>
|
) : (
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">{displayTitle}</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t('playlist.collapsedProgress', { completed: completedCount, total: totalCount })}
|
||||||
|
</span>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">•</span>
|
||||||
|
<span>{t('playlist.groupActive', { count: activeCount })}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{errorCount > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground/50">•</span>
|
||||||
|
<span className="text-destructive">
|
||||||
|
{t('playlist.groupErrors', { count: errorCount })}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{canDeletePlaylist && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDeletePlaylist?.(
|
||||||
|
groupId,
|
||||||
|
displayTitle,
|
||||||
|
historyRecords.map((record) => record.id)
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
aria-label={t('history.deletePlaylist')}
|
||||||
|
title={t('history.deletePlaylist')}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{!isExpanded && totalCount > 0 && (
|
||||||
{records.map((record) => (
|
<Progress value={aggregatePercent} className="h-0.5 w-full" />
|
||||||
<div
|
)}
|
||||||
key={`${groupId}:${record.entryType}:${record.id}`}
|
|
||||||
className="border-l border-border/50 pl-3"
|
<div
|
||||||
>
|
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out"
|
||||||
<DownloadItem download={record} />
|
style={{
|
||||||
|
gridTemplateRows: isExpanded ? '1fr' : '0fr'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-h-0">
|
||||||
|
<div className="space-y-3 pt-1">
|
||||||
|
{records.map((record) => (
|
||||||
|
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||||
|
<DownloadItem
|
||||||
|
download={record}
|
||||||
|
isSelected={selectedIds?.has(record.id) ?? false}
|
||||||
|
onToggleSelect={onToggleSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,26 +1,147 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
import { CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||||
|
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@renderer/components/ui/dialog'
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
import { useAtomValue } from 'jotai'
|
import { useAtomValue, useSetAtom } from 'jotai'
|
||||||
import { History as HistoryIcon } from 'lucide-react'
|
import { History as HistoryIcon } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useId, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||||
|
import { ipcServices } from '../../lib/ipc'
|
||||||
import type { DownloadRecord } from '../../store/downloads'
|
import type { DownloadRecord } from '../../store/downloads'
|
||||||
import { downloadStatsAtom, downloadsArrayAtom } from '../../store/downloads'
|
import {
|
||||||
|
downloadStatsAtom,
|
||||||
|
downloadsArrayAtom,
|
||||||
|
removeHistoryRecordsAtom,
|
||||||
|
removeHistoryRecordsByPlaylistAtom
|
||||||
|
} from '../../store/downloads'
|
||||||
|
import { settingsAtom } from '../../store/settings'
|
||||||
|
import { DownloadDialog } from './DownloadDialog'
|
||||||
import { DownloadItem } from './DownloadItem'
|
import { DownloadItem } from './DownloadItem'
|
||||||
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
import { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||||
|
|
||||||
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
type StatusFilter = 'all' | 'active' | 'completed' | 'error'
|
||||||
|
type ConfirmAction =
|
||||||
|
| { type: 'delete-selected'; ids: string[] }
|
||||||
|
| { type: 'delete-playlist'; playlistId: string; title: string; ids: string[] }
|
||||||
|
|
||||||
export function UnifiedDownloadHistory() {
|
const normalizeSavedFileName = (fileName?: string): string | undefined => {
|
||||||
|
if (!fileName) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const trimmed = fileName.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateFilePathCandidates = (
|
||||||
|
downloadPath: string,
|
||||||
|
title: string,
|
||||||
|
format: string,
|
||||||
|
savedFileName?: string
|
||||||
|
): string[] => {
|
||||||
|
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||||
|
const safeTitle = title.trim() || 'Unknown'
|
||||||
|
|
||||||
|
const savedNameCandidates: string[] = []
|
||||||
|
const trimmedSavedFileName = savedFileName?.trim()
|
||||||
|
if (trimmedSavedFileName) {
|
||||||
|
const normalized = normalizeSavedFileName(trimmedSavedFileName)
|
||||||
|
if (normalized) {
|
||||||
|
savedNameCandidates.push(normalized)
|
||||||
|
}
|
||||||
|
if (!normalized || normalized !== trimmedSavedFileName) {
|
||||||
|
savedNameCandidates.push(trimmedSavedFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateFileNames =
|
||||||
|
savedNameCandidates.length > 0
|
||||||
|
? savedNameCandidates
|
||||||
|
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
|
||||||
|
return Array.from(
|
||||||
|
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryFileOperation = async (
|
||||||
|
paths: string[],
|
||||||
|
operation: (filePath: string) => Promise<boolean>
|
||||||
|
): Promise<boolean> => {
|
||||||
|
for (const filePath of paths) {
|
||||||
|
const success = await operation(filePath)
|
||||||
|
if (success) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||||
|
const normalized = normalizeSavedFileName(fileName)
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!normalized.includes('.')) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const ext = normalized.split('.').pop()
|
||||||
|
return ext?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||||
|
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||||
|
if (savedExt) {
|
||||||
|
return savedExt
|
||||||
|
}
|
||||||
|
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||||
|
if (selectedExt) {
|
||||||
|
return selectedExt
|
||||||
|
}
|
||||||
|
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedDownloadHistoryProps {
|
||||||
|
onOpenSupportedSites?: () => void
|
||||||
|
onOpenSettings?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedDownloadHistory({
|
||||||
|
onOpenSupportedSites,
|
||||||
|
onOpenSettings
|
||||||
|
}: UnifiedDownloadHistoryProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const allRecords = useAtomValue(downloadsArrayAtom)
|
const allRecords = useAtomValue(downloadsArrayAtom)
|
||||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||||
|
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
|
||||||
|
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
|
||||||
|
const settings = useAtomValue(settingsAtom)
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
|
||||||
|
const [confirmBusy, setConfirmBusy] = useState(false)
|
||||||
|
const [alsoDeleteFiles, setAlsoDeleteFiles] = useState(false)
|
||||||
|
const alsoDeleteFilesId = useId()
|
||||||
|
|
||||||
useHistorySync()
|
useHistorySync()
|
||||||
|
|
||||||
|
const historyRecords = useMemo(
|
||||||
|
() => allRecords.filter((record) => record.entryType === 'history'),
|
||||||
|
[allRecords]
|
||||||
|
)
|
||||||
|
const selectedCount = selectedIds.size
|
||||||
|
|
||||||
const filteredRecords = useMemo(() => {
|
const filteredRecords = useMemo(() => {
|
||||||
return allRecords.filter((record) => {
|
return allRecords.filter((record) => {
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
@@ -48,6 +169,184 @@ export function UnifiedDownloadHistory() {
|
|||||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const selectableIds = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||||
|
[filteredRecords]
|
||||||
|
)
|
||||||
|
const selectableCount = selectableIds.length
|
||||||
|
const selectionSummary =
|
||||||
|
selectableCount === 0
|
||||||
|
? t('history.selectedCount', { count: selectedCount })
|
||||||
|
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const historyIdSet = new Set(historyRecords.map((record) => record.id))
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
let changed = false
|
||||||
|
const next = new Set<string>()
|
||||||
|
for (const id of prev) {
|
||||||
|
if (historyIdSet.has(id)) {
|
||||||
|
next.add(id)
|
||||||
|
} else {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}, [historyRecords, selectedIds.size])
|
||||||
|
|
||||||
|
const handleToggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestDeleteSelected = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfirmAction({ type: 'delete-selected', ids: Array.from(selectedIds) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestDeletePlaylist = (playlistId: string, title: string, ids: string[]) => {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfirmAction({ type: 'delete-playlist', playlistId, title, ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pruneSelectedIds = (ids: string[]) => {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
let changed = false
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (next.delete(id)) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmContent = useMemo(() => {
|
||||||
|
if (!confirmAction) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
switch (confirmAction.type) {
|
||||||
|
case 'delete-selected': {
|
||||||
|
return {
|
||||||
|
title: t('history.confirmDeleteSelectedTitle'),
|
||||||
|
description: t('history.confirmDeleteSelectedDescription', {
|
||||||
|
count: confirmAction.ids.length
|
||||||
|
}),
|
||||||
|
actionLabel: t('history.removeAction')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'delete-playlist': {
|
||||||
|
return {
|
||||||
|
title: t('history.confirmDeletePlaylistTitle'),
|
||||||
|
description: t('history.confirmDeletePlaylistDescription', {
|
||||||
|
count: confirmAction.ids.length,
|
||||||
|
title: confirmAction.title
|
||||||
|
}),
|
||||||
|
actionLabel: t('history.removeAction')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [confirmAction, t])
|
||||||
|
|
||||||
|
const deleteHistoryFiles = async (records: DownloadRecord[]) => {
|
||||||
|
const failedIds: string[] = []
|
||||||
|
for (const record of records) {
|
||||||
|
if (!record.title) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const downloadPath = record.downloadPath || settings.downloadPath
|
||||||
|
if (!downloadPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const formatForPath = resolveDownloadExtension(record)
|
||||||
|
const filePaths = generateFilePathCandidates(
|
||||||
|
downloadPath,
|
||||||
|
record.title,
|
||||||
|
formatForPath,
|
||||||
|
record.savedFileName
|
||||||
|
)
|
||||||
|
const deleted = await tryFileOperation(filePaths, (filePath) =>
|
||||||
|
ipcServices.fs.deleteFile(filePath)
|
||||||
|
)
|
||||||
|
if (!deleted) {
|
||||||
|
failedIds.push(record.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failedIds.length > 0) {
|
||||||
|
console.warn('Failed to delete some playlist files:', failedIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmAction = async () => {
|
||||||
|
if (!confirmAction) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setConfirmBusy(true)
|
||||||
|
try {
|
||||||
|
if (confirmAction.type === 'delete-selected') {
|
||||||
|
await ipcServices.history.removeHistoryItems(confirmAction.ids)
|
||||||
|
removeHistoryRecords(confirmAction.ids)
|
||||||
|
if (alsoDeleteFiles) {
|
||||||
|
const idSet = new Set(confirmAction.ids)
|
||||||
|
const recordsToDelete = historyRecords.filter((record) => idSet.has(record.id))
|
||||||
|
await deleteHistoryFiles(recordsToDelete)
|
||||||
|
}
|
||||||
|
pruneSelectedIds(confirmAction.ids)
|
||||||
|
toast.success(t('notifications.itemsRemoved', { count: confirmAction.ids.length }))
|
||||||
|
}
|
||||||
|
if (confirmAction.type === 'delete-playlist') {
|
||||||
|
const idSet = new Set(confirmAction.ids)
|
||||||
|
const playlistRecords = historyRecords.filter((record) => idSet.has(record.id))
|
||||||
|
await ipcServices.history.removeHistoryByPlaylistId(confirmAction.playlistId)
|
||||||
|
removeHistoryRecordsByPlaylist(confirmAction.playlistId)
|
||||||
|
await deleteHistoryFiles(playlistRecords)
|
||||||
|
pruneSelectedIds(confirmAction.ids)
|
||||||
|
toast.success(
|
||||||
|
t('notifications.playlistHistoryRemoved', { count: confirmAction.ids.length })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setConfirmAction(null)
|
||||||
|
setAlsoDeleteFiles(false)
|
||||||
|
} catch (error) {
|
||||||
|
if (confirmAction.type === 'delete-selected') {
|
||||||
|
console.error('Failed to remove selected history items:', error)
|
||||||
|
toast.error(t('notifications.itemsRemoveFailed'))
|
||||||
|
}
|
||||||
|
if (confirmAction.type === 'delete-playlist') {
|
||||||
|
console.error('Failed to remove playlist history:', error)
|
||||||
|
toast.error(t('notifications.playlistHistoryRemoveFailed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setConfirmBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const groupedView = useMemo(() => {
|
const groupedView = useMemo(() => {
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -99,51 +398,61 @@ export function UnifiedDownloadHistory() {
|
|||||||
}, [filteredRecords])
|
}, [filteredRecords])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
|
||||||
<CardHeader className="gap-4 p-0">
|
<CardHeader className="gap-4 p-0 pb-4 sticky top-0 z-50 bg-background backdrop-blur supports-[backdrop-filter]:bg-background/95">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
{filters.map((filter) => {
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
const isActive = statusFilter === filter.key
|
{filters.map((filter) => {
|
||||||
return (
|
const isActive = statusFilter === filter.key
|
||||||
<Button
|
return (
|
||||||
key={filter.key}
|
<Button
|
||||||
variant={isActive ? 'secondary' : 'ghost'}
|
key={filter.key}
|
||||||
size="sm"
|
variant={isActive ? 'secondary' : 'ghost'}
|
||||||
className={
|
size="sm"
|
||||||
isActive
|
className={
|
||||||
? 'h-8 rounded-full px-3 shadow-sm'
|
isActive
|
||||||
: 'h-8 rounded-full border border-border/60 px-3'
|
? 'h-8 rounded-full px-3 shadow-sm'
|
||||||
}
|
: 'h-8 rounded-full border border-border/60 px-3'
|
||||||
onClick={() => setStatusFilter(filter.key)}
|
}
|
||||||
>
|
onClick={() => setStatusFilter(filter.key)}
|
||||||
<span>{filter.label}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
|
|
||||||
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{filter.count}
|
<span>{filter.label}</span>
|
||||||
</span>
|
<span
|
||||||
</Button>
|
className={cn(
|
||||||
)
|
'ml-1 min-w-5 rounded-full px-1 text-xs font-medium text-neutral-900',
|
||||||
})}
|
isActive ? ' bg-neutral-100' : ' bg-neutral-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{filter.count}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DownloadDialog
|
||||||
|
onOpenSupportedSites={onOpenSupportedSites}
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 p-0 overflow-hidden w-full">
|
<CardContent className="space-y-3 p-0 overflow-x-hidden w-full">
|
||||||
{filteredRecords.length === 0 ? (
|
{filteredRecords.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border/60 px-6 py-10 text-center text-muted-foreground">
|
||||||
<HistoryIcon className="h-10 w-10 opacity-50" />
|
<HistoryIcon className="h-10 w-10 opacity-50" />
|
||||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 sm:space-y-4 overflow-hidden w-full">
|
<div className="space-y-4 w-full">
|
||||||
{groupedView.order.map((item) => {
|
{groupedView.order.map((item) => {
|
||||||
if (item.type === 'single') {
|
if (item.type === 'single') {
|
||||||
return (
|
return (
|
||||||
<DownloadItem
|
<DownloadItem
|
||||||
key={`${item.record.entryType}:${item.record.id}`}
|
key={`${item.record.entryType}:${item.record.id}`}
|
||||||
download={item.record}
|
download={item.record}
|
||||||
|
isSelected={selectedIds.has(item.record.id)}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -160,12 +469,90 @@ export function UnifiedDownloadHistory() {
|
|||||||
title={group.title}
|
title={group.title}
|
||||||
totalCount={group.totalCount}
|
totalCount={group.totalCount}
|
||||||
records={group.records}
|
records={group.records}
|
||||||
|
selectedIds={selectedIds}
|
||||||
|
onToggleSelect={handleToggleSelect}
|
||||||
|
onDeletePlaylist={handleRequestDeletePlaylist}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 z-40 w-[calc(100%-2rem)] -translate-x-1/2 sm:left-auto sm:right-6 sm:translate-x-0 sm:w-auto">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-full border border-border/50 bg-background/80 pl-5 pr-2 py-2 shadow-lg backdrop-blur">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{selectionSummary}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full px-3"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
>
|
||||||
|
{t('history.clearSelection')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full px-3"
|
||||||
|
onClick={handleRequestDeleteSelected}
|
||||||
|
>
|
||||||
|
{t('history.deleteSelected')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(confirmAction)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !confirmBusy) {
|
||||||
|
setConfirmAction(null)
|
||||||
|
setAlsoDeleteFiles(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmContent && (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{confirmContent.title}</DialogTitle>
|
||||||
|
<DialogDescription>{confirmContent.description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{confirmAction?.type === 'delete-selected' && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={alsoDeleteFilesId}
|
||||||
|
checked={alsoDeleteFiles}
|
||||||
|
onCheckedChange={(checked) => setAlsoDeleteFiles(checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={alsoDeleteFilesId}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{t('history.alsoDeleteFiles')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmAction(null)
|
||||||
|
setAlsoDeleteFiles(false)
|
||||||
|
}}
|
||||||
|
disabled={confirmBusy}
|
||||||
|
>
|
||||||
|
{t('download.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleConfirmAction} disabled={confirmBusy}>
|
||||||
|
{confirmContent.actionLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
</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,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -11,9 +12,10 @@ 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 type { SubscriptionRule } from '@shared/types'
|
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { useEffect, useId, useRef, useState } from 'react'
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
@@ -26,7 +28,15 @@ const sanitizeCommaList = (value: string) =>
|
|||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||||
|
|
||||||
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
|
const sanitizeTemplateInput = (value: string) => value.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
|
||||||
|
|
||||||
|
const buildDefaultSubscriptionDirectory = (downloadPath: string) => {
|
||||||
|
const trimmed = downloadPath.trim().replace(/[\\/]+$/, '')
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'Subscriptions'
|
||||||
|
}
|
||||||
|
return `${trimmed}/Subscriptions`
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubscriptionFormData {
|
export interface SubscriptionFormData {
|
||||||
url?: string
|
url?: string
|
||||||
@@ -69,8 +79,10 @@ export function SubscriptionFormDialog({
|
|||||||
const [detectingFeed, setDetectingFeed] = useState(false)
|
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||||
|
|
||||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||||
const prevDefaultPathRef = useRef(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(() => {
|
||||||
@@ -78,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(', '))
|
||||||
@@ -91,22 +105,15 @@ export function SubscriptionFormDialog({
|
|||||||
setKeywords('')
|
setKeywords('')
|
||||||
setTags('')
|
setTags('')
|
||||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||||
setDownloadDirectory(settings.downloadPath)
|
setDownloadDirectory(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)
|
||||||
}
|
}
|
||||||
}, [
|
}, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])
|
||||||
open,
|
|
||||||
mode,
|
|
||||||
subscription,
|
|
||||||
settings.subscriptionOnlyLatestDefault,
|
|
||||||
settings.downloadPath,
|
|
||||||
settings.subscriptionFilenameTemplate
|
|
||||||
])
|
|
||||||
|
|
||||||
// Sync download directory with settings changes (only in add mode)
|
// Sync download directory with settings changes (only in add mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
const newPath = settings.downloadPath
|
const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)
|
||||||
setDownloadDirectory((prev) => {
|
setDownloadDirectory((prev) => {
|
||||||
if (!prev || prev === prevDefaultPathRef.current) {
|
if (!prev || prev === prevDefaultPathRef.current) {
|
||||||
return newPath
|
return newPath
|
||||||
@@ -117,13 +124,6 @@ export function SubscriptionFormDialog({
|
|||||||
}
|
}
|
||||||
}, [settings.downloadPath, mode])
|
}, [settings.downloadPath, mode])
|
||||||
|
|
||||||
// Sync naming template with settings changes (only in add mode)
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === 'add') {
|
|
||||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
|
||||||
}
|
|
||||||
}, [settings.subscriptionFilenameTemplate, mode])
|
|
||||||
|
|
||||||
// Sync onlyLatest with settings changes (only in add mode)
|
// Sync onlyLatest with settings changes (only in add mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
@@ -265,14 +265,6 @@ export function SubscriptionFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
|
||||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('subscriptions.fields.tags')}</Label>
|
|
||||||
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -282,25 +274,62 @@ export function SubscriptionFormDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div
|
||||||
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||||
<Input
|
className={cn(
|
||||||
value={namingTemplate}
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
|
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||||
/>
|
)}
|
||||||
</div>
|
aria-hidden={!advancedOptionsOpen}
|
||||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
>
|
||||||
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
<div className="space-y-3 border-t pt-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||||
|
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.tags')}</Label>
|
||||||
|
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
||||||
|
<Input
|
||||||
|
value={namingTemplate}
|
||||||
|
onChange={(event) =>
|
||||||
|
setNamingTemplate(sanitizeTemplateInput(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||||
|
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||||
|
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline'
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('rounded-lg bg-muted/30 text-card-foreground shadow', className)}
|
className={cn('rounded-lg bg-muted/30 text-card-foreground', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
'peer dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -22,5 +22,4 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
|||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox }
|
export { Checkbox }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
'flex h-9 w-full rounded-md border px-3 py-1 text-base bg-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
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 }
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||||
@@ -7,7 +8,7 @@ interface RemoteImageProps {
|
|||||||
/**
|
/**
|
||||||
* Remote image URL or local file path
|
* Remote image URL or local file path
|
||||||
* If it's a remote URL (http/https), it will be cached automatically
|
* If it's a remote URL (http/https), it will be cached automatically
|
||||||
* If it's a local path (file:// or data:), it will be used directly
|
* If it's a local path (vidbee://, file:// or data:), it will be used directly
|
||||||
*/
|
*/
|
||||||
src?: string | null
|
src?: string | null
|
||||||
alt: string
|
alt: string
|
||||||
@@ -36,7 +37,7 @@ interface RemoteImageProps {
|
|||||||
*
|
*
|
||||||
* This component automatically handles:
|
* This component automatically handles:
|
||||||
* - Remote URL caching via thumbnail cache service
|
* - Remote URL caching via thumbnail cache service
|
||||||
* - Local file paths (file://, data:)
|
* - Local file paths (vidbee://, file://, data:)
|
||||||
* - Loading states and error handling
|
* - Loading states and error handling
|
||||||
* - Placeholder display with loading indicator
|
* - Placeholder display with loading indicator
|
||||||
*
|
*
|
||||||
@@ -46,7 +47,7 @@ interface RemoteImageProps {
|
|||||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
|
* <RemoteImage src="https://example.com/image.jpg" alt="Example" />
|
||||||
*
|
*
|
||||||
* // Local file path (no caching)
|
* // Local file path (no caching)
|
||||||
* <RemoteImage src="file:///path/to/image.jpg" alt="Local" />
|
* <RemoteImage src="vidbee://thumbnails/example.jpg" alt="Local" />
|
||||||
*
|
*
|
||||||
* // Without cache (direct load)
|
* // Without cache (direct load)
|
||||||
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
|
* <RemoteImage src="https://example.com/image.jpg" alt="Example" useCache={false} />
|
||||||
@@ -66,6 +67,8 @@ interface RemoteImageProps {
|
|||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
const IMAGE_LOAD_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
export function RemoteImage({
|
export function RemoteImage({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@@ -80,6 +83,7 @@ export function RemoteImage({
|
|||||||
const shouldUseCache =
|
const shouldUseCache =
|
||||||
useCache &&
|
useCache &&
|
||||||
src &&
|
src &&
|
||||||
|
!src.startsWith(APP_PROTOCOL_SCHEME) &&
|
||||||
!src.startsWith('file://') &&
|
!src.startsWith('file://') &&
|
||||||
!src.startsWith('data:') &&
|
!src.startsWith('data:') &&
|
||||||
(src.startsWith('http://') || src.startsWith('https://'))
|
(src.startsWith('http://') || src.startsWith('https://'))
|
||||||
@@ -89,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) {
|
||||||
@@ -100,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])
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
'flex h-9 w-full items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
127
src/renderer/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import { XIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = 'right',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||||
|
side === 'right' &&
|
||||||
|
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||||
|
side === 'left' &&
|
||||||
|
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||||
|
side === 'top' &&
|
||||||
|
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||||
|
side === 'bottom' &&
|
||||||
|
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn('text-foreground font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -24,9 +15,20 @@ import MingcuteRssLine from '~icons/mingcute/rss-line'
|
|||||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||||
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||||
|
|
||||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
|
||||||
|
type NavigationTarget = Page | 'supported-sites'
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
|
id: NavigationTarget
|
||||||
|
icon: {
|
||||||
|
active: React.ComponentType<{ className?: string }>
|
||||||
|
inactive: React.ComponentType<{ className?: string }>
|
||||||
|
}
|
||||||
|
label: string
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageNavigationItem {
|
||||||
id: Page
|
id: Page
|
||||||
icon: {
|
icon: {
|
||||||
active: React.ComponentType<{ className?: string }>
|
active: React.ComponentType<{ className?: string }>
|
||||||
@@ -38,13 +40,12 @@ interface NavigationItem {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
currentPage: Page
|
currentPage: Page
|
||||||
onPageChange: (page: Page) => void
|
onPageChange: (page: Page) => void
|
||||||
|
onOpenSupportedSites: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ currentPage, onPageChange }: 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[] = [
|
||||||
{
|
{
|
||||||
@@ -64,16 +65,17 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
label: t('menu.rss')
|
label: t('menu.rss')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sites',
|
id: 'supported-sites',
|
||||||
icon: {
|
icon: {
|
||||||
active: MingcuteCheckCircleFill,
|
active: MingcuteCheckCircleFill,
|
||||||
inactive: MingcuteCheckCircleLine
|
inactive: MingcuteCheckCircleLine
|
||||||
},
|
},
|
||||||
label: t('menu.supportedSites')
|
label: t('menu.supportedSites'),
|
||||||
|
onClick: onOpenSupportedSites
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const bottomNavigationItems: NavigationItem[] = [
|
const bottomNavigationItems: PageNavigationItem[] = [
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
icon: {
|
icon: {
|
||||||
@@ -92,31 +94,18 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
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 = 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
|
||||||
|
const handleClick = item.onClick ?? (() => onPageChange(item.id as Page))
|
||||||
|
|
||||||
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">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onPageChange(item.id)}
|
onClick={handleClick}
|
||||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||||
>
|
>
|
||||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -147,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
|
|
||||||
<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 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">
|
||||||
@@ -197,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => onPageChange(item.id)}
|
onClick={() => onPageChange(item.id)}
|
||||||
className={`no-drag 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">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-md ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -4,15 +4,10 @@ import {
|
|||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger
|
AccordionTrigger
|
||||||
} from '@renderer/components/ui/accordion'
|
} from '@renderer/components/ui/accordion'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
|
||||||
import { Input } from '@renderer/components/ui/input'
|
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 { useAtom } from 'jotai'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { ipcServices } from '../../lib/ipc'
|
|
||||||
import { settingsAtom } from '../../store/settings'
|
|
||||||
|
|
||||||
interface AdvancedOptionsProps {
|
interface AdvancedOptionsProps {
|
||||||
startTime: string
|
startTime: string
|
||||||
@@ -21,6 +16,7 @@ interface AdvancedOptionsProps {
|
|||||||
onStartTimeChange: (value: string) => void
|
onStartTimeChange: (value: string) => void
|
||||||
onEndTimeChange: (value: string) => void
|
onEndTimeChange: (value: string) => void
|
||||||
onDownloadSubsChange: (value: boolean) => void
|
onDownloadSubsChange: (value: boolean) => void
|
||||||
|
showAccordion?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdvancedOptions({
|
export function AdvancedOptions({
|
||||||
@@ -29,71 +25,65 @@ export function AdvancedOptions({
|
|||||||
downloadSubs,
|
downloadSubs,
|
||||||
onStartTimeChange,
|
onStartTimeChange,
|
||||||
onEndTimeChange,
|
onEndTimeChange,
|
||||||
onDownloadSubsChange
|
onDownloadSubsChange,
|
||||||
|
showAccordion = true
|
||||||
}: AdvancedOptionsProps) {
|
}: AdvancedOptionsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [settings] = useAtom(settingsAtom)
|
|
||||||
|
|
||||||
const handleSelectLocation = async () => {
|
const content = (
|
||||||
try {
|
<div className="space-y-6">
|
||||||
const path = await ipcServices.fs.selectDirectory()
|
{/* Time Range */}
|
||||||
if (path) {
|
<div className="space-y-2">
|
||||||
await ipcServices.settings.set('downloadPath', path)
|
<Label className="text-xs font-medium text-muted-foreground ml-1">
|
||||||
toast.success(t('notifications.settingsSaved'))
|
{t('advancedOptions.timeRange')}
|
||||||
}
|
</Label>
|
||||||
} catch (error) {
|
<div className="flex items-center gap-4">
|
||||||
console.error('Failed to select directory:', error)
|
<div className="flex-1 relative group">
|
||||||
toast.error('Failed to select directory')
|
<Input
|
||||||
}
|
placeholder={t('advancedOptions.startPlaceholder')}
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||||
|
className="h-9 text-center"
|
||||||
|
title={t('advancedOptions.startHint')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
|
<div className="flex-1 relative group">
|
||||||
|
<Input
|
||||||
|
placeholder={t('advancedOptions.endPlaceholder')}
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||||
|
className="h-9 text-center"
|
||||||
|
title={t('advancedOptions.endHint')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitles */}
|
||||||
|
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/30">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-semibold">{t('advancedOptions.downloadSubs')}</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t('advancedOptions.downloadSubsHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!showAccordion) {
|
||||||
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion type="single" collapsible>
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="advanced">
|
<AccordionItem value="advanced" className="border-b">
|
||||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
<AccordionTrigger className="flex items-center gap-2 py-4 text-sm font-semibold hover:no-underline">
|
||||||
<AccordionContent className="space-y-4">
|
<span className="flex-1 text-left">{t('advancedOptions.title')}</span>
|
||||||
{/* Time Range */}
|
</AccordionTrigger>
|
||||||
<div className="space-y-2">
|
<AccordionContent className="pb-6 pt-2">{content}</AccordionContent>
|
||||||
<Label>{t('advancedOptions.timeRange')}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
placeholder={t('advancedOptions.startPlaceholder')}
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => onStartTimeChange(e.target.value)}
|
|
||||||
title={t('advancedOptions.startHint')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">-</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
placeholder={t('advancedOptions.endPlaceholder')}
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => onEndTimeChange(e.target.value)}
|
|
||||||
title={t('advancedOptions.endHint')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('advancedOptions.startHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtitles */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>{t('advancedOptions.downloadSubs')}</Label>
|
|
||||||
<Switch checked={downloadSubs} onCheckedChange={onDownloadSubsChange} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download Location */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('advancedOptions.downloadLocation')}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input value={settings.downloadPath} readOnly className="flex-1" />
|
|
||||||
<Button onClick={handleSelectLocation} variant="outline">
|
|
||||||
{t('settings.selectPath')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Button } from '@renderer/components/ui/button'
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||||
import { Label } from '@renderer/components/ui/label'
|
import { Label } from '@renderer/components/ui/label'
|
||||||
import {
|
import {
|
||||||
@@ -8,19 +7,27 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '@renderer/components/ui/select'
|
} from '@renderer/components/ui/select'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { VideoInfo } from '../../../../shared/types'
|
import type { VideoInfo } from '../../../../shared/types'
|
||||||
|
|
||||||
interface AudioExtractorProps {
|
export interface AudioExtractorState {
|
||||||
videoInfo: VideoInfo
|
extractFormat: string
|
||||||
onExtract: (type: 'extract') => void
|
extractQuality: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
interface AudioExtractorProps {
|
||||||
|
videoInfo: VideoInfo
|
||||||
|
state: AudioExtractorState
|
||||||
|
onStateChange: (state: Partial<AudioExtractorState>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioExtractor({
|
||||||
|
videoInfo: _videoInfo,
|
||||||
|
state,
|
||||||
|
onStateChange
|
||||||
|
}: AudioExtractorProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [extractFormat, setExtractFormat] = useState('mp3')
|
const { extractFormat, extractQuality } = state
|
||||||
const [extractQuality, setExtractQuality] = useState('5')
|
|
||||||
|
|
||||||
const audioFormats = [
|
const audioFormats = [
|
||||||
{ value: 'mp3', label: 'MP3' },
|
{ value: 'mp3', label: 'MP3' },
|
||||||
@@ -49,7 +56,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
<Label className="text-sm font-semibold">{t('audioExtract.selectFormat')}</Label>
|
||||||
<Select value={extractFormat} onValueChange={setExtractFormat}>
|
<Select
|
||||||
|
value={extractFormat}
|
||||||
|
onValueChange={(value) => onStateChange({ extractFormat: value })}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -65,7 +75,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
|||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
<Label className="text-sm font-semibold">{t('audioExtract.selectQuality')}</Label>
|
||||||
<Select value={extractQuality} onValueChange={setExtractQuality}>
|
<Select
|
||||||
|
value={extractQuality}
|
||||||
|
onValueChange={(value) => onStateChange({ extractQuality: value })}
|
||||||
|
>
|
||||||
<SelectTrigger className="h-10">
|
<SelectTrigger className="h-10">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -79,10 +92,6 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
|
|
||||||
{t('audioExtract.extract')}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
import { Badge } from '@renderer/components/ui/badge'
|
import { Badge } from '@renderer/components/ui/badge'
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from '@renderer/components/ui/card'
|
|
||||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||||
import { Input } from '@renderer/components/ui/input'
|
|
||||||
import { Label } from '@renderer/components/ui/label'
|
|
||||||
import { Separator } from '@renderer/components/ui/separator'
|
import { Separator } from '@renderer/components/ui/separator'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
import { useSetAtom } from 'jotai'
|
|
||||||
import { ArrowLeft, Clock, Download as DownloadIcon, Eye, Play } from 'lucide-react'
|
import { Clock, Eye, Play } from 'lucide-react'
|
||||||
import { useId, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import type { VideoInfo } from '../../../../shared/types'
|
import type { VideoInfo } from '../../../../shared/types'
|
||||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||||
import { ipcServices } from '../../lib/ipc'
|
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
|
||||||
import { addDownloadAtom } from '../../store/downloads'
|
|
||||||
import { clearVideoInfoAtom } from '../../store/video'
|
|
||||||
import { AdvancedOptions } from './AdvancedOptions'
|
|
||||||
import { AudioExtractor } from './AudioExtractor'
|
|
||||||
import { FormatSelector } from './FormatSelector'
|
import { FormatSelector } from './FormatSelector'
|
||||||
|
|
||||||
|
export interface VideoInfoCardState {
|
||||||
|
title: string
|
||||||
|
activeTab: 'video' | 'audio'
|
||||||
|
selectedVideoFormat: string
|
||||||
|
selectedAudioForVideo: string
|
||||||
|
selectedAudioFormat: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
downloadSubs: boolean
|
||||||
|
customDownloadPath: string
|
||||||
|
audioExtractor: AudioExtractorState
|
||||||
|
}
|
||||||
|
|
||||||
interface VideoInfoCardProps {
|
interface VideoInfoCardProps {
|
||||||
videoInfo: VideoInfo
|
videoInfo: VideoInfo
|
||||||
|
state: VideoInfoCardState
|
||||||
|
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||||
|
onTabChange: (tab: 'video' | 'audio') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds?: number): string {
|
function formatDuration(seconds?: number): string {
|
||||||
@@ -46,129 +47,62 @@ function formatViews(views?: number): string {
|
|||||||
return views.toString()
|
return views.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
export function VideoInfoCard({
|
||||||
|
videoInfo,
|
||||||
|
state,
|
||||||
|
onStateChange,
|
||||||
|
onTabChange
|
||||||
|
}: VideoInfoCardProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
|
|
||||||
const addDownload = useSetAtom(addDownloadAtom)
|
|
||||||
const titleId = useId()
|
|
||||||
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
|
const { title, activeTab } = state
|
||||||
const [title, setTitle] = useState(videoInfo.title)
|
|
||||||
const [selectedVideoFormat, setSelectedVideoFormat] = useState('')
|
|
||||||
const [selectedAudioForVideo, setSelectedAudioForVideo] = useState('')
|
|
||||||
const [selectedAudioFormat, setSelectedAudioFormat] = useState('')
|
|
||||||
const [startTime, setStartTime] = useState('')
|
|
||||||
const [endTime, setEndTime] = useState('')
|
|
||||||
const [downloadSubs, setDownloadSubs] = useState(false)
|
|
||||||
|
|
||||||
const handleDownload = async (type: 'video' | 'audio' | 'extract') => {
|
|
||||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
|
||||||
|
|
||||||
const downloadItem = {
|
|
||||||
id,
|
|
||||||
url: videoInfo.webpage_url || '',
|
|
||||||
title,
|
|
||||||
thumbnail: videoInfo.thumbnail,
|
|
||||||
type: type === 'extract' ? 'audio' : type,
|
|
||||||
status: 'pending' as const,
|
|
||||||
progress: { percent: 0 },
|
|
||||||
duration: videoInfo.duration,
|
|
||||||
description: videoInfo.description,
|
|
||||||
channel: videoInfo.extractor_key,
|
|
||||||
uploader: videoInfo.extractor_key,
|
|
||||||
createdAt: Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
url: videoInfo.webpage_url || '',
|
|
||||||
type,
|
|
||||||
format: type === 'video' ? selectedVideoFormat : selectedAudioFormat,
|
|
||||||
audioFormat: type === 'video' ? selectedAudioForVideo : undefined,
|
|
||||||
startTime: startTime || undefined,
|
|
||||||
endTime: endTime || undefined,
|
|
||||||
downloadSubs
|
|
||||||
}
|
|
||||||
|
|
||||||
addDownload(downloadItem)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ipcServices.download.startDownload(id, options)
|
|
||||||
|
|
||||||
// Update the download info in the main process queue
|
|
||||||
await ipcServices.download.updateDownloadInfo(id, {
|
|
||||||
title,
|
|
||||||
thumbnail: videoInfo.thumbnail,
|
|
||||||
duration: videoInfo.duration,
|
|
||||||
description: videoInfo.description,
|
|
||||||
channel: videoInfo.extractor_key,
|
|
||||||
uploader: videoInfo.extractor_key,
|
|
||||||
createdAt: Date.now()
|
|
||||||
})
|
|
||||||
|
|
||||||
toast.success(t('notifications.downloadStarted'))
|
|
||||||
clearVideoInfo()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start download:', error)
|
|
||||||
toast.error(t('notifications.downloadFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div>
|
||||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
|
<Card className="overflow-hidden border shadow-sm">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<CardHeader className="p-3">
|
||||||
{t('download.back')}
|
<div className="flex gap-3">
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<CardHeader className="pb-4">
|
|
||||||
<div className="flex flex-col md:flex-row gap-6">
|
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<ImageWithPlaceholder
|
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
|
||||||
src={cachedThumbnail}
|
<ImageWithPlaceholder
|
||||||
alt={title}
|
src={cachedThumbnail}
|
||||||
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
|
alt={title}
|
||||||
fallbackIcon={<Play className="h-12 w-12" />}
|
className="w-full h-full object-cover"
|
||||||
/>
|
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video Metadata */}
|
{/* Video Metadata */}
|
||||||
<div className="flex-1 space-y-4 min-w-0">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="space-y-3">
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
|
<Badge
|
||||||
<CardDescription className="flex flex-wrap gap-2 items-center">
|
variant="outline"
|
||||||
{videoInfo.duration && (
|
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
|
||||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
>
|
||||||
<Clock className="h-3.5 w-3.5" />
|
{videoInfo.extractor_key || t('download.videoInfo')}
|
||||||
<span>{formatDuration(videoInfo.duration)}</span>
|
</Badge>
|
||||||
</Badge>
|
{videoInfo.duration && (
|
||||||
)}
|
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||||
{videoInfo.view_count && (
|
<Clock className="h-2.5 w-2.5" />
|
||||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
<span>{formatDuration(videoInfo.duration)}</span>
|
||||||
<Eye className="h-3.5 w-3.5" />
|
</Badge>
|
||||||
<span>{formatViews(videoInfo.view_count)}</span>
|
)}
|
||||||
</Badge>
|
{videoInfo.view_count && (
|
||||||
)}
|
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||||
{videoInfo.uploader && (
|
<Eye className="h-2.5 w-2.5" />
|
||||||
<Badge variant="outline" className="px-2.5 py-1">
|
<span>{formatViews(videoInfo.view_count)}</span>
|
||||||
{videoInfo.uploader}
|
</Badge>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-1">
|
||||||
<Label htmlFor={titleId} className="text-sm font-semibold">
|
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
|
||||||
{t('download.title')}
|
{videoInfo.uploader && (
|
||||||
</Label>
|
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
|
||||||
<Input
|
)}
|
||||||
id={titleId}
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="font-medium h-10"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,72 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<CardContent className="pt-6 pb-6">
|
<CardContent className="p-3 pt-3">
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
<Tabs
|
||||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
value={activeTab}
|
||||||
<TabsTrigger value="video" className="text-sm font-medium">
|
onValueChange={(v) => {
|
||||||
|
onTabChange(v as 'video' | 'audio')
|
||||||
|
onStateChange({ activeTab: v as 'video' | 'audio' })
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="grid grid-cols-2 w-full mb-3 h-8">
|
||||||
|
<TabsTrigger value="video" className="text-xs">
|
||||||
{t('download.video')}
|
{t('download.video')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="audio" className="text-sm font-medium">
|
<TabsTrigger value="audio" className="text-xs">
|
||||||
{t('download.audio')}
|
{t('download.audio')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="video" className="space-y-5 mt-0">
|
<TabsContent value="video" className="space-y-3 mt-0">
|
||||||
<FormatSelector
|
<FormatSelector
|
||||||
formats={videoInfo.formats || []}
|
formats={videoInfo.formats || []}
|
||||||
type="video"
|
type="video"
|
||||||
onVideoFormatChange={setSelectedVideoFormat}
|
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||||
onAudioFormatChange={setSelectedAudioForVideo}
|
onAudioFormatChange={(format) => onStateChange({ selectedAudioForVideo: format })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AdvancedOptions
|
|
||||||
startTime={startTime}
|
|
||||||
endTime={endTime}
|
|
||||||
downloadSubs={downloadSubs}
|
|
||||||
onStartTimeChange={setStartTime}
|
|
||||||
onEndTimeChange={setEndTime}
|
|
||||||
onDownloadSubsChange={setDownloadSubs}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDownload('video')}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
|
||||||
{t('download.downloadVideo')}
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="audio" className="space-y-5 mt-0">
|
<TabsContent value="audio" className="space-y-3 mt-0">
|
||||||
<FormatSelector
|
<FormatSelector
|
||||||
formats={videoInfo.formats || []}
|
formats={videoInfo.formats || []}
|
||||||
type="audio"
|
type="audio"
|
||||||
onAudioFormatChange={setSelectedAudioFormat}
|
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
|
<AudioExtractor
|
||||||
|
videoInfo={videoInfo}
|
||||||
<AdvancedOptions
|
state={state.audioExtractor}
|
||||||
startTime={startTime}
|
onStateChange={(updates) =>
|
||||||
endTime={endTime}
|
onStateChange({
|
||||||
downloadSubs={downloadSubs}
|
audioExtractor: { ...state.audioExtractor, ...updates }
|
||||||
onStartTimeChange={setStartTime}
|
})
|
||||||
onEndTimeChange={setEndTime}
|
}
|
||||||
onDownloadSubsChange={setDownloadSubs}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => handleDownload('audio')}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
|
||||||
{t('download.downloadAudio')}
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { ipcServices } from '../lib/ipc'
|
import { ipcServices } from '../lib/ipc'
|
||||||
|
|
||||||
export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
||||||
@@ -14,7 +14,11 @@ export const useCachedThumbnail = (url?: string | null): string | undefined => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith('file://') || url.startsWith('data:')) {
|
if (
|
||||||
|
url.startsWith(APP_PROTOCOL_SCHEME) ||
|
||||||
|
url.startsWith('file://') ||
|
||||||
|
url.startsWith('data:')
|
||||||
|
) {
|
||||||
setCachedUrl(url)
|
setCachedUrl(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||