Compare commits

..

38 Commits

Author SHA1 Message Date
Nexmoe
e332ec4ddc chore: release v1.1.5 2026-01-10 19:18:55 +08:00
Nexmoe
3b74712f57 fix(subscription): improve cover image selection (#73)
* fix(subscriptions): improve rss cover fallback

* fix(subscriptions): match img src first
2026-01-10 19:18:03 +08:00
Nexmoe
475e4127c2 fix(remote-image): add load timeout (#72) 2026-01-10 18:39:00 +08:00
Nexmoe
3041307aa2 fix: Advanced settings tab broken (#71) 2026-01-10 18:30:18 +08:00
Nexmoe
e2ab8dce60 chore: release v1.1.4 2026-01-09 21:39:01 +08:00
Nexmoe
c1806e5321 fix(main): hide window on autostart (#68) 2026-01-09 21:03:25 +08:00
Nexmoe
92494966c6 fix(settings): merge defaults for getAll (#66) 2026-01-09 20:53:08 +08:00
Nexmoe
f04680b8c2 chore: release v1.1.3 2026-01-02 16:17:42 +08:00
Nexmoe
a5b94411fe fix(download): handle empty format selections (#64) 2026-01-02 16:16:04 +08:00
Nexmoe
71ae4a4425 fix(download): add format fallback selectors (#63) 2026-01-01 14:41:59 +08:00
Nexmoe
e8413aefae feat(ui): show update indicator on About (#58)
* feat(ui): add update indicator for about

* fix(ci): remove unused import

* fix(ci): retry binary downloads
2025-12-26 16:46:55 +08:00
Nexmoe
b229225b6e chore: release v1.1.2 2025-12-26 13:04:52 +08:00
Nexmoe
5497ed6245 chore(templates): simplify bug report (#57) 2025-12-26 13:04:30 +08:00
Nexmoe
155bf652f2 fix(ci): harden download steps (#56) 2025-12-26 13:02:21 +08:00
Nexmoe
86bd77d995 fix(download): improve format fallbacks (#55) 2025-12-26 13:02:07 +08:00
Nexmoe
b38b356c22 feat(issue): add issue templates (#54) 2025-12-26 12:53:51 +08:00
Nexmoe
5020605590 chore: release v1.1.1 2025-12-25 22:20:18 +08:00
Nexmoe
b15c3d8ce5 feat(about): refresh description and links (#52) 2025-12-25 22:18:04 +08:00
Nexmoe
a51655697d fix(update): streamline update notification (#51) 2025-12-23 09:43:14 +08:00
Nexmoe
f5c26f5f02 Animate advanced options panels (#47) 2025-12-21 13:32:46 +08:00
Nexmoe
3bc9d76f6e Add JS runtime support for yt-dlp #36 (#46)
* Add yt-dlp JS runtime support

* Update build scripts and logging

* fix: update README to include DeepWiki badge and correct contributor badge logo
2025-12-21 10:38:04 +08:00
Nexmoe
5751d73d5b Limit electron languages to English (#45) 2025-12-20 22:34:06 +08:00
Nexmoe
50bd9f1659 fix: update README download links to point to new VidBee website
* Replace individual OS download links with a single link to the VidBee download page for improved clarity and accessibility.
2025-12-20 21:49:46 +08:00
Nexmoe
e7627e1132 chore: release v1.1.0 2025-12-20 19:26:01 +08:00
Nexmoe
d7b131d795 feat: update configuration and improve download components
* Modify biome.json to exclude specific directories from scanning and add experimental scanner ignores.
* Refine .vscode/settings.json for better Tailwind CSS integration and maintain locales paths.
* Simplify DownloadDialog component by removing unused imports and optimizing button rendering.
* Enhance DownloadItem component's accessibility and interaction handling.
* Introduce Input component in AdvancedOptions for improved user input handling.
2025-12-20 19:25:45 +08:00
Nexmoe
74dbb223ce feat: enhance README with browser script and RSS auto-download features
* Update VidBee description to include RSS auto-download automation.
* Add section for VidBee browser script installation and usage, detailing supported sites for quick downloads.
* Introduce new screenshot for the browser script feature.
2025-12-20 19:14:13 +08:00
Nexmoe
11a931f865 feat: add VidBee quick download user script and update .gitignore
* Introduce a new user script for quick downloads from various video platforms.
* Update .gitignore to exclude 'dist' and include 'dist-ssr' directory.
2025-12-20 19:04:42 +08:00
Nexmoe
79a78d4322 feat: initialize VidBee download extension with core functionality
* Add .gitignore files for main and monkey directories.
* Create initial project structure for VidBee download extension.
* Implement main functionality in src/main/index.ts to handle deep links.
* Develop UI components including DownloadDialog and VidBee download button.
* Set up TypeScript configuration and Vite for build process.
* Add styles for download button and tooltips.
* Integrate localization for user interface elements.
* Establish deep link handling in the renderer for improved user experience.
2025-12-20 19:03:13 +08:00
Nexmoe
a1c44baf65 feat: refactor download components and enhance UI/UX
* Rename ElectronStore to LegacyStore for clarity in subscription manager.
* Remove unused deep link state in AppContent and add a TODO for future handling.
* Introduce a new DownloadDialog component for managing downloads.
* Update DownloadItem to include a details sheet for video metadata.
* Enhance PlaylistDownloadGroup to persist expanded state in local storage.
* Improve UnifiedDownloadHistory layout and integrate DownloadDialog for better user interaction.
* Update various UI components for consistency and improved styling.
* Add new localization strings for download actions across multiple languages.
2025-12-20 17:03:12 +08:00
Nexmoe
86e6b93476 Add custom download folders and refine RSS dialog #37 (#44)
* Add custom download folders

* Refine RSS defaults and dialog layout

* Record template folder in download path

* Store template folder in history paths

* Resolve template vars for history paths

* Default RSS directory to Subscriptions
2025-12-20 13:05:12 +08:00
Nexmoe
dbcd77963f Add bulk history actions and selection UX #37 (#42)
* Add collapsed playlist progress

* Improve history bulk actions and selection UX

* Fix ffmpeg download resolution in setup script
2025-12-20 12:07:13 +08:00
Nexmoe
58a4d2da36 Handle download deep links (#41)
* Add download deep link support

* Queue deep links until renderer ready
2025-12-19 22:18:11 +08:00
Nexmoe
db16d5d051 Remove supported sites page (#40)
* Remove supported sites page

* Remove supported sites page component
2025-12-19 20:41:58 +08:00
Nexmoe
41f40b6e71 Use icon tabs with tooltips (#38)
* feat: icon tabs with tooltips

* chore: add conductor config
2025-12-19 18:03:03 +08:00
Nexmoe
1ee717888f chore: release v1.0.2 2025-12-06 16:46:10 +08:00
Nexmoe
c7815eb81a feat: register vidbee protocol and respect vidbee:// paths in images (#35) 2025-12-06 16:45:48 +08:00
Nexmoe
3946b5ef2f feat: resolve ~ in config paths before passing to engine (#34) 2025-11-29 11:10:16 +08:00
Nexmoe
ef99eb1f59 feat: add Chromium option closes #32 (#33) 2025-11-22 19:28:20 +08:00
93 changed files with 6882 additions and 2097 deletions

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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

View 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

View File

@@ -76,10 +76,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,7 +103,11 @@ 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 }}"
@@ -112,7 +116,7 @@ jobs:
- 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
@@ -130,4 +134,3 @@ jobs:
name: dist-${{ matrix.os }} name: dist-${{ matrix.os }}
path: dist/ path: dist/
retention-days: 1 retention-days: 1

4
.gitignore vendored
View File

@@ -1,7 +1,7 @@
node_modules node_modules
dist /dist
out out
.conductor/
.DS_Store .DS_Store
.eslintcache .eslintcache
*.log* *.log*

View File

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

View File

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

View File

@@ -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.
![VidBee Browser Script](screenshots/browser-script.png)
**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,
![VidBee Download Queue](screenshots/download-queue.png) ![VidBee Download Queue](screenshots/download-queue.png)
### 📡 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:

View File

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

6
conductor.json Normal file
View File

@@ -0,0 +1,6 @@
{
"scripts": {
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
"run": "pnpm run dev"
}
}

View File

@@ -2,6 +2,10 @@ appId: com.vidbee
productName: VidBee productName: VidBee
directories: directories:
buildResources: build buildResources: build
protocols:
- name: VidBee
schemes:
- vidbee
files: files:
- '!**/.vscode/*' - '!**/.vscode/*'
- '!src/*' - '!src/*'
@@ -19,6 +23,7 @@ nsis:
uninstallDisplayName: ${productName} uninstallDisplayName: ${productName}
createDesktopShortcut: always createDesktopShortcut: always
mac: mac:
identity: null
entitlementsInherit: build/entitlements.mac.plist entitlementsInherit: build/entitlements.mac.plist
notarize: false notarize: false
artifactName: ${name}-${version}-${arch}.${ext} artifactName: ${name}-${version}-${arch}.${ext}
@@ -45,3 +50,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

23
monkey/.gitignore vendored Normal file
View 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?

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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/*'
]
}
})
]
})

View File

@@ -1,6 +1,6 @@
{ {
"name": "vidbee", "name": "vidbee",
"version": "1.0.1", "version": "1.1.5",
"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
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

View File

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

View File

@@ -15,6 +15,9 @@ 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: /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: /linux64.*gpl.*\.tar\.xz$/i,
binaryName: 'ffmpeg'
}
} }
} }
} }
@@ -83,40 +104,159 @@ 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)
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
safeUnlink(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
file.close()
safeUnlink(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
}
response.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
})
request.setTimeout(30000, () => {
request.destroy(new Error('Download timeout'))
})
request.on('error', (err) => {
file.close()
safeUnlink(dest)
reject(err)
})
})
}
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
await downloadFile(url, dest)
return
} catch (error) {
lastError = error
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed (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,6 +298,32 @@ function fileExists(filePath) {
return fs.existsSync(filePath) return fs.existsSync(filePath)
} }
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
@@ -173,7 +339,7 @@ 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)
log(`Downloaded ${output} successfully`, 'success') log(`Downloaded ${output} successfully`, 'success')
@@ -186,7 +352,7 @@ 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)) {
@@ -197,9 +363,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)
@@ -229,7 +412,7 @@ 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)) {
@@ -240,9 +423,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)
@@ -266,7 +461,7 @@ 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)) {
@@ -277,9 +472,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)
@@ -302,6 +514,52 @@ 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)) {
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)
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 +577,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)

View File

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

View File

@@ -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,7 +65,8 @@ 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', '--embed-chapters', '--no-mtime']
@@ -115,8 +130,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)

View File

@@ -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'
@@ -22,8 +31,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 +136,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 +176,9 @@ export function createWindow(): void {
}) })
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
if (shouldStartHidden) {
return
}
mainWindow?.show() mainWindow?.show()
}) })
@@ -84,17 +192,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 +265,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...')
@@ -158,9 +366,7 @@ function initAutoUpdater(): void {
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.send('update:show-notification', { mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready', version: info.version
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
}) })
} }
}) })
@@ -182,6 +388,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 +417,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
@@ -228,6 +474,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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 setUpdateReady = useSetAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const { t } = useTranslation() const { t } = 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,34 +172,31 @@ function AppContent() {
return return
} }
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const resetDownloadState = () => { const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) { if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false updateDownloadInProgressRef.current = false
} }
} }
const handleUpdateAvailable = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
setUpdateAvailable({
available: true,
version: info.version
})
}
const handleUpdateDownloaded = (rawInfo: unknown) => { const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string } const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState() resetDownloadState()
setUpdateReady({
const versionLabel = info?.version ?? '' ready: true,
const downloadedMessage = versionLabel version: info.version
? t('about.notifications.updateDownloadedVersion', { version: versionLabel }) })
: t('about.notifications.updateDownloaded') setUpdateAvailable({
toast.success(downloadedMessage) available: true,
version: info.version
showRestartPrompt() })
} }
const handleUpdateError = (rawMessage: unknown) => { const handleUpdateError = (rawMessage: unknown) => {
@@ -154,13 +215,13 @@ function AppContent() {
} }
const handleUpdateNotification = (rawPayload: unknown) => { const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string } const payload = (rawPayload ?? {}) as { version?: string }
const versionLabel = payload.version ?? '' const versionLabel = payload?.version ?? ''
const downloadedMessage = versionLabel const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel }) ? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded') : t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? downloadedMessage, { toast.info(downloadedMessage, {
action: { action: {
label: t('about.notifications.restartNowAction'), label: t('about.notifications.restartNowAction'),
onClick: () => { onClick: () => {
@@ -171,51 +232,30 @@ function AppContent() {
} }
// 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) 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) ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
} }
}, [t]) }, [setUpdateAvailable, setUpdateReady, t])
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 +266,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 +293,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>
) )
} }

View File

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

File diff suppressed because it is too large Load Diff

View 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(() => {
@@ -494,7 +499,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 +643,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 +713,64 @@ 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>
)} )}
{/* 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 +780,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 +799,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 +819,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 +842,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 +861,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 +882,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,13 +898,13 @@ 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="space-y-1 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"> <div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
<span className="font-medium shrink-0"> <span className="font-medium shrink-0">
{download.progress.percent.toFixed(1)}% {download.progress.percent.toFixed(1)}%
</span> </span>
<div className="flex flex-wrap items-center gap-3 min-w-0 flex-1"> <div className="flex flex-wrap items-center gap-2 min-w-0 flex-1">
{download.progress.downloaded && download.progress.total && ( {download.progress.downloaded && download.progress.total && (
<span className="truncate max-w-[100px]"> <span className="truncate max-w-[100px]">
{download.progress.downloaded} / {download.progress.total} {download.progress.downloaded} / {download.progress.total}
@@ -902,6 +929,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>
) )
} }

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 className="h-11">
</SelectTrigger> <SelectValue />
<SelectContent className="max-h-[300px] p-1.5"> </SelectTrigger>
{videoFormats.map((format) => ( <SelectContent className="max-h-[300px] p-1.5">
<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 py-2.5"
<span className="text-sm">{formatVideoLabel(format)}</span> >
</SelectItem> <span className="text-sm">{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 className="h-11">
</SelectTrigger> <SelectValue />
<SelectContent className="max-h-[300px] p-1.5"> </SelectTrigger>
<SelectItem value="none" className="cursor-pointer py-2.5"> <SelectContent className="max-h-[300px] p-1.5">
<span className="text-sm">{t('download.noAudio')}</span> <SelectItem value="none" className="cursor-pointer py-2.5">
</SelectItem> <span className="text-sm">{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 py-2.5"
>
<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>

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
/**
* Renderer process logger utility
* Use electron-log/renderer which automatically forwards logs to main process
*/
import log from 'electron-log/renderer'
// Export electron-log instance
export default log
// Export commonly used logging methods
export const logger = log
// Predefined scoped loggers
export const scopedLoggers = {
renderer: log.scope('renderer'),
error: log.scope('error'),
component: log.scope('component'),
api: log.scope('api')
}

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "اختر تنسيق الصوت", "selectAudioFormat": "اختر تنسيق الصوت",
"selectFormat": "اختر التنسيق", "selectFormat": "اختر التنسيق",
"selectVideoFormat": "اختر تنسيق الفيديو", "selectVideoFormat": "اختر تنسيق الفيديو",
"startDownload": "بدء التحميل",
"singleVideo": "فيديو واحد", "singleVideo": "فيديو واحد",
"speed": "السرعة", "speed": "السرعة",
"title": "العنوان", "title": "العنوان",
@@ -370,8 +371,7 @@
"showMoreFormats": "إظهار المزيد من خيارات التنسيق", "showMoreFormats": "إظهار المزيد من خيارات التنسيق",
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة", "showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.", "filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
}, },
"system": "النظام", "system": "النظام",
"theme": "المظهر", "theme": "المظهر",
@@ -392,7 +392,6 @@
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.", "description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
"downloadDirectory": "مجلد التحميل", "downloadDirectory": "مجلد التحميل",
"filenameTemplate": "قالب اسم الملف (الملف فقط)", "filenameTemplate": "قالب اسم الملف (الملف فقط)",
"checkInterval": "فترة الفحص (ساعات)",
"onlyLatest": "تحميل أحدث فيديو فقط", "onlyLatest": "تحميل أحدث فيديو فقط",
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل." "onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
}, },

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Audio-Format auswählen", "selectAudioFormat": "Audio-Format auswählen",
"selectFormat": "Format auswählen", "selectFormat": "Format auswählen",
"selectVideoFormat": "Video-Format auswählen", "selectVideoFormat": "Video-Format auswählen",
"startDownload": "Download starten",
"singleVideo": "Einzelnes Video", "singleVideo": "Einzelnes Video",
"speed": "Geschwindigkeit", "speed": "Geschwindigkeit",
"title": "Titel", "title": "Titel",
@@ -370,8 +371,7 @@
"showMoreFormats": "Mehr Formatoptionen anzeigen", "showMoreFormats": "Mehr Formatoptionen anzeigen",
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen", "showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.", "filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt."
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
}, },
"system": "System", "system": "System",
"theme": "Design", "theme": "Design",
@@ -392,7 +392,6 @@
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.", "description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
"downloadDirectory": "Download-Verzeichnis", "downloadDirectory": "Download-Verzeichnis",
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)", "filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
"checkInterval": "Prüfintervall (Stunden)",
"onlyLatest": "Nur das neueste Video herunterladen", "onlyLatest": "Nur das neueste Video herunterladen",
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload." "onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
}, },

View File

@@ -15,7 +15,7 @@
"autoUpdateTitle": "Auto updates", "autoUpdateTitle": "Auto updates",
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.", "betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel", "betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.", "description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
"followAuthorActions": { "followAuthorActions": {
"follow": "Follow @nexmoex" "follow": "Follow @nexmoex"
}, },
@@ -50,7 +50,13 @@
"documentation": "Help center", "documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.", "documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues", "feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.", "feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Report bugs or request features on GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.",
"discord": "Discord",
"discordDescription": "Join our Discord community for discussions and support.",
"license": "License", "license": "License",
"licenseDescription": "Review the open-source license terms.", "licenseDescription": "Review the open-source license terms.",
"website": "Official website", "website": "Official website",
@@ -123,6 +129,10 @@
"downloadBtn": "Download", "downloadBtn": "Download",
"downloadPending": "Pending", "downloadPending": "Pending",
"downloadQueue": "Download Queue", "downloadQueue": "Download Queue",
"customDownloadFolder": "Custom download folder",
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
"autoFolderHint": "Automatic folders are created from metadata.",
"useAutoFolder": "Use automatic folder",
"downloadVideo": "Download Video", "downloadVideo": "Download Video",
"downloading": "Downloading...", "downloading": "Downloading...",
"enterUrl": "Enter Video URL", "enterUrl": "Enter Video URL",
@@ -155,7 +165,9 @@
"showDetails": "Show details", "showDetails": "Show details",
"hideDetails": "Hide details", "hideDetails": "Hide details",
"selectAudioFormat": "Select Audio Format", "selectAudioFormat": "Select Audio Format",
"selectDownloadType": "Select download type",
"selectFormat": "Select Format", "selectFormat": "Select Format",
"startDownload": "Start Download",
"selectVideoFormat": "Select Video Format", "selectVideoFormat": "Select Video Format",
"singleVideo": "Single Video", "singleVideo": "Single Video",
"speed": "Speed", "speed": "Speed",
@@ -194,6 +206,25 @@
"subscription": "Subscription" "subscription": "Subscription"
} }
}, },
"error": {
"title": "Something went wrong",
"description": "An unexpected error occurred. Please try reloading the application or report this issue if it persists.",
"message": "Error Message",
"unknownError": "Unknown error occurred",
"goHome": "Go Home",
"reload": "Reload App",
"copyReport": "Copy Error Report",
"copied": "Copied!",
"copySuccess": "Error report copied to clipboard",
"copyFailed": "Failed to copy error report",
"showDetails": "Show Details",
"hideDetails": "Hide Details",
"stackTrace": "Stack Trace",
"componentStack": "Component Stack",
"noStackTrace": "No stack trace available",
"fullReport": "Full Error Report",
"helpText": "If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page."
},
"errors": { "errors": {
"clickToCopy": "Click to copy details", "clickToCopy": "Click to copy details",
"clipboardEmpty": "Clipboard is empty", "clipboardEmpty": "Clipboard is empty",
@@ -209,10 +240,23 @@
"clearCancelled": "Clear Cancelled", "clearCancelled": "Clear Cancelled",
"clearCompleted": "Clear Completed", "clearCompleted": "Clear Completed",
"clearErrors": "Clear Errors", "clearErrors": "Clear Errors",
"clearAll": "Clear All History",
"clearAllAction": "Clear History",
"clearSelection": "Clear Selection",
"confirmClearAllTitle": "Clear all history?",
"confirmClearAllDescription": "Remove {{count}} items from your history. Files stay on disk.",
"confirmDeleteSelectedTitle": "Remove selected items?",
"confirmDeleteSelectedDescription": "Remove {{count}} items from your history. Files stay on disk.",
"alsoDeleteFiles": "Also delete files",
"confirmDeletePlaylistTitle": "Remove playlist history?",
"confirmDeletePlaylistDescription": "Remove {{count}} items from {{title}} and delete their files.",
"copyToClipboard": "Copy to clipboard", "copyToClipboard": "Copy to clipboard",
"copyUrl": "Copy URL", "copyUrl": "Copy URL",
"date": "Date", "date": "Date",
"deletePlaylist": "Remove Playlist",
"deleteSelected": "Remove Selected",
"description": "View and manage your download history", "description": "View and manage your download history",
"doneSelecting": "Done",
"duration": "Duration", "duration": "Duration",
"fileSize": "File Size", "fileSize": "File Size",
"filters": { "filters": {
@@ -228,7 +272,14 @@
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
"openFolder": "Open Folder", "openFolder": "Open Folder",
"openInBrowser": "Click to open in browser", "openInBrowser": "Click to open in browser",
"removeAction": "Remove",
"removeItem": "Remove Item", "removeItem": "Remove Item",
"select": "Select",
"selectAll": "Select All",
"selectVisible": "Select visible",
"selectItem": "Select item",
"selectedCount": "{{count}} selected",
"selectionSummary": "{{selected}} of {{total}} visible selected",
"stats": { "stats": {
"cancelled": "Cancelled", "cancelled": "Cancelled",
"completed": "Completed", "completed": "Completed",
@@ -257,9 +308,15 @@
"downloadCompleted": "Download completed", "downloadCompleted": "Download completed",
"downloadFailed": "Download failed", "downloadFailed": "Download failed",
"downloadStarted": "Download started", "downloadStarted": "Download started",
"historyCleared": "History cleared",
"historyClearFailed": "Failed to clear history",
"itemRemoved": "Item removed", "itemRemoved": "Item removed",
"itemsRemoved": "Removed {{count}} items",
"itemsRemoveFailed": "Failed to remove selected items",
"openFileFailed": "Failed to open file", "openFileFailed": "Failed to open file",
"openFolderFailed": "Failed to open folder", "openFolderFailed": "Failed to open folder",
"playlistHistoryRemoved": "Playlist removed and files deleted",
"playlistHistoryRemoveFailed": "Failed to remove playlist history",
"removeFailed": "Failed to remove item", "removeFailed": "Failed to remove item",
"settingsSaved": "Settings saved", "settingsSaved": "Settings saved",
"urlCopied": "URL copied to clipboard", "urlCopied": "URL copied to clipboard",
@@ -268,6 +325,7 @@
"playlist": { "playlist": {
"badgeLabel": "Playlist", "badgeLabel": "Playlist",
"clearPreview": "Clear preview", "clearPreview": "Clear preview",
"collapsedProgress": "Downloading playlist: {{completed}} / {{total}} completed",
"comingSoon": "Playlist download feature coming soon!", "comingSoon": "Playlist download feature coming soon!",
"completed": "Playlist downloaded", "completed": "Playlist downloaded",
"description": "Download all videos from a YouTube playlist or channel", "description": "Download all videos from a YouTube playlist or channel",
@@ -283,7 +341,9 @@
"folderFormat": "Folder name format for playlists", "folderFormat": "Folder name format for playlists",
"foundVideos": "Found {{count}} videos in playlist", "foundVideos": "Found {{count}} videos in playlist",
"groupActive": "{{count}} active", "groupActive": "{{count}} active",
"groupCollapse": "Collapse",
"groupErrors": "{{count}} failed", "groupErrors": "{{count}} failed",
"groupExpand": "Expand",
"groupSummary": "{{completed}} / {{total}} completed", "groupSummary": "{{completed}} / {{total}} completed",
"linkLabel": "Playlist URL", "linkLabel": "Playlist URL",
"noEntries": "No videos were found in this playlist", "noEntries": "No videos were found in this playlist",
@@ -298,6 +358,8 @@
"range": "Range (Optional)", "range": "Range (Optional)",
"resetToDefault": "Reset to default", "resetToDefault": "Reset to default",
"selectedRange": "Range: {{start}}-{{end}}", "selectedRange": "Range: {{start}}-{{end}}",
"selectedVideos": "{{count}} selected",
"downloadCurrentRange": "Download Selected",
"showingCount": "Showing {{count}} videos", "showingCount": "Showing {{count}} videos",
"startIndex": "Start (1)", "startIndex": "Start (1)",
"title": "Download Playlist", "title": "Download Playlist",
@@ -322,6 +384,7 @@
"browserOptions": { "browserOptions": {
"brave": "Brave", "brave": "Brave",
"chrome": "Chrome", "chrome": "Chrome",
"chromium": "Chromium",
"edge": "Edge", "edge": "Edge",
"firefox": "Firefox", "firefox": "Firefox",
"safari": "Safari" "safari": "Safari"
@@ -337,6 +400,7 @@
"fileSelectError": "Failed to select file", "fileSelectError": "Failed to select file",
"general": "General", "general": "General",
"language": "Language", "language": "Language",
"languageDescription": "Choose your preferred language for the application interface",
"light": "Light", "light": "Light",
"hideDockIcon": "Hide Dock icon", "hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.", "hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
@@ -370,8 +434,7 @@
"showMoreFormats": "Show more format options", "showMoreFormats": "Show more format options",
"showMoreFormatsDescription": "Display additional format options in the interface", "showMoreFormatsDescription": "Display additional format options in the interface",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Pattern used when a subscription does not override its filename.", "filenameDescription": "Pattern used when a subscription does not override its filename."
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
}, },
"system": "System", "system": "System",
"theme": "Theme", "theme": "Theme",
@@ -391,8 +454,7 @@
"title": "Automation defaults", "title": "Automation defaults",
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.", "description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
"downloadDirectory": "Download directory", "downloadDirectory": "Download directory",
"filenameTemplate": "Filename template (file only)", "filenameTemplate": "Filename template",
"checkInterval": "Check interval (hours)",
"onlyLatest": "Download only the latest video", "onlyLatest": "Download only the latest video",
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload." "onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
}, },
@@ -405,7 +467,7 @@
"keywords": "Keyword filter (comma separated)", "keywords": "Keyword filter (comma separated)",
"tags": "Auto tags", "tags": "Auto tags",
"customDirectory": "Custom directory", "customDirectory": "Custom directory",
"namingTemplate": "Custom filename template (file only)", "namingTemplate": "Custom filename template",
"onlyLatest": "Download only the latest video", "onlyLatest": "Download only the latest video",
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.", "onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
"enabled": "Enabled", "enabled": "Enabled",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Seleccionar Formato de Audio", "selectAudioFormat": "Seleccionar Formato de Audio",
"selectFormat": "Seleccionar Formato", "selectFormat": "Seleccionar Formato",
"selectVideoFormat": "Seleccionar Formato de Video", "selectVideoFormat": "Seleccionar Formato de Video",
"startDownload": "Iniciar descarga",
"singleVideo": "Video Individual", "singleVideo": "Video Individual",
"speed": "Velocidad", "speed": "Velocidad",
"title": "Título", "title": "Título",
@@ -370,8 +371,7 @@
"showMoreFormats": "Mostrar más opciones de formato", "showMoreFormats": "Mostrar más opciones de formato",
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz", "showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.", "filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo."
"intervalDescription": "Con qué frecuencia VidBee verifica cada feed de suscripción (1-24 horas)."
}, },
"system": "Sistema", "system": "Sistema",
"theme": "Tema", "theme": "Tema",
@@ -392,7 +392,6 @@
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.", "description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
"downloadDirectory": "Directorio de descarga", "downloadDirectory": "Directorio de descarga",
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)", "filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
"checkInterval": "Intervalo de verificación (horas)",
"onlyLatest": "Descargar solo el último video", "onlyLatest": "Descargar solo el último video",
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga." "onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
}, },

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Sélectionner le Format Audio", "selectAudioFormat": "Sélectionner le Format Audio",
"selectFormat": "Sélectionner le Format", "selectFormat": "Sélectionner le Format",
"selectVideoFormat": "Sélectionner le Format Vidéo", "selectVideoFormat": "Sélectionner le Format Vidéo",
"startDownload": "Démarrer le téléchargement",
"showDetails": "Afficher les détails", "showDetails": "Afficher les détails",
"singleVideo": "Vidéo Unique", "singleVideo": "Vidéo Unique",
"speed": "Vitesse", "speed": "Vitesse",
@@ -370,8 +371,7 @@
"showMoreFormats": "Afficher plus d'options de format", "showMoreFormats": "Afficher plus d'options de format",
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface", "showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier.", "filenameDescription": "Modèle utilisé lorsquun abonnement ne remplace pas son nom de fichier."
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
}, },
"system": "Système", "system": "Système",
"theme": "Thème", "theme": "Thème",
@@ -484,7 +484,6 @@
"title": "Ajouter un flux RSS" "title": "Ajouter un flux RSS"
}, },
"defaults": { "defaults": {
"checkInterval": "Intervalle de vérification (heures)",
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.", "description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
"downloadDirectory": "Répertoire de téléchargement", "downloadDirectory": "Répertoire de téléchargement",
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)", "filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Pilih Format Audio", "selectAudioFormat": "Pilih Format Audio",
"selectFormat": "Pilih Format", "selectFormat": "Pilih Format",
"selectVideoFormat": "Pilih Format Video", "selectVideoFormat": "Pilih Format Video",
"startDownload": "Mulai unduhan",
"singleVideo": "Video Tunggal", "singleVideo": "Video Tunggal",
"speed": "Kecepatan", "speed": "Kecepatan",
"title": "Judul", "title": "Judul",
@@ -370,8 +371,7 @@
"showMoreFormats": "Tampilkan lebih banyak opsi format", "showMoreFormats": "Tampilkan lebih banyak opsi format",
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka", "showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.", "filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya."
"intervalDescription": "Seberapa sering VidBee memeriksa setiap feed berlangganan (1-24 jam)."
}, },
"system": "Sistem", "system": "Sistem",
"theme": "Tema", "theme": "Tema",
@@ -392,7 +392,6 @@
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.", "description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
"downloadDirectory": "Direktori unduhan", "downloadDirectory": "Direktori unduhan",
"filenameTemplate": "Template nama file (hanya file)", "filenameTemplate": "Template nama file (hanya file)",
"checkInterval": "Interval pemeriksaan (jam)",
"onlyLatest": "Unduh hanya video terbaru", "onlyLatest": "Unduh hanya video terbaru",
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru." "onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
}, },

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Seleziona Formato Audio", "selectAudioFormat": "Seleziona Formato Audio",
"selectFormat": "Seleziona Formato", "selectFormat": "Seleziona Formato",
"selectVideoFormat": "Seleziona Formato Video", "selectVideoFormat": "Seleziona Formato Video",
"startDownload": "Avvia download",
"showDetails": "Mostra dettagli", "showDetails": "Mostra dettagli",
"singleVideo": "Video Singolo", "singleVideo": "Video Singolo",
"speed": "Velocità", "speed": "Velocità",
@@ -370,8 +371,7 @@
"showMoreFormats": "Mostra più opzioni formato", "showMoreFormats": "Mostra più opzioni formato",
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia", "showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.", "filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file."
"intervalDescription": "La frequenza con cui VidBee controlla ciascun feed di abbonamento (1-24 ore)."
}, },
"system": "Sistema", "system": "Sistema",
"theme": "Tema", "theme": "Tema",
@@ -484,7 +484,6 @@
"title": "Aggiungi RSS" "title": "Aggiungi RSS"
}, },
"defaults": { "defaults": {
"checkInterval": "Intervallo di controllo (ore)",
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.", "description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
"downloadDirectory": "Scarica la directory", "downloadDirectory": "Scarica la directory",
"filenameTemplate": "Modello nome file (solo file)", "filenameTemplate": "Modello nome file (solo file)",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "オーディオフォーマットを選択", "selectAudioFormat": "オーディオフォーマットを選択",
"selectFormat": "フォーマットを選択", "selectFormat": "フォーマットを選択",
"selectVideoFormat": "ビデオフォーマットを選択", "selectVideoFormat": "ビデオフォーマットを選択",
"startDownload": "ダウンロードを開始",
"showDetails": "詳細を表示", "showDetails": "詳細を表示",
"singleVideo": "単一ビデオ", "singleVideo": "単一ビデオ",
"speed": "速度", "speed": "速度",
@@ -370,8 +371,7 @@
"showMoreFormats": "より多くのフォーマットオプションを表示", "showMoreFormats": "より多くのフォーマットオプションを表示",
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示", "showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。", "filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 24 時間)。"
}, },
"system": "システム", "system": "システム",
"theme": "テーマ", "theme": "テーマ",
@@ -484,7 +484,6 @@
"title": "RSSを追加" "title": "RSSを追加"
}, },
"defaults": { "defaults": {
"checkInterval": "チェック間隔(時間)",
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。", "description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
"downloadDirectory": "ダウンロードディレクトリ", "downloadDirectory": "ダウンロードディレクトリ",
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)", "filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "오디오 형식 선택", "selectAudioFormat": "오디오 형식 선택",
"selectFormat": "형식 선택", "selectFormat": "형식 선택",
"selectVideoFormat": "비디오 형식 선택", "selectVideoFormat": "비디오 형식 선택",
"startDownload": "다운로드 시작",
"showDetails": "세부정보 표시", "showDetails": "세부정보 표시",
"singleVideo": "단일 비디오", "singleVideo": "단일 비디오",
"speed": "속도", "speed": "속도",
@@ -370,8 +371,7 @@
"showMoreFormats": "더 많은 형식 옵션 표시", "showMoreFormats": "더 많은 형식 옵션 표시",
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시", "showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.", "filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다."
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
}, },
"system": "시스템", "system": "시스템",
"theme": "테마", "theme": "테마",
@@ -484,7 +484,6 @@
"title": "RSS 추가" "title": "RSS 추가"
}, },
"defaults": { "defaults": {
"checkInterval": "확인 간격(시간)",
"description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.", "description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.",
"downloadDirectory": "디렉토리 다운로드", "downloadDirectory": "디렉토리 다운로드",
"filenameTemplate": "파일 이름 템플릿(파일만)", "filenameTemplate": "파일 이름 템플릿(파일만)",

View File

@@ -182,6 +182,7 @@
"selectAudioFormat": "Selecionar Formato de Áudio", "selectAudioFormat": "Selecionar Formato de Áudio",
"selectFormat": "Selecionar Formato", "selectFormat": "Selecionar Formato",
"selectVideoFormat": "Selecionar Formato de Vídeo", "selectVideoFormat": "Selecionar Formato de Vídeo",
"startDownload": "Iniciar download",
"showDetails": "Mostrar detalhes", "showDetails": "Mostrar detalhes",
"singleVideo": "Vídeo Único", "singleVideo": "Vídeo Único",
"speed": "Velocidade", "speed": "Velocidade",
@@ -370,8 +371,7 @@
"showMoreFormats": "Mostrar mais opções de formato", "showMoreFormats": "Mostrar mais opções de formato",
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface", "showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo.", "filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo."
"intervalDescription": "Com que frequência o VidBee verifica cada feed de assinatura (1 a 24 horas)."
}, },
"system": "Sistema", "system": "Sistema",
"theme": "Tema", "theme": "Tema",
@@ -484,7 +484,6 @@
"title": "Adicionar RSS" "title": "Adicionar RSS"
}, },
"defaults": { "defaults": {
"checkInterval": "Intervalo de verificação (horas)",
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.", "description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
"downloadDirectory": "Baixar diretório", "downloadDirectory": "Baixar diretório",
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)", "filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",

View File

@@ -157,6 +157,7 @@
"selectAudioFormat": "Выбрать формат аудио", "selectAudioFormat": "Выбрать формат аудио",
"selectFormat": "Выбрать формат", "selectFormat": "Выбрать формат",
"selectVideoFormat": "Выбрать формат видео", "selectVideoFormat": "Выбрать формат видео",
"startDownload": "Начать загрузку",
"singleVideo": "Одно видео", "singleVideo": "Одно видео",
"speed": "Скорость", "speed": "Скорость",
"title": "Название", "title": "Название",
@@ -370,8 +371,7 @@
"showMoreFormats": "Показать больше вариантов форматов", "showMoreFormats": "Показать больше вариантов форматов",
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе", "showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.", "filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла."
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
}, },
"system": "Системная", "system": "Системная",
"theme": "Тема", "theme": "Тема",
@@ -392,7 +392,6 @@
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.", "description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
"downloadDirectory": "Директория загрузки", "downloadDirectory": "Директория загрузки",
"filenameTemplate": "Шаблон имени файла (только файл)", "filenameTemplate": "Шаблон имени файла (только файл)",
"checkInterval": "Интервал проверки (часы)",
"onlyLatest": "Загружать только последнее видео", "onlyLatest": "Загружать только последнее видео",
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку." "onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
}, },

View File

@@ -180,8 +180,10 @@
"processing": "處理中", "processing": "處理中",
"progress": "進度", "progress": "進度",
"selectAudioFormat": "選擇音訊格式", "selectAudioFormat": "選擇音訊格式",
"selectDownloadType": "選擇下載類型",
"selectFormat": "選擇格式", "selectFormat": "選擇格式",
"selectVideoFormat": "選擇影片格式", "selectVideoFormat": "選擇影片格式",
"startDownload": "開始下載",
"showDetails": "顯示詳情", "showDetails": "顯示詳情",
"singleVideo": "單個影片", "singleVideo": "單個影片",
"speed": "速度", "speed": "速度",
@@ -370,8 +372,7 @@
"showMoreFormats": "顯示更多格式選項", "showMoreFormats": "顯示更多格式選項",
"showMoreFormatsDescription": "在介面中顯示額外的格式選項", "showMoreFormatsDescription": "在介面中顯示額外的格式選項",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。", "filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。"
"intervalDescription": "VidBee 檢查每個訂閱源的頻率1-24 小時)。"
}, },
"system": "系統", "system": "系統",
"theme": "主題", "theme": "主題",
@@ -484,7 +485,6 @@
"title": "添加RSS" "title": "添加RSS"
}, },
"defaults": { "defaults": {
"checkInterval": "檢查間隔(小時)",
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。", "description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
"downloadDirectory": "下載目錄", "downloadDirectory": "下載目錄",
"filenameTemplate": "文件名模板(僅限文件)", "filenameTemplate": "文件名模板(僅限文件)",

View File

@@ -180,8 +180,10 @@
"processing": "处理中", "processing": "处理中",
"progress": "进度", "progress": "进度",
"selectAudioFormat": "选择音频格式", "selectAudioFormat": "选择音频格式",
"selectDownloadType": "选择下载类型",
"selectFormat": "选择格式", "selectFormat": "选择格式",
"selectVideoFormat": "选择视频格式", "selectVideoFormat": "选择视频格式",
"startDownload": "开始下载",
"showDetails": "显示详情", "showDetails": "显示详情",
"singleVideo": "单个视频", "singleVideo": "单个视频",
"speed": "速度", "speed": "速度",
@@ -370,8 +372,7 @@
"showMoreFormats": "显示更多格式选项", "showMoreFormats": "显示更多格式选项",
"showMoreFormatsDescription": "在界面中显示额外的格式选项", "showMoreFormatsDescription": "在界面中显示额外的格式选项",
"subscriptionDefaults": { "subscriptionDefaults": {
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。", "filenameDescription": "当订阅不覆盖其文件名时使用的模式。"
"intervalDescription": "VidBee 检查每个订阅源的频率1-24 小时)。"
}, },
"system": "系统", "system": "系统",
"theme": "主题", "theme": "主题",
@@ -484,7 +485,6 @@
"title": "添加RSS" "title": "添加RSS"
}, },
"defaults": { "defaults": {
"checkInterval": "检查间隔(小时)",
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。", "description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
"downloadDirectory": "下载目录", "downloadDirectory": "下载目录",
"filenameTemplate": "文件名模板(仅限文件)", "filenameTemplate": "文件名模板(仅限文件)",

View File

@@ -6,6 +6,82 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './App' import App from './App'
import './i18n' import './i18n'
import { logger } from './lib/logger'
// Setup global error handlers
setupGlobalErrorHandlers()
// Get app version asynchronously
let appVersion: string | undefined
if (window?.api && window.electron?.ipcRenderer) {
import('./lib/ipc')
.then(({ ipcServices }) => ipcServices.app.getVersion())
.then((version) => {
appVersion = version
})
.catch((err) => {
logger.warn('Failed to get app version for error reporting:', err)
})
}
function setupGlobalErrorHandlers(): void {
// Handle uncaught JavaScript errors
window.addEventListener('error', (event) => {
logger.error('Uncaught error:', event.error)
if (window?.api) {
try {
window.api.send('error:renderer', {
error: {
name: event.error?.name || 'Error',
message: event.error?.message || event.message || 'Unknown error',
stack: event.error?.stack || event.filename
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
})
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
logger.error('Unhandled promise rejection:', event.reason)
if (window?.api) {
try {
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))
window.api.send('error:renderer', {
error: {
name: error.name || 'UnhandledPromiseRejection',
message: error.message || String(event.reason),
stack: error.stack
},
timestamp: Date.now(),
context: {
url: window.location.href,
userAgent: navigator.userAgent,
platform: navigator.platform,
version: appVersion
}
})
} catch (err) {
logger.error('Failed to send error to main process:', err)
}
}
})
}
const rootElement = document.getElementById('root') const rootElement = document.getElementById('root')
if (!rootElement) { if (!rootElement) {

View File

@@ -1,12 +1,6 @@
import { Badge } from '@renderer/components/ui/badge' import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button' import { Button } from '@renderer/components/ui/button'
import { import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Progress } from '@renderer/components/ui/progress' import { Progress } from '@renderer/components/ui/progress'
import { Switch } from '@renderer/components/ui/switch' import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
@@ -17,17 +11,17 @@ import {
FileText, FileText,
Github, Github,
Link as LinkIcon, Link as LinkIcon,
Mail,
MessageCircle, MessageCircle,
MessageSquare,
RefreshCw, RefreshCw,
ShieldCheck,
Twitter Twitter
} from 'lucide-react' } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ipcEvents, ipcServices } from '../lib/ipc' import { ipcEvents, ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings' import { saveSettingAtom, settingsAtom } from '../store/settings'
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
interface AboutResource { interface AboutResource {
icon: LucideIcon icon: LucideIcon
@@ -47,6 +41,8 @@ type LatestVersionState =
export function About() { export function About() {
const { t } = useTranslation() const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom) const [settings, _setSettings] = useAtom(settingsAtom)
const [updateReady] = useAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const [appVersion, setAppVersion] = useState<string>('—') const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null) const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null) const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
@@ -90,6 +86,10 @@ export function About() {
status: 'available', status: 'available',
version: versionLabel version: versionLabel
}) })
setUpdateAvailable({
available: true,
version: versionLabel
})
// Reset download progress when new update is available // Reset download progress when new update is available
setUpdateDownloadProgress(0) setUpdateDownloadProgress(0)
} }
@@ -115,7 +115,7 @@ export function About() {
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress) ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded) ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
} }
}, [t]) }, [setUpdateAvailable, t])
const handleSettingChange = async ( const handleSettingChange = async (
key: keyof typeof settings, key: keyof typeof settings,
@@ -137,6 +137,10 @@ export function About() {
status: 'available', status: 'available',
version: result.version ?? '' version: result.version ?? ''
}) })
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) { } else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error })) toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({ setLatestVersionState({
@@ -149,6 +153,10 @@ export function About() {
status: 'uptodate', status: 'uptodate',
version: result.version ?? appVersion version: result.version ?? appVersion
}) })
setUpdateAvailable({
available: false,
version: undefined
})
} }
} catch (error) { } catch (error) {
console.error('Failed to check for updates:', error) console.error('Failed to check for updates:', error)
@@ -161,6 +169,10 @@ export function About() {
openShareUrl('https://vidbee.org/download/') openShareUrl('https://vidbee.org/download/')
} }
const handleRestartToUpdate = () => {
void ipcServices.update.quitAndInstall()
}
const handleCheckForUpdates = async () => { const handleCheckForUpdates = async () => {
try { try {
toast.info(t('about.notifications.checkingUpdates')) toast.info(t('about.notifications.checkingUpdates'))
@@ -172,6 +184,10 @@ export function About() {
status: 'available', status: 'available',
version: result.version ?? '' version: result.version ?? ''
}) })
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) { } else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error })) toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({ setLatestVersionState({
@@ -184,6 +200,10 @@ export function About() {
status: 'uptodate', status: 'uptodate',
version: result.version ?? appVersion version: result.version ?? appVersion
}) })
setUpdateAvailable({
available: false,
version: undefined
})
} }
} catch (error) { } catch (error) {
console.error('Failed to check for updates:', error) console.error('Failed to check for updates:', error)
@@ -196,7 +216,7 @@ export function About() {
const shareLinks = useMemo(() => { const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl) const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.description')) const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)
return { return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
@@ -204,13 +224,13 @@ export function About() {
} }
}, [t]) }, [t])
const openShareUrl = (url: string) => { const openShareUrl = useCallback((url: string) => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
} }
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} }, [])
const handleShareTwitter = () => { const handleShareTwitter = () => {
openShareUrl(shareLinks.twitter) openShareUrl(shareLinks.twitter)
@@ -245,6 +265,39 @@ export function About() {
: 'text-muted-foreground' : 'text-muted-foreground'
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
const handleXFeedback = useCallback(() => {
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
}, [appVersion, openShareUrl])
const feedbackResources = useMemo<AboutResource[]>(
() => [
{
icon: Github,
label: t('about.resources.githubIssues'),
description: t('about.resources.githubIssuesDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
description: t('about.resources.xFeedbackDescription'),
actionLabel: t('about.actions.feedback'),
onClick: handleXFeedback
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
description: t('about.resources.discordDescription'),
actionLabel: t('about.actions.visit'),
href: 'https://discord.gg/uBqXV6QPdm'
}
],
[t, handleXFeedback]
)
const aboutResources = useMemo<AboutResource[]>( const aboutResources = useMemo<AboutResource[]>(
() => [ () => [
{ {
@@ -260,27 +313,6 @@ export function About() {
description: t('about.resources.changelogDescription'), description: t('about.resources.changelogDescription'),
actionLabel: t('about.actions.view'), actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/releases' href: 'https://github.com/nexmoe/VidBee/releases'
},
{
icon: MessageCircle,
label: t('about.resources.feedback'),
description: t('about.resources.feedbackDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: ShieldCheck,
label: t('about.resources.license'),
description: t('about.resources.licenseDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
},
{
icon: Mail,
label: t('about.resources.contact'),
description: t('about.resources.contactDescription'),
actionLabel: t('about.actions.email'),
href: 'mailto:nexmoex@gmail.com'
} }
], ],
[t] [t]
@@ -291,69 +323,87 @@ export function About() {
<div className="container mx-auto max-w-5xl p-6 space-y-6"> <div className="container mx-auto max-w-5xl p-6 space-y-6">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" /> <img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
<div className="space-y-2"> <div className="flex-1 space-y-2">
<div> <div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2> <div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">{t('about.description')}</p> <h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
</div> <Badge variant="secondary">
<div className="flex flex-wrap items-center gap-2"> {t('about.versionLabel', { version: appVersion })}
<Badge variant="secondary"> </Badge>
{t('about.versionLabel', { version: appVersion })} {latestVersionState ? (
</Badge> <div className="flex flex-wrap items-center gap-2">
{latestVersionState ? ( {latestVersionBadgeText ? (
<div className="flex flex-wrap items-center gap-2"> <Badge variant="outline">{latestVersionBadgeText}</Badge>
{latestVersionBadgeText ? ( ) : null}
<Badge variant="outline">{latestVersionBadgeText}</Badge> {latestVersionStatusText ? (
) : null} <span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText ? ( {latestVersionStatusText}
<span className={`text-sm ${latestVersionStatusClass}`}> </span>
{latestVersionStatusText} ) : null}
</span> </div>
) : null} ) : null}
</div>
) : null}
</div>
{updateDownloadProgress !== null && (
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
</div> </div>
)} <div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-3.5 w-3.5" />
</a>
</Button>
{updateReady.ready ? (
<Button
onClick={handleRestartToUpdate}
variant="default"
size="sm"
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
{t('about.notifications.restartNowAction')}
</Button>
) : null}
{latestVersionState?.status === 'available' ? (
<Button
onClick={handleGoToDownload}
variant="default"
size="sm"
className="gap-2"
>
<Download className="h-3.5 w-3.5" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-4 w-4" />
</a>
</Button>
{latestVersionState?.status === 'available' ? (
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
<Download className="h-4 w-4" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div> </div>
{updateDownloadProgress !== null && (
<div className="flex flex-col gap-3 pt-4">
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
</div>
</div>
)}
<div className="flex items-center justify-between gap-4 pt-6"> <div className="flex items-center justify-between gap-4 pt-6">
<div className="space-y-1"> <div className="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p> <p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
@@ -370,7 +420,6 @@ export function About() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle> <CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -415,12 +464,47 @@ export function About() {
</Card> </Card>
<Card> <Card>
<CardHeader>
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
</CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col divide-y"> <div className="flex flex-col divide-y">
{/* Feedback section - merged into one row */}
<div className="flex items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
<p className="text-sm text-muted-foreground">
{t('about.resources.feedbackDescription')}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{feedbackResources.map((resource) => {
const Icon = resource.icon
return resource.href ? (
<Button key={resource.label} variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
<Icon className="h-4 w-4" />
{resource.label}
</a>
</Button>
) : (
<Button
key={resource.label}
variant="outline"
size="sm"
onClick={resource.onClick}
className="gap-2"
>
<Icon className="h-4 w-4" />
{resource.label}
</Button>
)
})}
</div>
</div>
{/* Other resources */}
{aboutResources.map((resource) => { {aboutResources.map((resource) => {
const Icon = resource.icon const Icon = resource.icon
return ( return (

View File

@@ -1,126 +1,4 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@renderer/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { AlertCircle, Download, Loader2, Search } from 'lucide-react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory' import { UnifiedDownloadHistory } from '../components/download/UnifiedDownloadHistory'
import { PlaylistPreviewCard } from '../components/playlist/PlaylistPreviewCard'
import { VideoInfoCard } from '../components/video/VideoInfoCard'
import { ipcEvents, ipcServices } from '../lib/ipc'
import {
addDownloadAtom,
addHistoryRecordAtom,
removeDownloadAtom,
updateDownloadAtom
} from '../store/downloads'
import { loadSettingsAtom, settingsAtom } from '../store/settings'
import {
currentVideoInfoAtom,
fetchVideoInfoAtom,
videoInfoErrorAtom,
videoInfoLoadingAtom
} from '../store/video'
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
best: null,
good: 1080,
normal: 720,
bad: 480,
worst: 360
}
const qualityPresetToAudioAbr: Record<OneClickQualityPreset, number | null> = {
best: 320,
good: 256,
normal: 192,
bad: 128,
worst: 96
}
const dedupe = (candidates: Array<string | undefined>): string[] => {
const seen = new Set<string>()
const result: string[] = []
for (const candidate of candidates) {
if (!candidate) continue
if (seen.has(candidate)) continue
seen.add(candidate)
result.push(candidate)
}
return result
}
const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
settings.oneClickQuality ?? 'best'
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio']
}
const abrLimit = qualityPresetToAudioAbr[preset]
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
}
const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
}
const maxHeight = qualityPresetToVideoHeight[preset]
const videoCandidates = dedupe([
maxHeight ? `bestvideo[height<=${maxHeight}]` : undefined,
'bestvideo'
])
const audioSelectors = buildAudioSelectors(preset)
const combinations: string[] = []
for (const video of videoCandidates) {
for (const audio of audioSelectors) {
combinations.push(`${video}+${audio}`)
}
}
if (audioSelectors.includes('none')) {
for (const video of videoCandidates) {
combinations.push(video)
}
} else {
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
combinations.push('bestvideo+bestaudio')
}
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
}
interface HomeProps { interface HomeProps {
onOpenSupportedSites?: () => void onOpenSupportedSites?: () => void
@@ -128,687 +6,18 @@ interface HomeProps {
} }
export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) { export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
const { t } = useTranslation()
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
const [loading] = useAtom(videoInfoLoadingAtom)
const [error] = useAtom(videoInfoErrorAtom)
const [settings] = useAtom(settingsAtom)
const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const updateDownload = useSetAtom(updateDownloadAtom)
const addDownload = useSetAtom(addDownloadAtom)
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const [url, setUrl] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
const inlinePreviewSites = popularSites
.slice(0, 3)
.map((site) => t(`sites.popular.${site.id}.label`))
.join(', ')
// Playlist states
const playlistUrlId = useId()
const downloadTypeId = useId()
const [playlistUrl, setPlaylistUrl] = useState('')
const [downloadType, setDownloadType] = useState<'video' | 'audio'>('video')
const [startIndex, setStartIndex] = useState('1')
const [endIndex, setEndIndex] = useState('')
const [playlistInfo, setPlaylistInfo] = useState<PlaylistInfo | null>(null)
const [playlistPreviewLoading, setPlaylistPreviewLoading] = useState(false)
const [playlistDownloadLoading, setPlaylistDownloadLoading] = useState(false)
const [playlistPreviewError, setPlaylistPreviewError] = useState<string | null>(null)
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
const computePlaylistRange = useCallback(
(info: PlaylistInfo) => {
const parsedStart = Math.max(parseInt(startIndex, 10) || 1, 1)
const rawEnd = endIndex ? Math.max(parseInt(endIndex, 10), parsedStart) : undefined
const start = info.entryCount > 0 ? Math.min(parsedStart, info.entryCount) : parsedStart
const endValue =
rawEnd !== undefined
? info.entryCount > 0
? Math.min(rawEnd, info.entryCount)
: rawEnd
: undefined
return { start, end: endValue }
},
[startIndex, endIndex]
)
const selectedPlaylistEntries = useMemo(() => {
if (!playlistInfo) {
return []
}
const range = computePlaylistRange(playlistInfo)
const previewEnd = range.end ?? playlistInfo.entryCount
return playlistInfo.entries.filter(
(entry) => entry.index >= range.start && entry.index <= previewEnd
)
}, [playlistInfo, computePlaylistRange])
const syncHistoryItem = useCallback(
async (id: string) => {
try {
const historyItem = await ipcServices.history.getHistoryById(id)
if (historyItem) {
addHistoryRecord(historyItem)
removeDownload(id)
}
} catch (error) {
console.error('Failed to sync history item:', error)
}
},
[addHistoryRecord, removeDownload]
)
useEffect(() => {
// Load settings on mount
loadSettings()
// Listen for download events from main process
ipcEvents.on('download:started', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download started:', id)
updateDownload({ id, changes: { status: 'downloading' } })
})
ipcEvents.on('download:progress', (...args: unknown[]) => {
const data = args[0] as { id: string; progress: unknown }
console.log('Download progress:', data)
const progress = data.progress as {
percent: number
currentSpeed?: string
eta?: string
downloaded?: string
total?: string
}
updateDownload({
id: data.id,
changes: {
progress: {
percent: progress.percent || 0,
currentSpeed: progress.currentSpeed || '',
eta: progress.eta || '',
downloaded: progress.downloaded || '',
total: progress.total || ''
},
speed: progress.currentSpeed || ''
}
})
})
ipcEvents.on('download:completed', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download completed:', id)
updateDownload({ id, changes: { status: 'completed' } })
toast.success(t('notifications.downloadCompleted'))
void syncHistoryItem(id)
})
ipcEvents.on('download:error', (...args: unknown[]) => {
const data = args[0] as { id: string; error: string }
console.error('Download error:', data)
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
toast.error(t('notifications.downloadFailed'))
void syncHistoryItem(data.id)
})
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
const id = args[0] as string
console.log('Download cancelled:', id)
updateDownload({ id, changes: { status: 'cancelled' } })
void syncHistoryItem(id)
})
return () => {
// Note: Event listeners are automatically cleaned up when the component unmounts
// The removeListener calls are not needed as the event system handles cleanup
}
}, [loadSettings, syncHistoryItem, t, updateDownload])
const handlePasteUrl = useCallback(async () => {
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
setUrl(text.trim())
inputRef.current?.focus()
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [t])
const handleFetchVideo = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
await fetchVideoInfo(url.trim())
}, [url, fetchVideoInfo, t])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleFetchVideo()
}
},
[handleFetchVideo]
)
const handleOneClickDownload = useCallback(async () => {
if (!url.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
// Create initial download item with placeholder info
const trimmedUrl = url.trim()
const downloadItem = {
id,
url: trimmedUrl,
title: t('download.fetchingVideoInfo'),
type: settings.oneClickDownloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: Date.now()
}
const options = {
url: trimmedUrl,
type: settings.oneClickDownloadType,
format:
settings.oneClickDownloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
}
addDownload(downloadItem)
try {
// Start download immediately
await ipcServices.download.startDownload(id, options)
// Fetch video info in parallel to update the download item
try {
const videoInfo = await ipcServices.download.getVideoInfo(url.trim())
// Update the download item in the renderer state
updateDownload({
id,
changes: {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
// Extract additional metadata if available
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the download info in the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: videoInfo.title,
thumbnail: videoInfo.thumbnail,
duration: videoInfo.duration,
description: videoInfo.description,
channel: videoInfo.extractor_key,
uploader: videoInfo.extractor_key,
createdAt: Date.now(),
startedAt: Date.now()
})
// Show a subtle notification that video info was updated
toast.success(t('download.videoInfoUpdated'))
} catch (infoError) {
console.warn('Failed to fetch video info for one-click download:', infoError)
// Keep the placeholder title if video info fetch fails
// Update the title to indicate info fetch failed
updateDownload({
id,
changes: {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
}
})
// Also update the main process queue
await ipcServices.download.updateDownloadInfo(id, {
title: t('download.infoUnavailable'),
createdAt: Date.now(),
startedAt: Date.now()
})
}
toast.success(t('download.oneClickDownloadStarted'))
setUrl('') // Clear the URL after starting download
} catch (error) {
console.error('Failed to start one-click download:', error)
toast.error(t('notifications.downloadFailed'))
}
}, [url, settings, addDownload, updateDownload, t])
// Playlist handlers
const handlePastePlaylistUrl = useCallback(async () => {
if (playlistBusy) return
try {
const text = await navigator.clipboard.readText()
if (!text.trim()) {
toast.error(t('errors.clipboardEmpty'))
return
}
const trimmed = text.trim()
setPlaylistUrl(trimmed)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
} catch (error) {
console.error('Failed to paste URL:', error)
toast.error(t('errors.pasteFromClipboard'))
}
}, [playlistBusy, t])
const handleClearPlaylistPreview = useCallback(() => {
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}, [])
const handlePreviewPlaylist = useCallback(async () => {
if (!playlistUrl.trim()) {
toast.error(t('errors.emptyUrl'))
return
}
setPlaylistPreviewError(null)
setPlaylistPreviewLoading(true)
try {
const trimmedUrl = playlistUrl.trim()
const info = await ipcServices.download.getPlaylistInfo(trimmedUrl)
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
toast.success(t('playlist.foundVideos', { count: info.entryCount }))
} catch (error) {
console.error('Failed to fetch playlist info:', error)
const message =
error instanceof Error && error.message ? error.message : t('playlist.previewFailed')
setPlaylistPreviewError(message)
setPlaylistInfo(null)
toast.error(t('playlist.previewFailed'))
} finally {
setPlaylistPreviewLoading(false)
}
}, [playlistUrl, t])
const handleDownloadPlaylist = useCallback(async () => {
const trimmedUrl = playlistUrl.trim()
if (!trimmedUrl) {
toast.error(t('errors.emptyUrl'))
return
}
if (!playlistInfo) {
toast.error(t('playlist.previewRequired'))
return
}
setPlaylistPreviewError(null)
setPlaylistDownloadLoading(true)
try {
const info = playlistInfo
setPlaylistInfo(info)
if (info.entryCount === 0) {
toast.error(t('playlist.noEntries'))
return
}
const range = computePlaylistRange(info)
const previewEnd = range.end ?? info.entryCount
if (previewEnd < range.start || previewEnd === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
const format =
downloadType === 'video'
? buildVideoFormatPreference(settings)
: buildAudioFormatPreference(settings)
const result = await ipcServices.download.startPlaylistDownload({
url: trimmedUrl,
type: downloadType,
format,
startIndex: range.start,
endIndex: range.end
})
if (result.totalCount === 0) {
toast.error(t('playlist.noEntriesInRange'))
return
}
const baseCreatedAt = Date.now()
result.entries.forEach((entry, index) => {
const downloadItem = {
id: entry.downloadId,
url: entry.url,
title: entry.title || t('download.fetchingVideoInfo'),
type: downloadType,
status: 'pending' as const,
progress: { percent: 0 },
createdAt: baseCreatedAt + index,
playlistId: result.groupId,
playlistTitle: result.playlistTitle,
playlistIndex: entry.index,
playlistSize: result.totalCount
}
addDownload(downloadItem)
})
toast.success(t('playlist.downloadStarted', { count: result.totalCount }))
} catch (error) {
console.error('Failed to start playlist download:', error)
toast.error(t('playlist.downloadFailed'))
} finally {
setPlaylistDownloadLoading(false)
}
}, [playlistUrl, playlistInfo, computePlaylistRange, downloadType, settings, addDownload, t])
// Auto-focus input on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
return ( return (
<div <div className="w-full h-full flex flex-col min-h-0">
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full" <div
style={{ maxWidth: '100%' }} className="container mx-auto max-w-7xl p-6 w-full h-full flex flex-col min-h-0"
> style={{ maxWidth: '100%' }}
<Card> >
<Tabs {/* Unified Download History */}
defaultValue="single" <UnifiedDownloadHistory
value={activeTab} onOpenSupportedSites={onOpenSupportedSites}
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')} onOpenSettings={onOpenSettings}
className="w-full gap-0"
>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle>
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
</CardTitle>
<CardDescription>
{activeTab === 'single' ? (
<div className="flex flex-wrap items-center gap-2">
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
<Button
type="button"
variant="link"
className="p-0 h-auto"
onClick={() => onOpenSupportedSites?.()}
>
{t('sites.viewAll')}
</Button>
</div>
) : (
t('playlist.playlistUrlDescription')
)}
</CardDescription>
</div>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="single" className="flex items-center gap-2">
{t('download.singleVideo')}
</TabsTrigger>
<TabsTrigger value="playlist" className="flex items-center gap-2">
{t('playlist.title')}
</TabsTrigger>
</TabsList>
</div>
</CardHeader>
<CardContent>
{/* Single Video Download Tab */}
<TabsContent value="single" className="space-y-6 mt-0">
{/* URL Input Card */}
{!videoInfo && (
<div className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Input
ref={inputRef}
placeholder={t('download.urlPlaceholder')}
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-10"
disabled={loading}
/>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
{t('download.paste')}
</Button>
{settings.oneClickDownload ? (
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
{t('download.oneClickDownloadNow')}
</>
)}
</Button>
) : (
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
{t('download.fetch')}
</>
)}
</Button>
)}
</div>
{/* One-Click Download Info */}
{settings.oneClickDownload && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t('download.oneClickDownloadEnabled')}</span>
</div>
{onOpenSettings && (
<Button
type="button"
variant="link"
className="h-auto px-2 py-0 text-xs"
onClick={onOpenSettings}
>
{t('download.goToSettings')}
</Button>
)}
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-destructive">
{t('errors.fetchInfoFailed')}
</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
</div>
)}
</div>
)}
{/* Video Info and Download Options */}
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
</TabsContent>
{/* Playlist Download Tab */}
<TabsContent value="playlist" className="space-y-6 mt-0">
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
<div className="flex gap-2">
<Input
id={playlistUrlId}
placeholder="https://www.youtube.com/playlist?list=..."
value={playlistUrl}
onChange={(e) => {
setPlaylistUrl(e.target.value)
setPlaylistInfo(null)
setPlaylistPreviewError(null)
}}
className="flex-1"
disabled={playlistBusy}
/>
<Button
onClick={handlePastePlaylistUrl}
variant="outline"
disabled={playlistBusy}
>
{t('download.paste')}
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
placeholder={t('playlist.startIndex')}
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
<Input
type="number"
placeholder={t('playlist.endIndex')}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
min="1"
disabled={playlistBusy}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Button
onClick={handlePreviewPlaylist}
variant="outline"
className="w-full sm:w-auto"
disabled={playlistBusy || !playlistUrl.trim()}
>
{playlistPreviewLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
<>
<Search className="mr-2 h-5 w-5" />
{t('playlist.previewButton')}
</>
)}
</Button>
{playlistInfo && (
<Button
onClick={handleDownloadPlaylist}
className="w-full sm:flex-1"
size="lg"
disabled={playlistDownloadLoading || !playlistUrl.trim()}
>
{playlistDownloadLoading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t('download.loading')}
</>
) : (
t('playlist.downloadPlaylist')
)}
</Button>
)}
</div>
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
)}
{playlistPreviewError && (
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
{playlistPreviewError}
</div>
)}
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
{/* Playlist Preview Card (outside main card) */}
{playlistInfo && (
<PlaylistPreviewCard
playlist={playlistInfo}
entries={selectedPlaylistEntries}
onClear={handleClearPlaylistPreview}
/> />
)} </div>
{/* Unified Download History */}
<UnifiedDownloadHistory />
</div> </div>
) )
} }

View File

@@ -18,42 +18,50 @@ import {
} from '@renderer/components/ui/select' } from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch' import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import type { OneClickQualityPreset } from '@shared/types' import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ipcServices } from '../lib/ipc'
import { logger } from '../lib/logger'
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings' import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
const clampSubscriptionInterval = (value: string) => {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed)) {
return 3
}
return Math.min(24, Math.max(1, parsed))
}
export function Settings() { export function Settings() {
const { t } = useTranslation() const { t, i18n: i18nInstance } = useTranslation()
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom) const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom) const loadSettings = useSetAtom(loadSettingsAtom)
const saveSetting = useSetAtom(saveSettingAtom) const saveSetting = useSetAtom(saveSettingAtom)
const [platform, setPlatform] = useState<string>('') const [platform, setPlatform] = useState<string>('')
const [activeTab, setActiveTab] = useState<string>('general')
useEffect(() => { useEffect(() => {
loadSettings() logger.info('[Settings] Component mounted, loading settings...')
try {
loadSettings()
// Note: settings will be logged in the next useEffect after it's loaded
} catch (error) {
logger.error('[Settings] Failed to load settings:', error)
}
}, [loadSettings]) }, [loadSettings])
useEffect(() => {
logger.info('[Settings] Settings state updated', {
settingsKeys: Object.keys(settings),
settingsValues: settings
})
}, [settings])
useEffect(() => { useEffect(() => {
const fetchPlatform = async () => { const fetchPlatform = async () => {
try { try {
const { ipcServices } = await import('../lib/ipc')
const platformInfo = await ipcServices.app.getPlatform() const platformInfo = await ipcServices.app.getPlatform()
setPlatform(platformInfo) setPlatform(platformInfo)
} catch (error) { } catch (error) {
console.error('Failed to get platform info:', error) logger.error('Failed to get platform info:', error)
} }
} }
@@ -66,57 +74,60 @@ export function Settings() {
key: keyof typeof settings, key: keyof typeof settings,
value: (typeof settings)[keyof typeof settings] value: (typeof settings)[keyof typeof settings]
) => { ) => {
await saveSetting({ key, value }) try {
toast.success(t('notifications.settingsSaved')) logger.info('[Settings] Changing setting', { key, value, currentValue: settings[key] })
await saveSetting({ key, value })
toast.success(t('notifications.settingsSaved'))
logger.info('[Settings] Setting changed successfully', { key, value })
} catch (error) {
logger.error('[Settings] Failed to change setting', { key, value, error })
toast.error(t('settings.saveError') || 'Failed to save setting')
}
} }
const handleSelectPath = async () => { const handleSelectPath = async () => {
try { try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectDirectory() const path = await ipcServices.fs.selectDirectory()
if (path) { if (path) {
await handleSettingChange('downloadPath', path) await handleSettingChange('downloadPath', path)
} }
} catch (error) { } catch (error) {
console.error('Failed to select directory:', error) logger.error('Failed to select directory:', error)
toast.error(t('settings.directorySelectError')) toast.error(t('settings.directorySelectError'))
} }
} }
const handleSelectConfigFile = async () => { const handleSelectConfigFile = async () => {
try { try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile() const path = await ipcServices.fs.selectFile()
if (path) { if (path) {
await handleSettingChange('configPath', path) await handleSettingChange('configPath', path)
} }
} catch (error) { } catch (error) {
console.error('Failed to select file:', error) logger.error('Failed to select file:', error)
toast.error(t('settings.fileSelectError')) toast.error(t('settings.fileSelectError'))
} }
} }
const handleSelectCookiesFile = async () => { const handleSelectCookiesFile = async () => {
try { try {
const { ipcServices } = await import('../lib/ipc')
const path = await ipcServices.fs.selectFile() const path = await ipcServices.fs.selectFile()
if (path) { if (path) {
await handleSettingChange('cookiesPath', path) await handleSettingChange('cookiesPath', path)
} }
} catch (error) { } catch (error) {
console.error('Failed to select cookies file:', error) logger.error('Failed to select cookies file:', error)
toast.error(t('settings.fileSelectError')) toast.error(t('settings.fileSelectError'))
} }
} }
const handleOpenCookiesFaq = async () => { const handleOpenCookiesFaq = async () => {
try { try {
const { ipcServices } = await import('../lib/ipc')
await ipcServices.fs.openExternal( await ipcServices.fs.openExternal(
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp' 'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
) )
} catch (error) { } catch (error) {
console.error('Failed to open cookies FAQ:', error) logger.error('Failed to open cookies FAQ:', error)
toast.error(t('settings.openLinkError')) toast.error(t('settings.openLinkError'))
} }
} }
@@ -131,6 +142,21 @@ export function Settings() {
await handleSettingChange('theme', value) await handleSettingChange('theme', value)
} }
const languageOptions = languageList
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18nInstance.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
return ( return (
<div className="h-full bg-background"> <div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6"> <div className="container mx-auto max-w-4xl p-6 space-y-6">
@@ -139,7 +165,29 @@ export function Settings() {
<p className="text-muted-foreground">{t('settings.description')}</p> <p className="text-muted-foreground">{t('settings.description')}</p>
</div> </div>
<Tabs defaultValue="general"> <Tabs
value={activeTab}
onValueChange={(value) => {
logger.info('[Settings] Tab changed', { from: activeTab, to: value })
setActiveTab(value)
try {
if (value === 'advanced') {
logger.info('[Settings] Entering advanced tab', {
settings: settings,
settingsKeys: Object.keys(settings),
maxConcurrentDownloads: settings.maxConcurrentDownloads,
browserForCookies: settings.browserForCookies,
cookiesPath: settings.cookiesPath,
proxy: settings.proxy,
configPath: settings.configPath,
enableAnalytics: settings.enableAnalytics
})
}
} catch (error) {
logger.error('[Settings] Error when entering advanced tab:', error)
}
}}
>
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger> <TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger> <TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
@@ -185,6 +233,53 @@ export function Settings() {
</Select> </Select>
</ItemActions> </ItemActions>
</Item> </Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.language')}</ItemTitle>
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={currentLanguage.value}
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder={currentLanguage.name}>
<div className="flex items-center gap-2">
<span
className={`${currentLanguage.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<SelectItem
key={option.value}
value={option.value}
className={isActive ? 'font-semibold bg-muted' : undefined}
>
<div className="flex items-center gap-2">
<span
className={`${option.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={option.hreflang}>{option.name}</span>
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</ItemActions>
</Item>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -316,41 +411,21 @@ export function Settings() {
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Switch <Switch
checked={settings.showMoreFormats} checked={settings.showMoreFormats ?? false}
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)} onCheckedChange={(value) => {
try {
logger.info('[Settings] Toggling showMoreFormats', { value })
handleSettingChange('showMoreFormats', value)
} catch (error) {
logger.error('[Settings] Error toggling showMoreFormats:', error)
}
}}
/> />
</ItemActions> </ItemActions>
</Item> </Item>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
<ItemDescription>
{t('settings.subscriptionDefaults.intervalDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Input
type="number"
min={1}
max={24}
defaultValue={settings.subscriptionCheckIntervalHours}
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
onBlur={(event) =>
void handleSettingChange(
'subscriptionCheckIntervalHours',
clampSubscriptionInterval(event.target.value)
)
}
className="w-24"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted"> <Item variant="muted">
<ItemContent> <ItemContent>
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle> <ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
@@ -359,23 +434,55 @@ export function Settings() {
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Select {(() => {
value={settings.maxConcurrentDownloads.toString()} try {
onValueChange={(value) => const maxConcurrent = settings.maxConcurrentDownloads ?? 5
handleSettingChange('maxConcurrentDownloads', Number(value)) const maxConcurrentStr = maxConcurrent.toString()
logger.info('[Settings] Rendering max concurrent downloads select', {
maxConcurrent,
maxConcurrentStr,
type: typeof maxConcurrent
})
return (
<Select
value={maxConcurrentStr}
onValueChange={(value) => {
try {
const numValue = Number(value)
logger.info('[Settings] Max concurrent downloads changed', {
oldValue: maxConcurrent,
newValue: numValue,
stringValue: value
})
handleSettingChange('maxConcurrentDownloads', numValue)
} catch (error) {
logger.error(
'[Settings] Error changing max concurrent downloads:',
error
)
}
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
)
} catch (error) {
logger.error(
'[Settings] Error rendering max concurrent downloads select:',
error
)
return <div>Error loading max concurrent downloads setting</div>
} }
> })()}
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</ItemActions> </ItemActions>
</Item> </Item>
@@ -387,12 +494,33 @@ export function Settings() {
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription> <ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Input {(() => {
placeholder={t('settings.proxyPlaceholder')} try {
value={settings.proxy} const proxyValue = settings.proxy ?? ''
onChange={(e) => handleSettingChange('proxy', e.target.value)} logger.info('[Settings] Rendering proxy input', { proxyValue })
className="w-64" return (
/> <Input
placeholder={t('settings.proxyPlaceholder')}
value={proxyValue}
onChange={(e) => {
try {
logger.info('[Settings] Proxy value changed', {
oldValue: proxyValue,
newValue: e.target.value
})
handleSettingChange('proxy', e.target.value)
} catch (error) {
logger.error('[Settings] Error changing proxy:', error)
}
}}
className="w-64"
/>
)
} catch (error) {
logger.error('[Settings] Error rendering proxy input:', error)
return <div>Error loading proxy setting</div>
}
})()}
</ItemActions> </ItemActions>
</Item> </Item>
@@ -404,17 +532,37 @@ export function Settings() {
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription> <ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<div className="flex gap-2 w-full max-w-md"> {(() => {
<Input value={settings.configPath} readOnly className="flex-1" /> try {
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button> const configPathValue = settings.configPath ?? ''
<Button logger.info('[Settings] Rendering config file input', { configPathValue })
variant="secondary" return (
onClick={() => void handleSettingChange('configPath', '')} <div className="flex gap-2 w-full max-w-md">
disabled={!settings.configPath} <Input value={configPathValue} readOnly className="flex-1" />
> <Button onClick={handleSelectConfigFile}>
{t('settings.clearConfigFile')} {t('settings.selectPath')}
</Button> </Button>
</div> <Button
variant="secondary"
onClick={() => {
try {
logger.info('[Settings] Clearing config path')
void handleSettingChange('configPath', '')
} catch (error) {
logger.error('[Settings] Error clearing config path:', error)
}
}}
disabled={!configPathValue}
>
{t('settings.clearConfigFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering config file input:', error)
return <div>Error loading config file setting</div>
}
})()}
</ItemActions> </ItemActions>
</Item> </Item>
</ItemGroup> </ItemGroup>
@@ -426,24 +574,58 @@ export function Settings() {
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription> <ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Select {(() => {
value={settings.browserForCookies} try {
onValueChange={(value) => handleSettingChange('browserForCookies', value)} const browserValue = settings.browserForCookies ?? 'none'
> logger.info('[Settings] Rendering browser for cookies select', {
<SelectTrigger className="w-32"> browserValue
<SelectValue /> })
</SelectTrigger> return (
<SelectContent> <Select
<SelectItem value="none">{t('settings.none')}</SelectItem> value={browserValue}
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem> onValueChange={(value) => {
<SelectItem value="firefox"> try {
{t('settings.browserOptions.firefox')} logger.info('[Settings] Browser for cookies changed', {
</SelectItem> oldValue: browserValue,
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem> newValue: value
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem> })
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem> handleSettingChange('browserForCookies', value)
</SelectContent> } catch (error) {
</Select> logger.error('[Settings] Error changing browser for cookies:', error)
}
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('settings.none')}</SelectItem>
<SelectItem value="chrome">
{t('settings.browserOptions.chrome')}
</SelectItem>
<SelectItem value="chromium">
{t('settings.browserOptions.chromium')}
</SelectItem>
<SelectItem value="firefox">
{t('settings.browserOptions.firefox')}
</SelectItem>
<SelectItem value="edge">
{t('settings.browserOptions.edge')}
</SelectItem>
<SelectItem value="safari">
{t('settings.browserOptions.safari')}
</SelectItem>
<SelectItem value="brave">
{t('settings.browserOptions.brave')}
</SelectItem>
</SelectContent>
</Select>
)
} catch (error) {
logger.error('[Settings] Error rendering browser for cookies select:', error)
return <div>Error loading browser for cookies setting</div>
}
})()}
</ItemActions> </ItemActions>
</Item> </Item>
@@ -455,17 +637,37 @@ export function Settings() {
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription> <ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<div className="flex gap-2 w-full max-w-md"> {(() => {
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" /> try {
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button> const cookiesPathValue = settings.cookiesPath ?? ''
<Button logger.info('[Settings] Rendering cookies file input', { cookiesPathValue })
variant="secondary" return (
onClick={() => void handleSettingChange('cookiesPath', '')} <div className="flex gap-2 w-full max-w-md">
disabled={!settings.cookiesPath} <Input value={cookiesPathValue} readOnly className="flex-1" />
> <Button onClick={handleSelectCookiesFile}>
{t('settings.clearCookiesFile')} {t('settings.selectPath')}
</Button> </Button>
</div> <Button
variant="secondary"
onClick={() => {
try {
logger.info('[Settings] Clearing cookies path')
void handleSettingChange('cookiesPath', '')
} catch (error) {
logger.error('[Settings] Error clearing cookies path:', error)
}
}}
disabled={!cookiesPathValue}
>
{t('settings.clearCookiesFile')}
</Button>
</div>
)
} catch (error) {
logger.error('[Settings] Error rendering cookies file input:', error)
return <div>Error loading cookies file setting</div>
}
})()}
</ItemActions> </ItemActions>
</Item> </Item>
@@ -474,12 +676,10 @@ export function Settings() {
<Item variant="muted"> <Item variant="muted">
<ItemContent> <ItemContent>
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle> <ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
<ItemDescription> <ul className="list-disc list-inside space-y-1 text-muted-foreground text-sm leading-normal">
<ul className="list-disc list-inside space-y-1"> <li>{t('settings.cookiesHelpBrowser')}</li>
<li>{t('settings.cookiesHelpBrowser')}</li> <li>{t('settings.cookiesHelpFile')}</li>
<li>{t('settings.cookiesHelpFile')}</li> </ul>
</ul>
</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}> <Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
@@ -496,10 +696,33 @@ export function Settings() {
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription> <ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<Switch {(() => {
checked={settings.enableAnalytics} try {
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)} const analyticsValue = settings.enableAnalytics ?? true
/> logger.info('[Settings] Rendering enable analytics switch', {
analyticsValue
})
return (
<Switch
checked={analyticsValue}
onCheckedChange={(value) => {
try {
logger.info('[Settings] Enable analytics changed', {
oldValue: analyticsValue,
newValue: value
})
handleSettingChange('enableAnalytics', value)
} catch (error) {
logger.error('[Settings] Error changing enable analytics:', error)
}
}}
/>
)
} catch (error) {
logger.error('[Settings] Error rendering enable analytics switch:', error)
return <div>Error loading enable analytics setting</div>
}
})()}
</ItemActions> </ItemActions>
</Item> </Item>
</ItemGroup> </ItemGroup>

View File

@@ -134,7 +134,7 @@ function SubscriptionTab({
<TabsTrigger <TabsTrigger
value={subscription.id} value={subscription.id}
className={cn( className={cn(
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0', 'flex h-auto w-20 flex-col rounded-2xl items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
isActive && 'bg-muted/45' isActive && 'bg-muted/45'
)} )}
> >
@@ -338,7 +338,7 @@ export function Subscriptions() {
))} ))}
{/* Add RSS Button */} {/* Add RSS Button */}
<Button <Button
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0" className="flex h-auto w-20 flex-col items-center gap-1 rounded-2xl px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
variant="ghost" variant="ghost"
onClick={() => setAddDialogOpen(true)} onClick={() => setAddDialogOpen(true)}
> >

View File

@@ -1,117 +0,0 @@
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { popularSites } from '@renderer/data/popularSites'
import { ipcServices } from '@renderer/lib/ipc'
import { ExternalLink } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const referenceUrl = 'https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md'
function SiteIcon({ domain, alt }: { domain: string; alt: string }) {
const [iconUrl, setIconUrl] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const loadIcon = async () => {
try {
const dataUrl = await ipcServices.app.getSiteIcon(domain)
if (!cancelled) {
setIconUrl(dataUrl)
}
} catch (error) {
console.error('Failed to load site icon:', error)
if (!cancelled) {
setIconUrl(null)
}
}
}
void loadIcon()
return () => {
cancelled = true
}
}, [domain])
return <RemoteImage src={iconUrl} alt={alt} className="w-full h-full" />
}
export function SupportedSites() {
const { t } = useTranslation()
return (
<div className="container mx-auto max-w-5xl p-6 space-y-6" style={{ maxWidth: '100%' }}>
<Card>
<CardHeader>
<CardTitle>{t('sites.pageTitle')}</CardTitle>
<CardDescription>{t('sites.pageDescription')}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t('sites.pageIntro')}</p>
</CardContent>
</Card>
<section className="space-y-3">
<h2 className="text-lg font-semibold px-6">{t('sites.popularSection')}</h2>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{popularSites.map((site) => {
const labelKey = `sites.popular.${site.id}.label`
const descriptionKey = `sites.popular.${site.id}.description`
const label = t(labelKey)
const description = t(descriptionKey)
const hasDescription = description !== descriptionKey
return (
<li key={site.id}>
<a
href={site.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 rounded-md border border-border px-4 py-3 transition-colors hover:bg-muted/50 hover:border-primary/50 group"
>
<div className="shrink-0 w-10 h-10 rounded-xs overflow-hidden">
<SiteIcon domain={site.domain} alt={label} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{label}</p>
<ExternalLink className="w-3 h-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
</div>
{hasDescription ? (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">
{description}
</p>
) : null}
</div>
</a>
</li>
)
})}
</ul>
</section>
<Card>
<CardHeader>
<CardTitle>{t('sites.moreTitle')}</CardTitle>
<CardDescription>{t('sites.moreDescription')}</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<a href={referenceUrl} target="_blank" rel="noreferrer">
{t('sites.openFullList')}
</a>
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -96,6 +96,31 @@ export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
set(downloadRecordsAtom, downloads) set(downloadRecordsAtom, downloads)
}) })
export const removeHistoryRecordsAtom = atom(null, (get, set, ids: string[]) => {
if (!ids || ids.length === 0) {
return
}
const downloads = new Map(get(downloadRecordsAtom))
const uniqueIds = Array.from(new Set(ids))
uniqueIds.forEach((id) => {
downloads.delete(recordKey('history', id))
})
set(downloadRecordsAtom, downloads)
})
export const removeHistoryRecordsByPlaylistAtom = atom(null, (get, set, playlistId: string) => {
if (!playlistId) {
return
}
const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) {
if (item.entryType === 'history' && item.playlistId === playlistId) {
downloads.delete(key)
}
}
set(downloadRecordsAtom, downloads)
})
export const clearHistoryRecordsAtom = atom(null, (get, set) => { export const clearHistoryRecordsAtom = atom(null, (get, set) => {
const downloads = new Map(get(downloadRecordsAtom)) const downloads = new Map(get(downloadRecordsAtom))
for (const [key, item] of downloads.entries()) { for (const [key, item] of downloads.entries()) {

View File

@@ -0,0 +1,21 @@
import { atom } from 'jotai'
type UpdateReadyState = {
ready: boolean
version?: string
}
type UpdateAvailableState = {
available: boolean
version?: string
}
export const updateReadyAtom = atom<UpdateReadyState>({
ready: false,
version: undefined
})
export const updateAvailableAtom = atom<UpdateAvailableState>({
available: false,
version: undefined
})

2
src/shared/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const APP_PROTOCOL = 'vidbee'
export const APP_PROTOCOL_SCHEME = `${APP_PROTOCOL}://`

View File

@@ -147,6 +147,7 @@ export interface PlaylistEntry {
title: string title: string
url: string url: string
index: number index: number
thumbnail?: string
} }
export interface PlaylistInfo { export interface PlaylistInfo {
@@ -164,6 +165,7 @@ export interface PlaylistDownloadOptions {
endIndex?: number endIndex?: number
filenameFormat?: string filenameFormat?: string
folderFormat?: string folderFormat?: string
customDownloadPath?: string
} }
export interface PlaylistDownloadEntry { export interface PlaylistDownloadEntry {
@@ -267,12 +269,12 @@ export interface AppSettings {
hideDockIcon: boolean hideDockIcon: boolean
launchAtLogin: boolean launchAtLogin: boolean
autoUpdate: boolean autoUpdate: boolean
subscriptionFilenameTemplate: string
subscriptionOnlyLatestDefault: boolean subscriptionOnlyLatestDefault: boolean
subscriptionCheckIntervalHours: number
enableAnalytics: boolean enableAnalytics: boolean
} }
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
export const defaultSettings: AppSettings = { export const defaultSettings: AppSettings = {
downloadPath: '', downloadPath: '',
showMoreFormats: false, showMoreFormats: false,
@@ -291,8 +293,6 @@ export const defaultSettings: AppSettings = {
hideDockIcon: false, hideDockIcon: false,
launchAtLogin: false, launchAtLogin: false,
autoUpdate: true, autoUpdate: true,
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
subscriptionOnlyLatestDefault: true, subscriptionOnlyLatestDefault: true,
subscriptionCheckIntervalHours: 3,
enableAnalytics: true enableAnalytics: true
} }