Compare commits
10 Commits
nexmoe/aut
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7627e1132 | ||
|
|
d7b131d795 | ||
|
|
74dbb223ce | ||
|
|
11a931f865 | ||
|
|
79a78d4322 | ||
|
|
a1c44baf65 | ||
|
|
86e6b93476 | ||
|
|
dbcd77963f | ||
|
|
58a4d2da36 | ||
|
|
db16d5d051 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
/dist
|
||||
out
|
||||
.conductor/
|
||||
.DS_Store
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -25,5 +25,8 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/locales"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
35
README.md
35
README.md
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
</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
|
||||
|
||||
@@ -43,6 +43,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.
|
||||
|
||||
### 🌐 Browser Script (Quick Download)
|
||||
|
||||
For a more convenient downloading experience, you can install the VidBee browser script to add a quick download button directly on supported video websites.
|
||||
|
||||

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

|
||||
|
||||
### 📡 RSS Auto Download
|
||||
|
||||
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
|
||||
|
||||
## 🌐 Supported Sites
|
||||
|
||||
VidBee supports hundreds of video and audio platforms through yt-dlp. Here are the most popular platforms:
|
||||
|
||||
13
biome.json
13
biome.json
@@ -39,7 +39,18 @@
|
||||
},
|
||||
"files": {
|
||||
"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": {
|
||||
"enabled": true,
|
||||
|
||||
23
monkey/.gitignore
vendored
Normal file
23
monkey/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
156
monkey/dist/vidbee-quick-download.user.js
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
// ==UserScript==
|
||||
// @name vidbee-quick-download
|
||||
// @namespace vidbee
|
||||
// @version 0.0.1
|
||||
// @icon https://vidbee.org/favicon.svg
|
||||
// @match https://www.youtube.com/*
|
||||
// @match https://youtube.com/*
|
||||
// @match https://music.youtube.com/*
|
||||
// @match https://www.bilibili.com/*
|
||||
// @match https://bilibili.com/*
|
||||
// @match https://www.tiktok.com/*
|
||||
// @match https://tiktok.com/*
|
||||
// @match https://vimeo.com/*
|
||||
// @match https://www.vimeo.com/*
|
||||
// @match https://www.dailymotion.com/*
|
||||
// @match https://dailymotion.com/*
|
||||
// @match https://www.twitch.tv/*
|
||||
// @match https://twitch.tv/*
|
||||
// @match https://twitter.com/*
|
||||
// @match https://www.twitter.com/*
|
||||
// @match https://x.com/*
|
||||
// @match https://www.x.com/*
|
||||
// @match https://www.instagram.com/*
|
||||
// @match https://instagram.com/*
|
||||
// @match https://www.facebook.com/*
|
||||
// @match https://facebook.com/*
|
||||
// @match https://fb.com/*
|
||||
// @match https://www.fb.com/*
|
||||
// @match https://www.reddit.com/*
|
||||
// @match https://reddit.com/*
|
||||
// @match https://soundcloud.com/*
|
||||
// @match https://www.soundcloud.com/*
|
||||
// @match https://www.nicovideo.jp/*
|
||||
// @match https://nicovideo.jp/*
|
||||
// @match https://kick.com/*
|
||||
// @match https://www.kick.com/*
|
||||
// @match https://bandcamp.com/*
|
||||
// @match https://*.bandcamp.com/*
|
||||
// @match https://www.mixcloud.com/*
|
||||
// @match https://mixcloud.com/*
|
||||
// @grant GM_addStyle
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const d=new Set;const importCSS = async e=>{d.has(e)||(d.add(e),(t=>{typeof GM_addStyle=="function"?GM_addStyle(t):(document.head||document.documentElement).appendChild(document.createElement("style")).append(t);})(e));};
|
||||
|
||||
const styleCss = '.vidbee-download-container{position:fixed;bottom:16px;right:16px;z-index:9999;transition:all .2s ease;overflow:visible}.vidbee-download-container.vidbee-hidden{opacity:0;pointer-events:none;transform:scale(0)}.vidbee-download-button{position:relative;display:flex;align-items:center;justify-content:center;width:36px;height:36px;padding:0;background:#00000080;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fffc;border:1px solid rgba(255,255,255,.1);border-radius:50%;box-shadow:0 2px 8px #0003;cursor:pointer;font-size:0;transition:all .2s ease;opacity:.6;overflow:visible}.vidbee-download-button:hover{opacity:1;background:#000000b3;border-color:#fff3;box-shadow:0 4px 12px #0000004d;transform:scale(1.1)}.vidbee-download-button:active{transform:scale(.95)}.vidbee-download-button svg{flex-shrink:0;stroke:currentColor;width:16px;height:16px}.vidbee-tooltip{position:absolute;right:calc(100% + 8px);top:50%;padding:6px 10px;background:#000000e6;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff;font-size:12px;font-weight:500;white-space:nowrap;border-radius:4px;box-shadow:0 2px 8px #0000004d;opacity:0;pointer-events:none;transition:opacity .2s ease,transform .2s ease;transform:translateY(-50%) translate(4px);z-index:10000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1}.vidbee-tooltip:after{content:"";position:absolute;left:100%;top:50%;transform:translateY(-50%);border:4px solid transparent;border-left-color:#000000e6}.vidbee-download-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}.vidbee-close-button{position:absolute;top:-6px;right:-6px;display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;background:#ff4d4de6;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);color:#fff;border:1px solid rgba(255,255,255,.2);border-radius:50%;box-shadow:0 2px 4px #0000004d;cursor:pointer;font-size:0;transition:all .2s ease;z-index:1;opacity:0;pointer-events:none;overflow:visible}.vidbee-download-container:hover .vidbee-close-button{opacity:1;pointer-events:auto}.vidbee-close-button:hover{background:#ff4d4d;transform:scale(1.15);box-shadow:0 3px 6px #0006}.vidbee-close-button:active{transform:scale(.9)}.vidbee-close-button svg{flex-shrink:0;stroke:currentColor;width:10px;height:10px}.vidbee-close-button .vidbee-tooltip{right:calc(100% + 6px);top:50%;left:auto;transform:translateY(-50%) translate(4px)}.vidbee-close-button .vidbee-tooltip:after{left:100%;top:50%;right:auto;transform:translateY(-50%);border-left-color:#000000e6;border-top-color:transparent}.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip{opacity:1;transform:translateY(-50%) translate(0)}';
|
||||
importCSS(styleCss);
|
||||
function getVideoUrl() {
|
||||
return window.location.href;
|
||||
}
|
||||
function hideButtonTemporarily() {
|
||||
const container = document.getElementById("vidbee-download-btn");
|
||||
if (container) {
|
||||
container.classList.add("vidbee-hidden");
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove("vidbee-hidden");
|
||||
}
|
||||
}, 5e3);
|
||||
}
|
||||
}
|
||||
function createVidBeeButton() {
|
||||
if (document.getElementById("vidbee-download-btn")) {
|
||||
return;
|
||||
}
|
||||
const videoUrl = getVideoUrl();
|
||||
if (!videoUrl) {
|
||||
return;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
container.id = "vidbee-download-btn";
|
||||
container.className = "vidbee-download-container";
|
||||
const button = document.createElement("button");
|
||||
button.className = "vidbee-download-button";
|
||||
button.setAttribute("aria-label", "Download with VidBee");
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`;
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.className = "vidbee-close-button";
|
||||
closeButton.setAttribute("aria-label", "Hide button");
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`;
|
||||
closeButton.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
hideButtonTemporarily();
|
||||
});
|
||||
let clickTimer = null;
|
||||
let clickCount = 0;
|
||||
button.addEventListener("click", () => {
|
||||
clickCount++;
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`;
|
||||
window.location.href = vidbeeUrl;
|
||||
clickCount = 0;
|
||||
}, 300);
|
||||
} else if (clickCount === 2) {
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer);
|
||||
}
|
||||
clickCount = 0;
|
||||
hideButtonTemporarily();
|
||||
}
|
||||
});
|
||||
container.appendChild(button);
|
||||
container.appendChild(closeButton);
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
function init() {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", createVidBeeButton);
|
||||
} else {
|
||||
createVidBeeButton();
|
||||
}
|
||||
let lastUrl = location.href;
|
||||
let urlCheckTimer = null;
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href;
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl;
|
||||
const oldButton = document.getElementById("vidbee-download-btn");
|
||||
if (oldButton) {
|
||||
oldButton.remove();
|
||||
}
|
||||
const hostname = window.location.hostname;
|
||||
const delay = hostname.includes("bilibili.com") ? 800 : 500;
|
||||
setTimeout(createVidBeeButton, delay);
|
||||
}
|
||||
};
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer);
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100);
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener("popstate", () => {
|
||||
setTimeout(checkUrlChange, 300);
|
||||
});
|
||||
}
|
||||
init();
|
||||
|
||||
})();
|
||||
16
monkey/package.json
Normal file
16
monkey/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "vidbee-quick-download",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.3",
|
||||
"vite-plugin-monkey": "^7.1.1"
|
||||
}
|
||||
}
|
||||
943
monkey/pnpm-lock.yaml
generated
Normal file
943
monkey/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,943 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^7.1.3
|
||||
version: 7.3.0
|
||||
vite-plugin-monkey:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.8(postcss@8.5.6)(vite@7.3.0)
|
||||
|
||||
packages:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||
|
||||
bundle-name@4.1.0:
|
||||
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
cuint@0.2.2:
|
||||
resolution: {integrity: sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw==}
|
||||
|
||||
default-browser-id@5.0.1:
|
||||
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
default-browser@5.4.0:
|
||||
resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
define-lazy-prop@3.0.0:
|
||||
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@6.0.1:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
esbuild@0.27.2:
|
||||
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
peerDependencies:
|
||||
picomatch: ^3 || ^4
|
||||
peerDependenciesMeta:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||
|
||||
import-meta-resolve@4.2.0:
|
||||
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
hasBin: true
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||
engines: {node: '>=14.16'}
|
||||
hasBin: true
|
||||
|
||||
is-wsl@3.1.0:
|
||||
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@3.1.0:
|
||||
resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
mime@2.5.2:
|
||||
resolution: {integrity: sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimatch@3.0.8:
|
||||
resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
picomatch@4.0.3:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss-url@10.1.3:
|
||||
resolution: {integrity: sha512-FUzyxfI5l2tKmXdYc6VTu3TWZsInayEKPbiyW+P6vmmIrrb4I6CGX0BFoewgYHLK+oIL5FECEK02REYRpBvUCw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
rollup@4.54.0:
|
||||
resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
run-applescript@7.1.0:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
systemjs@6.15.1:
|
||||
resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
vite-plugin-monkey@7.1.8:
|
||||
resolution: {integrity: sha512-FGz1jlHcodt+L120UcHLiN0jXqQZdZHxLuHcGQg8Zjas6BulxLM1oiGUg3M95HbjYVFH6ygrOJoZ5YCYu1purw==}
|
||||
peerDependencies:
|
||||
vite: ^6.0.0 || ^7.0.0
|
||||
peerDependenciesMeta:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
resolution: {integrity: sha512-AkTuIuVTET12tpsVIQo+ZU6f/qDmKuRUcjaqR+OIvm+aCBsZ95i7UVY5WJ9TMsSaZ0DA2WxoZ4acu0sPH+OKAw==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@esbuild/aix-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.27.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.54.0':
|
||||
optional: true
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
acorn-walk@8.3.4:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.15.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
run-applescript: 7.1.0
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
cuint@0.2.2: {}
|
||||
|
||||
default-browser-id@5.0.1: {}
|
||||
|
||||
default-browser@5.4.0:
|
||||
dependencies:
|
||||
bundle-name: 4.1.0
|
||||
default-browser-id: 5.0.1
|
||||
|
||||
define-lazy-prop@3.0.0: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
esbuild@0.27.2:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.2
|
||||
'@esbuild/android-arm': 0.27.2
|
||||
'@esbuild/android-arm64': 0.27.2
|
||||
'@esbuild/android-x64': 0.27.2
|
||||
'@esbuild/darwin-arm64': 0.27.2
|
||||
'@esbuild/darwin-x64': 0.27.2
|
||||
'@esbuild/freebsd-arm64': 0.27.2
|
||||
'@esbuild/freebsd-x64': 0.27.2
|
||||
'@esbuild/linux-arm': 0.27.2
|
||||
'@esbuild/linux-arm64': 0.27.2
|
||||
'@esbuild/linux-ia32': 0.27.2
|
||||
'@esbuild/linux-loong64': 0.27.2
|
||||
'@esbuild/linux-mips64el': 0.27.2
|
||||
'@esbuild/linux-ppc64': 0.27.2
|
||||
'@esbuild/linux-riscv64': 0.27.2
|
||||
'@esbuild/linux-s390x': 0.27.2
|
||||
'@esbuild/linux-x64': 0.27.2
|
||||
'@esbuild/netbsd-arm64': 0.27.2
|
||||
'@esbuild/netbsd-x64': 0.27.2
|
||||
'@esbuild/openbsd-arm64': 0.27.2
|
||||
'@esbuild/openbsd-x64': 0.27.2
|
||||
'@esbuild/openharmony-arm64': 0.27.2
|
||||
'@esbuild/sunos-x64': 0.27.2
|
||||
'@esbuild/win32-arm64': 0.27.2
|
||||
'@esbuild/win32-ia32': 0.27.2
|
||||
'@esbuild/win32-x64': 0.27.2
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
htmlparser2@10.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 6.0.1
|
||||
|
||||
import-meta-resolve@4.2.0: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-inside-container@1.0.0:
|
||||
dependencies:
|
||||
is-docker: 3.0.0
|
||||
|
||||
is-wsl@3.1.0:
|
||||
dependencies:
|
||||
is-inside-container: 1.0.0
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@3.1.0:
|
||||
dependencies:
|
||||
semver: 6.3.1
|
||||
|
||||
mime@2.5.2: {}
|
||||
|
||||
minimatch@3.0.8:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.4.0
|
||||
define-lazy-prop: 3.0.0
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
postcss-url@10.1.3(postcss@8.5.6):
|
||||
dependencies:
|
||||
make-dir: 3.1.0
|
||||
mime: 2.5.2
|
||||
minimatch: 3.0.8
|
||||
postcss: 8.5.6
|
||||
xxhashjs: 0.2.2
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
rollup@4.54.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.54.0
|
||||
'@rollup/rollup-android-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-arm64': 4.54.0
|
||||
'@rollup/rollup-darwin-x64': 4.54.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.54.0
|
||||
'@rollup/rollup-freebsd-x64': 4.54.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.54.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.54.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.54.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.54.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.54.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.54.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
systemjs@6.15.1: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
vite-plugin-monkey@7.1.8(postcss@8.5.6)(vite@7.3.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.4
|
||||
cross-spawn: 7.0.6
|
||||
htmlparser2: 10.0.0
|
||||
import-meta-resolve: 4.2.0
|
||||
magic-string: 0.30.21
|
||||
mrmime: 2.0.1
|
||||
open: 10.2.0
|
||||
picocolors: 1.1.1
|
||||
postcss-url: 10.1.3(postcss@8.5.6)
|
||||
systemjs: 6.15.1
|
||||
optionalDependencies:
|
||||
vite: 7.3.0
|
||||
transitivePeerDependencies:
|
||||
- postcss
|
||||
|
||||
vite@7.3.0:
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.54.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
dependencies:
|
||||
is-wsl: 3.1.0
|
||||
|
||||
xxhashjs@0.2.2:
|
||||
dependencies:
|
||||
cuint: 0.2.2
|
||||
146
monkey/src/main.ts
Normal file
146
monkey/src/main.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import './style.css'
|
||||
|
||||
// Get current video URL
|
||||
// yt-dlp can handle all URLs directly, so we just return the current URL
|
||||
function getVideoUrl(): string | null {
|
||||
return window.location.href
|
||||
}
|
||||
|
||||
// Temporarily hide button
|
||||
function hideButtonTemporarily(): void {
|
||||
const container = document.getElementById('vidbee-download-btn')
|
||||
if (container) {
|
||||
container.classList.add('vidbee-hidden')
|
||||
// Auto restore after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (container) {
|
||||
container.classList.remove('vidbee-hidden')
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create VidBee download button
|
||||
function createVidBeeButton(): void {
|
||||
// Check if button already exists
|
||||
if (document.getElementById('vidbee-download-btn')) {
|
||||
return
|
||||
}
|
||||
|
||||
const videoUrl = getVideoUrl()
|
||||
if (!videoUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create button container
|
||||
const container = document.createElement('div')
|
||||
container.id = 'vidbee-download-btn'
|
||||
container.className = 'vidbee-download-container'
|
||||
|
||||
// Create main download button
|
||||
const button = document.createElement('button')
|
||||
button.className = 'vidbee-download-button'
|
||||
button.setAttribute('aria-label', 'Download with VidBee')
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Download with VidBee</span>
|
||||
`
|
||||
|
||||
// Create close button
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.className = 'vidbee-close-button'
|
||||
closeButton.setAttribute('aria-label', 'Hide button')
|
||||
closeButton.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
<span class="vidbee-tooltip">Hide</span>
|
||||
`
|
||||
|
||||
// Handle close button click - temporarily hide
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
hideButtonTemporarily()
|
||||
})
|
||||
|
||||
let clickTimer: number | null = null
|
||||
let clickCount = 0
|
||||
|
||||
// Handle main button click event - single click for download
|
||||
button.addEventListener('click', () => {
|
||||
clickCount++
|
||||
|
||||
if (clickCount === 1) {
|
||||
clickTimer = window.setTimeout(() => {
|
||||
// Single click - trigger download
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
clickCount = 0
|
||||
}, 300)
|
||||
} else if (clickCount === 2) {
|
||||
// Double click - temporarily hide
|
||||
if (clickTimer !== null) {
|
||||
clearTimeout(clickTimer)
|
||||
}
|
||||
clickCount = 0
|
||||
hideButtonTemporarily()
|
||||
}
|
||||
})
|
||||
|
||||
// Assemble container
|
||||
container.appendChild(button)
|
||||
container.appendChild(closeButton)
|
||||
|
||||
// Insert container directly to body (fixed position)
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
function init(): void {
|
||||
// Wait for page to fully load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', createVidBeeButton)
|
||||
} else {
|
||||
createVidBeeButton()
|
||||
}
|
||||
|
||||
// Handle SPA navigation (when navigating between videos on sites like YouTube, Bilibili, etc.)
|
||||
let lastUrl = location.href
|
||||
let urlCheckTimer: number | null = null
|
||||
|
||||
const checkUrlChange = () => {
|
||||
const currentUrl = location.href
|
||||
if (currentUrl !== lastUrl) {
|
||||
lastUrl = currentUrl
|
||||
// Remove old button and create new one
|
||||
const oldButton = document.getElementById('vidbee-download-btn')
|
||||
if (oldButton) {
|
||||
oldButton.remove()
|
||||
}
|
||||
// Wait a bit for the page to update (different sites have different update speeds)
|
||||
const hostname = window.location.hostname
|
||||
const delay = hostname.includes('bilibili.com') ? 800 : 500
|
||||
setTimeout(createVidBeeButton, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Use MutationObserver for DOM changes (works for most SPA sites)
|
||||
new MutationObserver(() => {
|
||||
if (urlCheckTimer !== null) {
|
||||
clearTimeout(urlCheckTimer)
|
||||
}
|
||||
urlCheckTimer = window.setTimeout(checkUrlChange, 100)
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
// Also listen to popstate for browser navigation
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(checkUrlChange, 300)
|
||||
})
|
||||
}
|
||||
|
||||
init()
|
||||
169
monkey/src/style.css
Normal file
169
monkey/src/style.css
Normal file
@@ -0,0 +1,169 @@
|
||||
/* VidBee Download Button Container */
|
||||
.vidbee-download-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
transition: all 0.2s ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hidden state */
|
||||
.vidbee-download-container.vidbee-hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Main Download Button */
|
||||
.vidbee-download-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0.6;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.vidbee-download-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vidbee-download-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.vidbee-tooltip {
|
||||
position: absolute;
|
||||
right: calc(100% + 8px);
|
||||
top: 50%;
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.vidbee-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 4px solid transparent;
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.vidbee-download-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
/* Close Button - Hidden by default */
|
||||
.vidbee-close-button {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
background: rgba(255, 77, 77, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Show close button on container hover */
|
||||
.vidbee-download-container:hover .vidbee-close-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.vidbee-close-button:hover {
|
||||
background: rgba(255, 77, 77, 1);
|
||||
transform: scale(1.15);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vidbee-close-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.vidbee-close-button svg {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* Close button tooltip positioning */
|
||||
.vidbee-close-button .vidbee-tooltip {
|
||||
right: calc(100% + 6px);
|
||||
top: 50%;
|
||||
left: auto;
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.vidbee-close-button .vidbee-tooltip::after {
|
||||
left: 100%;
|
||||
top: 50%;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border-left-color: rgba(0, 0, 0, 0.9);
|
||||
border-top-color: transparent;
|
||||
}
|
||||
|
||||
.vidbee-download-container:hover .vidbee-close-button:hover .vidbee-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
4
monkey/src/vite-env.d.ts
vendored
Normal file
4
monkey/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-monkey/client" />
|
||||
//// <reference types="vite-plugin-monkey/global" />
|
||||
/// <reference types="vite-plugin-monkey/style" />
|
||||
24
monkey/tsconfig.json
Normal file
24
monkey/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
monkey/tsconfig.node.json
Normal file
13
monkey/tsconfig.node.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
67
monkey/vite.config.ts
Normal file
67
monkey/vite.config.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import monkey from 'vite-plugin-monkey'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
monkey({
|
||||
entry: 'src/main.ts',
|
||||
userscript: {
|
||||
icon: 'https://vidbee.org/favicon.svg',
|
||||
namespace: 'vidbee',
|
||||
match: [
|
||||
// YouTube
|
||||
'https://www.youtube.com/*',
|
||||
'https://youtube.com/*',
|
||||
'https://music.youtube.com/*',
|
||||
// Bilibili (哔哩哔哩)
|
||||
'https://www.bilibili.com/*',
|
||||
'https://bilibili.com/*',
|
||||
// TikTok
|
||||
'https://www.tiktok.com/*',
|
||||
'https://tiktok.com/*',
|
||||
// Vimeo
|
||||
'https://vimeo.com/*',
|
||||
'https://www.vimeo.com/*',
|
||||
// Dailymotion
|
||||
'https://www.dailymotion.com/*',
|
||||
'https://dailymotion.com/*',
|
||||
// Twitch
|
||||
'https://www.twitch.tv/*',
|
||||
'https://twitch.tv/*',
|
||||
// Twitter/X
|
||||
'https://twitter.com/*',
|
||||
'https://www.twitter.com/*',
|
||||
'https://x.com/*',
|
||||
'https://www.x.com/*',
|
||||
// Instagram
|
||||
'https://www.instagram.com/*',
|
||||
'https://instagram.com/*',
|
||||
// Facebook
|
||||
'https://www.facebook.com/*',
|
||||
'https://facebook.com/*',
|
||||
'https://fb.com/*',
|
||||
'https://www.fb.com/*',
|
||||
// Reddit
|
||||
'https://www.reddit.com/*',
|
||||
'https://reddit.com/*',
|
||||
// SoundCloud
|
||||
'https://soundcloud.com/*',
|
||||
'https://www.soundcloud.com/*',
|
||||
// NicoNico
|
||||
'https://www.nicovideo.jp/*',
|
||||
'https://nicovideo.jp/*',
|
||||
// Kick
|
||||
'https://kick.com/*',
|
||||
'https://www.kick.com/*',
|
||||
// Bandcamp
|
||||
'https://bandcamp.com/*',
|
||||
'https://*.bandcamp.com/*',
|
||||
// Mixcloud
|
||||
'https://www.mixcloud.com/*',
|
||||
'https://mixcloud.com/*'
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
BIN
screenshots/browser-script.png
Normal file
BIN
screenshots/browser-script.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 438 KiB |
@@ -15,6 +15,8 @@ const http = require('node:http')
|
||||
// Configuration
|
||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||
const GITHUB_TOKEN =
|
||||
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||
|
||||
// Platform configuration
|
||||
const PLATFORM_CONFIG = {
|
||||
@@ -27,7 +29,12 @@ const PLATFORM_CONFIG = {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
||||
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||
output: 'ffmpeg.exe',
|
||||
extract: 'unzip'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
}
|
||||
},
|
||||
darwin: {
|
||||
@@ -41,13 +48,21 @@ const PLATFORM_CONFIG = {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-arm64.*\.zip$/i
|
||||
}
|
||||
},
|
||||
x64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
extract: 'unzip'
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
assetPattern: /ffmpeg-x86_64.*\.zip$/i
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,7 +75,12 @@ const PLATFORM_CONFIG = {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
||||
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||
output: 'ffmpeg_linux',
|
||||
extract: 'tar'
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +137,85 @@ function downloadFile(url, dest) {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
.get(url, { headers }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
return fetchJson(response.headers.location).then(resolve).catch(reject)
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
|
||||
}
|
||||
|
||||
let body = ''
|
||||
response.on('data', (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
.on('error', (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) {
|
||||
const platform = os.platform()
|
||||
ensureDir(extractDir)
|
||||
@@ -186,7 +285,7 @@ async function downloadYtDlp(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)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -197,9 +296,26 @@ async function downloadFfmpegWindows(config) {
|
||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
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 {
|
||||
await downloadFile(url, tempZip)
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -229,7 +345,7 @@ async function downloadFfmpegMac(config) {
|
||||
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)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -240,9 +356,21 @@ async function downloadFfmpegMac(config) {
|
||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
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 {
|
||||
await downloadFile(url, tempZip)
|
||||
await downloadFile(downloadUrl, tempZip)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractZip(tempZip, extractDir)
|
||||
|
||||
@@ -266,7 +394,7 @@ async function downloadFfmpegMac(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)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
@@ -277,9 +405,26 @@ async function downloadFfmpegLinux(config) {
|
||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||
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 {
|
||||
await downloadFile(url, tempTar)
|
||||
await downloadFile(downloadUrl, tempTar)
|
||||
log('Extracting ffmpeg...', 'info')
|
||||
extractTarXz(tempTar, extractDir)
|
||||
|
||||
|
||||
@@ -4,8 +4,17 @@ import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
||||
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
||||
if (!trimmed) {
|
||||
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 => {
|
||||
|
||||
@@ -39,12 +39,94 @@ protocol.registerSchemesAsPrivileged([
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
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) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(startHidden = false): void {
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
@@ -86,9 +168,6 @@ export function createWindow(startHidden = false): void {
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
if (startHidden) {
|
||||
return
|
||||
}
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
@@ -107,6 +186,8 @@ export function createWindow(startHidden = false): void {
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||
isRendererReady = true
|
||||
flushPendingDeepLinks()
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
@@ -260,6 +341,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
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
@@ -301,19 +404,10 @@ app.whenReady().then(async () => {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
if (!settingsManager.get('launchAtLogin')) {
|
||||
settingsManager.set('launchAtLogin', true)
|
||||
}
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
applyAutoLaunchSetting(settingsManager.get('launchAtLogin'))
|
||||
|
||||
const loginItemSettings = app.getLoginItemSettings()
|
||||
const startHidden =
|
||||
settingsManager.get('launchAtLogin') &&
|
||||
(loginItemSettings.wasOpenedAtLogin || loginItemSettings.wasOpenedAsHidden)
|
||||
|
||||
createWindow(startHidden)
|
||||
createWindow()
|
||||
|
||||
initAutoUpdater()
|
||||
|
||||
@@ -322,6 +416,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
handleDeepLinkArgv(process.argv)
|
||||
|
||||
app.on('activate', () => {
|
||||
// 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.
|
||||
|
||||
@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
|
||||
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()
|
||||
getHistoryCount(_context: IpcContext): {
|
||||
active: number
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
@@ -12,20 +11,12 @@ class SettingsService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||
const value = settingsManager.get(key)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
||||
}
|
||||
return value
|
||||
return settingsManager.get(key)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
||||
} else {
|
||||
settingsManager.set(key, value)
|
||||
}
|
||||
settingsManager.set(key, value)
|
||||
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
@@ -46,22 +37,11 @@ class SettingsService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
getAll(_context: IpcContext): AppSettings {
|
||||
const settings = settingsManager.getAll()
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
return settings
|
||||
return settingsManager.getAll()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
settingsManager.setAll(settings)
|
||||
|
||||
if (settings.language) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from 'node:path'
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
@@ -5,6 +6,7 @@ import type {
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
@@ -112,6 +114,7 @@ class SubscriptionService extends IpcService {
|
||||
): Promise<SubscriptionRule> {
|
||||
const resolved = resolveFeedFromInput(options.url)
|
||||
const settings = settingsManager.getAll()
|
||||
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||
const payload: SubscriptionCreatePayload = {
|
||||
sourceUrl: resolved.sourceUrl,
|
||||
feedUrl: resolved.feedUrl,
|
||||
@@ -120,9 +123,9 @@ class SubscriptionService extends IpcService {
|
||||
tags: options.tags,
|
||||
onlyDownloadLatest:
|
||||
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||
downloadDirectory: options.downloadDirectory || settings.downloadPath,
|
||||
downloadDirectory: options.downloadDirectory || defaultDownloadDirectory,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || settings.subscriptionFilenameTemplate
|
||||
options.namingTemplate || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
),
|
||||
enabled: options.enabled ?? true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { YTDlpEventEmitter } from 'yt-dlp-wrap-plus'
|
||||
import type {
|
||||
@@ -12,7 +13,11 @@ import type {
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
} from '../../shared/types'
|
||||
import { buildDownloadArgs, resolveVideoFormatSelector } from '../download-engine/args-builder'
|
||||
import {
|
||||
buildDownloadArgs,
|
||||
resolveVideoFormatSelector,
|
||||
sanitizeFilenameTemplate
|
||||
} from '../download-engine/args-builder'
|
||||
import {
|
||||
findFormatByIdCandidates,
|
||||
parseSizeToBytes,
|
||||
@@ -31,6 +36,115 @@ interface DownloadProcess {
|
||||
process: YTDlpEventEmitter
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
@@ -316,6 +430,10 @@ class DownloadEngine extends EventEmitter {
|
||||
const rangeEnd = Math.max(requestedStart, requestedEnd)
|
||||
const rawEntries = playlistInfo.entries.slice(rangeStart, rangeEnd + 1)
|
||||
const settings = settingsManager.getAll()
|
||||
const resolvedDownloadPath =
|
||||
options.customDownloadPath?.trim() ||
|
||||
resolveAutoPlaylistDownloadPath(settings.downloadPath, playlistInfo, options.url)
|
||||
ensureDirectoryExists(resolvedDownloadPath)
|
||||
|
||||
const selectedEntries = rawEntries.filter((entry) => {
|
||||
if (!entry.url) {
|
||||
@@ -339,7 +457,8 @@ class DownloadEngine extends EventEmitter {
|
||||
url: entry.url,
|
||||
type: options.type,
|
||||
format: options.format,
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||
customDownloadPath: resolvedDownloadPath
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
@@ -370,7 +489,7 @@ class DownloadEngine extends EventEmitter {
|
||||
title: entry.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
playlistId: groupId,
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
@@ -400,6 +519,12 @@ class DownloadEngine extends EventEmitter {
|
||||
const settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
const historyDownloadPath = resolveHistoryDownloadPath(
|
||||
targetDownloadPath,
|
||||
options.customFilenameTemplate
|
||||
)
|
||||
ensureDirectoryExists(targetDownloadPath)
|
||||
ensureDirectoryExists(historyDownloadPath)
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -419,7 +544,7 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: targetDownloadPath,
|
||||
downloadPath: historyDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
@@ -431,7 +556,7 @@ class DownloadEngine extends EventEmitter {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
let resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
@@ -482,6 +607,19 @@ class DownloadEngine extends EventEmitter {
|
||||
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 => {
|
||||
if (!formatId) {
|
||||
return false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
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 { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
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 {
|
||||
try {
|
||||
const database = this.getDatabase()
|
||||
|
||||
@@ -493,7 +493,9 @@ export class SubscriptionManager extends EventEmitter {
|
||||
|
||||
private migrateLegacyStore(): void {
|
||||
try {
|
||||
const LegacyStore = require('electron-store')
|
||||
const ElectronStore = require('electron-store')
|
||||
// Access the default export
|
||||
const LegacyStore = ElectronStore.default || ElectronStore
|
||||
const store = new LegacyStore({
|
||||
name: 'subscriptions',
|
||||
defaults: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
||||
import log from 'electron-log/main'
|
||||
import Parser from 'rss-parser'
|
||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../shared/types'
|
||||
import { settingsManager } from '../settings'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { historyManager } from './history-manager'
|
||||
@@ -406,7 +407,7 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||
const namingTemplate =
|
||||
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
|
||||
subscription.namingTemplate?.trim() || DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
|
||||
@@ -10,7 +10,6 @@ const ElectronStore = require('electron-store')
|
||||
const Store = ElectronStore.default || ElectronStore
|
||||
|
||||
const OLD_DEFAULT_DOWNLOAD_PATH = path.join(os.homedir(), 'Downloads')
|
||||
|
||||
const ensureDirectoryExists = (dir: string) => {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||
import type { SubscriptionRule } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
@@ -14,18 +14,16 @@ import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
import { SupportedSites } from './pages/SupportedSites'
|
||||
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||
|
||||
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',
|
||||
sites: '/sites'
|
||||
about: '/about'
|
||||
}
|
||||
|
||||
const normalizePathname = (pathname: string): string => {
|
||||
@@ -42,8 +40,6 @@ const pathToPage = (pathname: string): Page => {
|
||||
return 'settings'
|
||||
case '/about':
|
||||
return 'about'
|
||||
case '/sites':
|
||||
return 'sites'
|
||||
default:
|
||||
return 'home'
|
||||
}
|
||||
@@ -61,18 +57,43 @@ function AppContent() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const currentPage = pathToPage(location.pathname)
|
||||
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||
|
||||
const handlePageChange = (page: Page) => {
|
||||
const targetPath = pageToPath[page] ?? '/'
|
||||
if (normalizePathname(location.pathname) !== targetPath) {
|
||||
navigate(targetPath)
|
||||
}
|
||||
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(() => {
|
||||
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(() => {
|
||||
loadSubscriptions()
|
||||
|
||||
@@ -227,7 +248,11 @@ function AppContent() {
|
||||
return (
|
||||
<div className="flex flex-row h-screen">
|
||||
{/* Sidebar Navigation */}
|
||||
<Sidebar currentPage={currentPage} onPageChange={handlePageChange} />
|
||||
<Sidebar
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex flex-col flex-1 min-h-0 overflow-hidden bg-background">
|
||||
@@ -238,13 +263,13 @@ function AppContent() {
|
||||
className="flex-1 w-full overflow-y-auto overflow-x-hidden"
|
||||
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%' }}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Home
|
||||
onOpenSupportedSites={() => handlePageChange('sites')}
|
||||
onOpenSupportedSites={handleOpenSupportedSites}
|
||||
onOpenSettings={() => handlePageChange('settings')}
|
||||
/>
|
||||
}
|
||||
@@ -252,7 +277,6 @@ function AppContent() {
|
||||
<Route path="/subscriptions" element={<Subscriptions />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/sites" element={<SupportedSites />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: Menlo, monospace;
|
||||
--radius: 1.3rem;
|
||||
--radius: 0.625rem;
|
||||
--shadow-x: 0px;
|
||||
--shadow-y: 2px;
|
||||
--shadow-blur: 0px;
|
||||
@@ -89,21 +89,6 @@
|
||||
--font-sans: Open Sans, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--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 {
|
||||
@@ -145,16 +130,10 @@
|
||||
--font-serif: var(--font-serif);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--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);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
1349
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
1349
src/renderer/src/components/download/DownloadDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,23 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import { Progress } from '@renderer/components/ui/progress'
|
||||
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 { useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Loader2,
|
||||
Play,
|
||||
Trash2,
|
||||
@@ -150,6 +157,8 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||
|
||||
interface DownloadItemProps {
|
||||
download: DownloadRecord
|
||||
isSelected?: boolean
|
||||
onToggleSelect?: (id: string) => void
|
||||
}
|
||||
|
||||
type MetadataDetail = {
|
||||
@@ -184,7 +193,6 @@ const formatDateShort = (timestamp?: number) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
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 settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
@@ -201,18 +209,15 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const isSubscriptionDownload = download.origin === 'subscription'
|
||||
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||
const actionsContainerBaseClass =
|
||||
'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 actionsContainerClass =
|
||||
'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'
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
// Check if file exists when download data changes
|
||||
useEffect(() => {
|
||||
@@ -494,7 +499,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
href={download.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="wrap-break-word text-primary hover:underline"
|
||||
className="relative z-20 wrap-break-word text-primary hover:underline"
|
||||
>
|
||||
{download.url}
|
||||
</a>
|
||||
@@ -638,25 +643,68 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
const hasMetadataDetails = metadataDetails.length > 0
|
||||
|
||||
const isSelectedHistory = selectionEnabled && isSelected
|
||||
|
||||
return (
|
||||
<div className="group relative w-full max-w-full overflow-hidden">
|
||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-4">
|
||||
<div
|
||||
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 */}
|
||||
<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
|
||||
src={download.thumbnail}
|
||||
alt={download.title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6" />}
|
||||
fallbackIcon={<Play className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
||||
<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-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
<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-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-1 overflow-hidden">
|
||||
<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 line-clamp-1">
|
||||
{download.title}
|
||||
</p>
|
||||
{isSubscriptionDownload && (
|
||||
@@ -665,103 +713,64 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{(statusIcon || statusText) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{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 && (
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
{/* Status */}
|
||||
{statusIcon && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
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>
|
||||
<div className="flex items-center shrink-0">{statusIcon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{detailsOpen ? t('download.hideDetails') : t('download.showDetails')}</p>
|
||||
<p>{statusText}</p>
|
||||
</TooltipContent>
|
||||
</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>
|
||||
{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 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 ? (
|
||||
<>
|
||||
{download.status === 'completed' && (
|
||||
@@ -771,8 +780,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -787,8 +799,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -804,8 +819,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleRemoveHistory}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveHistory()
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -824,8 +842,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopyToClipboard()
|
||||
}}
|
||||
disabled={!canCopyToClipboard()}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -840,8 +861,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleOpenFolder}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleOpenFolder()
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -858,8 +882,11 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={handleCancel}
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCancel()
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -871,13 +898,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
{/* Progress */}
|
||||
{download.progress && download.status !== 'completed' && download.status !== 'error' && (
|
||||
<div className="space-y-2 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1.5 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground w-full">
|
||||
<div className="space-y-1 bg-background/60 w-full overflow-hidden">
|
||||
<Progress value={download.progress.percent} className="h-1 w-full" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground w-full">
|
||||
<span className="font-medium shrink-0">
|
||||
{download.progress.percent.toFixed(1)}%
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-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 && (
|
||||
<span className="truncate max-w-[100px]">
|
||||
{download.progress.downloaded} / {download.progress.total}
|
||||
@@ -902,6 +929,32 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ChevronDown, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { DownloadRecord } from '../../store/downloads'
|
||||
import { Button } from '../ui/button'
|
||||
import { Progress } from '../ui/progress'
|
||||
import { DownloadItem } from './DownloadItem'
|
||||
|
||||
interface PlaylistDownloadGroupProps {
|
||||
@@ -7,15 +11,50 @@ interface PlaylistDownloadGroupProps {
|
||||
title: string
|
||||
records: DownloadRecord[]
|
||||
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({
|
||||
groupId,
|
||||
title,
|
||||
records,
|
||||
totalCount
|
||||
totalCount,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onDeletePlaylist
|
||||
}: PlaylistDownloadGroupProps) {
|
||||
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 errorCount = records.filter((record) => record.status === 'error').length
|
||||
@@ -24,35 +63,108 @@ export function PlaylistDownloadGroup({
|
||||
).length
|
||||
|
||||
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 (
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-muted/20 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{displayTitle}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('playlist.groupSummary', { completed: completedCount, total: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{activeCount > 0 && <span>{t('playlist.groupActive', { count: activeCount })}</span>}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">
|
||||
{t('playlist.groupErrors', { count: errorCount })}
|
||||
</span>
|
||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={toggleLabel}
|
||||
title={toggleLabel}
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
) : (
|
||||
<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 className="space-y-2">
|
||||
{records.map((record) => (
|
||||
<div
|
||||
key={`${groupId}:${record.entryType}:${record.id}`}
|
||||
className="border-l border-border/50 pl-3"
|
||||
>
|
||||
<DownloadItem download={record} />
|
||||
{!isExpanded && totalCount > 0 && (
|
||||
<Progress value={aggregatePercent} className="h-0.5 w-full" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-in-out"
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,26 +1,147 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
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 { useAtomValue } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
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 { toast } from 'sonner'
|
||||
import { useHistorySync } from '../../hooks/use-history-sync'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
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 { PlaylistDownloadGroup } from './PlaylistDownloadGroup'
|
||||
|
||||
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 allRecords = useAtomValue(downloadsArrayAtom)
|
||||
const downloadStats = useAtomValue(downloadStatsAtom)
|
||||
const removeHistoryRecords = useSetAtom(removeHistoryRecordsAtom)
|
||||
const removeHistoryRecordsByPlaylist = useSetAtom(removeHistoryRecordsByPlaylistAtom)
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
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()
|
||||
|
||||
const historyRecords = useMemo(
|
||||
() => allRecords.filter((record) => record.entryType === 'history'),
|
||||
[allRecords]
|
||||
)
|
||||
const selectedCount = selectedIds.size
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
return allRecords.filter((record) => {
|
||||
switch (statusFilter) {
|
||||
@@ -48,6 +169,184 @@ export function UnifiedDownloadHistory() {
|
||||
{ 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 groups = new Map<
|
||||
string,
|
||||
@@ -99,51 +398,61 @@ export function UnifiedDownloadHistory() {
|
||||
}, [filteredRecords])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CardHeader className="gap-4 p-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
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'
|
||||
)}
|
||||
<div className={cn('space-y-4', selectedCount > 0 && 'pb-20')}>
|
||||
<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 justify-between gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{filters.map((filter) => {
|
||||
const isActive = statusFilter === filter.key
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? 'h-8 rounded-full px-3 shadow-sm'
|
||||
: 'h-8 rounded-full border border-border/60 px-3'
|
||||
}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
>
|
||||
{filter.count}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<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>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DownloadDialog
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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 ? (
|
||||
<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" />
|
||||
<p className="text-sm font-medium">{t('download.noItems')}</p>
|
||||
</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) => {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<DownloadItem
|
||||
key={`${item.record.entryType}:${item.record.id}`}
|
||||
download={item.record}
|
||||
isSelected={selectedIds.has(item.record.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -160,12 +469,90 @@ export function UnifiedDownloadHistory() {
|
||||
title={group.title}
|
||||
totalCount={group.totalCount}
|
||||
records={group.records}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onDeletePlaylist={handleRequestDeletePlaylist}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@renderer/components/ui/accordion'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,7 +19,7 @@ import { Switch } from '@renderer/components/ui/switch'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { settingsAtom } from '@renderer/store/settings'
|
||||
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 { ChevronRight } from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
@@ -26,7 +32,15 @@ const sanitizeCommaList = (value: string) =>
|
||||
.map((entry) => entry.trim())
|
||||
.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 {
|
||||
url?: string
|
||||
@@ -69,7 +83,7 @@ export function SubscriptionFormDialog({
|
||||
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||
|
||||
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||
const prevDefaultPathRef = useRef(settings.downloadPath)
|
||||
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
const urlInputId = useId()
|
||||
|
||||
// Initialize form values based on mode
|
||||
@@ -91,22 +105,15 @@ export function SubscriptionFormDialog({
|
||||
setKeywords('')
|
||||
setTags('')
|
||||
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||
setDownloadDirectory(settings.downloadPath)
|
||||
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||
setDownloadDirectory(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||
setNamingTemplate(DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE)
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
mode,
|
||||
subscription,
|
||||
settings.subscriptionOnlyLatestDefault,
|
||||
settings.downloadPath,
|
||||
settings.subscriptionFilenameTemplate
|
||||
])
|
||||
}, [open, mode, subscription, settings.subscriptionOnlyLatestDefault, settings.downloadPath])
|
||||
|
||||
// Sync download directory with settings changes (only in add mode)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
const newPath = settings.downloadPath
|
||||
const newPath = buildDefaultSubscriptionDirectory(settings.downloadPath)
|
||||
setDownloadDirectory((prev) => {
|
||||
if (!prev || prev === prevDefaultPathRef.current) {
|
||||
return newPath
|
||||
@@ -117,13 +124,6 @@ export function SubscriptionFormDialog({
|
||||
}
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
if (mode === 'add') {
|
||||
@@ -265,14 +265,6 @@ export function SubscriptionFormDialog({
|
||||
</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">
|
||||
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||
<div className="flex gap-2">
|
||||
@@ -282,17 +274,34 @@ export function SubscriptionFormDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-3">
|
||||
<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>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
{mode === 'add' && (
|
||||
|
||||
@@ -8,10 +8,10 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -22,5 +22,4 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
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
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
127
src/renderer/src/components/ui/sheet.tsx
Normal file
127
src/renderer/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import { XIcon } from 'lucide-react'
|
||||
import type * as React from 'react'
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription
|
||||
}
|
||||
@@ -24,9 +24,20 @@ import MingcuteRssLine from '~icons/mingcute/rss-line'
|
||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||
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 {
|
||||
id: NavigationTarget
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
inactive: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
label: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
interface PageNavigationItem {
|
||||
id: Page
|
||||
icon: {
|
||||
active: React.ComponentType<{ className?: string }>
|
||||
@@ -38,9 +49,10 @@ interface NavigationItem {
|
||||
interface SidebarProps {
|
||||
currentPage: Page
|
||||
onPageChange: (page: Page) => void
|
||||
onOpenSupportedSites: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
|
||||
@@ -64,16 +76,17 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
id: 'supported-sites',
|
||||
icon: {
|
||||
active: MingcuteCheckCircleFill,
|
||||
inactive: MingcuteCheckCircleLine
|
||||
},
|
||||
label: t('menu.supportedSites')
|
||||
label: t('menu.supportedSites'),
|
||||
onClick: onOpenSupportedSites
|
||||
}
|
||||
]
|
||||
|
||||
const bottomNavigationItems: NavigationItem[] = [
|
||||
const bottomNavigationItems: PageNavigationItem[] = [
|
||||
{
|
||||
id: 'settings',
|
||||
icon: {
|
||||
@@ -107,16 +120,17 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
}
|
||||
|
||||
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 handleClick = item.onClick ?? (() => onPageChange(item.id as Page))
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
onClick={handleClick}
|
||||
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||
>
|
||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||
</Button>
|
||||
@@ -153,7 +167,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="no-drag w-12 h-12">
|
||||
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
|
||||
<MingcuteGlobeLine className="h-5! w-5!" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -197,7 +211,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(item.id)}
|
||||
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' : ''}`} />
|
||||
</Button>
|
||||
|
||||
@@ -8,7 +8,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -16,7 +16,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
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>
|
||||
|
||||
@@ -4,15 +4,10 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger
|
||||
} from '@renderer/components/ui/accordion'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { Switch } from '@renderer/components/ui/switch'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
interface AdvancedOptionsProps {
|
||||
startTime: string
|
||||
@@ -21,6 +16,7 @@ interface AdvancedOptionsProps {
|
||||
onStartTimeChange: (value: string) => void
|
||||
onEndTimeChange: (value: string) => void
|
||||
onDownloadSubsChange: (value: boolean) => void
|
||||
showAccordion?: boolean
|
||||
}
|
||||
|
||||
export function AdvancedOptions({
|
||||
@@ -29,71 +25,65 @@ export function AdvancedOptions({
|
||||
downloadSubs,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
onDownloadSubsChange
|
||||
onDownloadSubsChange,
|
||||
showAccordion = true
|
||||
}: AdvancedOptionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
|
||||
const handleSelectLocation = async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
await ipcServices.settings.set('downloadPath', path)
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error('Failed to select directory')
|
||||
}
|
||||
const content = (
|
||||
<div className="space-y-6">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground ml-1">
|
||||
{t('advancedOptions.timeRange')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative group">
|
||||
<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 (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4">
|
||||
{/* Time Range */}
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="advanced" className="border-b">
|
||||
<AccordionTrigger className="flex items-center gap-2 py-4 text-sm font-semibold hover:no-underline">
|
||||
<span className="flex-1 text-left">{t('advancedOptions.title')}</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-6 pt-2">{content}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import {
|
||||
@@ -8,19 +7,27 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
|
||||
interface AudioExtractorProps {
|
||||
videoInfo: VideoInfo
|
||||
onExtract: (type: 'extract') => void
|
||||
export interface AudioExtractorState {
|
||||
extractFormat: string
|
||||
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 [extractFormat, setExtractFormat] = useState('mp3')
|
||||
const [extractQuality, setExtractQuality] = useState('5')
|
||||
const { extractFormat, extractQuality } = state
|
||||
|
||||
const audioFormats = [
|
||||
{ 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="space-y-2.5">
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -65,7 +75,10 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -79,10 +92,6 @@ export function AudioExtractor({ onExtract }: AudioExtractorProps) {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => onExtract('extract')} className="w-full" size="lg">
|
||||
{t('audioExtract.extract')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@renderer/components/ui/card'
|
||||
import { Card, CardContent, CardHeader } from '@renderer/components/ui/card'
|
||||
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 { 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 { useId, useState } from 'react'
|
||||
|
||||
import { Clock, Eye, Play } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
import { addDownloadAtom } from '../../store/downloads'
|
||||
import { clearVideoInfoAtom } from '../../store/video'
|
||||
import { AdvancedOptions } from './AdvancedOptions'
|
||||
import { AudioExtractor } from './AudioExtractor'
|
||||
import { AudioExtractor, type AudioExtractorState } from './AudioExtractor'
|
||||
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 {
|
||||
videoInfo: VideoInfo
|
||||
state: VideoInfoCardState
|
||||
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||
onTabChange: (tab: 'video' | 'audio') => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
@@ -46,129 +47,62 @@ function formatViews(views?: number): string {
|
||||
return views.toString()
|
||||
}
|
||||
|
||||
export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
export function VideoInfoCard({
|
||||
videoInfo,
|
||||
state,
|
||||
onStateChange,
|
||||
onTabChange
|
||||
}: VideoInfoCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const clearVideoInfo = useSetAtom(clearVideoInfoAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const titleId = useId()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo.thumbnail)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'video' | 'audio'>('video')
|
||||
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'))
|
||||
}
|
||||
}
|
||||
const { title, activeTab } = state
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Button variant="ghost" onClick={() => clearVideoInfo()} className="gap-2 -ml-2" size="sm">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('download.back')}
|
||||
</Button>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div>
|
||||
<Card className="overflow-hidden border shadow-sm">
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Thumbnail */}
|
||||
<div className="shrink-0">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full md:w-80 rounded-lg aspect-video object-cover shadow-sm"
|
||||
fallbackIcon={<Play className="h-12 w-12" />}
|
||||
/>
|
||||
<div className="relative overflow-hidden rounded-md aspect-video w-[120px] sm:w-[140px] bg-muted">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackIcon={<Play className="h-6 w-6 opacity-20" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
<div className="flex-1 space-y-4 min-w-0">
|
||||
<div className="space-y-3">
|
||||
<CardTitle className="text-2xl leading-tight">{t('download.videoInfo')}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-2 items-center">
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1.5 px-2.5 py-1">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.uploader && (
|
||||
<Badge variant="outline" className="px-2.5 py-1">
|
||||
{videoInfo.uploader}
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5 items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-semibold bg-muted/50 px-1.5 py-0.5"
|
||||
>
|
||||
{videoInfo.extractor_key || t('download.videoInfo')}
|
||||
</Badge>
|
||||
{videoInfo.duration && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Clock className="h-2.5 w-2.5" />
|
||||
<span>{formatDuration(videoInfo.duration)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{videoInfo.view_count && (
|
||||
<Badge variant="secondary" className="gap-1 px-1.5 py-0.5 text-[10px]">
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
<span>{formatViews(videoInfo.view_count)}</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor={titleId} className="text-sm font-semibold">
|
||||
{t('download.title')}
|
||||
</Label>
|
||||
<Input
|
||||
id={titleId}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="font-medium h-10"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-sm leading-tight line-clamp-2">{title}</p>
|
||||
{videoInfo.uploader && (
|
||||
<p className="text-[10px] text-muted-foreground truncate">{videoInfo.uploader}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,72 +110,49 @@ export function VideoInfoCard({ videoInfo }: VideoInfoCardProps) {
|
||||
|
||||
<Separator />
|
||||
|
||||
<CardContent className="pt-6 pb-6">
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'video' | 'audio')}>
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsTrigger value="video" className="text-sm font-medium">
|
||||
<CardContent className="p-3 pt-3">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
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')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="audio" className="text-sm font-medium">
|
||||
<TabsTrigger value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="video" className="space-y-5 mt-0">
|
||||
<TabsContent value="video" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="video"
|
||||
onVideoFormatChange={setSelectedVideoFormat}
|
||||
onAudioFormatChange={setSelectedAudioForVideo}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
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 value="audio" className="space-y-5 mt-0">
|
||||
<TabsContent value="audio" className="space-y-3 mt-0">
|
||||
<FormatSelector
|
||||
formats={videoInfo.formats || []}
|
||||
type="audio"
|
||||
onAudioFormatChange={setSelectedAudioFormat}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
|
||||
<AudioExtractor videoInfo={videoInfo} onExtract={handleDownload} />
|
||||
|
||||
<AdvancedOptions
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
downloadSubs={downloadSubs}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onDownloadSubsChange={setDownloadSubs}
|
||||
<AudioExtractor
|
||||
videoInfo={videoInfo}
|
||||
state={state.audioExtractor}
|
||||
onStateChange={(updates) =>
|
||||
onStateChange({
|
||||
audioExtractor: { ...state.audioExtractor, ...updates }
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => handleDownload('audio')}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "اختر تنسيق الصوت",
|
||||
"selectFormat": "اختر التنسيق",
|
||||
"selectVideoFormat": "اختر تنسيق الفيديو",
|
||||
"startDownload": "بدء التحميل",
|
||||
"singleVideo": "فيديو واحد",
|
||||
"speed": "السرعة",
|
||||
"title": "العنوان",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "Audio-Format auswählen",
|
||||
"selectFormat": "Format auswählen",
|
||||
"selectVideoFormat": "Video-Format auswählen",
|
||||
"startDownload": "Download starten",
|
||||
"singleVideo": "Einzelnes Video",
|
||||
"speed": "Geschwindigkeit",
|
||||
"title": "Titel",
|
||||
|
||||
@@ -123,6 +123,10 @@
|
||||
"downloadBtn": "Download",
|
||||
"downloadPending": "Pending",
|
||||
"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",
|
||||
"downloading": "Downloading...",
|
||||
"enterUrl": "Enter Video URL",
|
||||
@@ -155,7 +159,9 @@
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectDownloadType": "Select download type",
|
||||
"selectFormat": "Select Format",
|
||||
"startDownload": "Start Download",
|
||||
"selectVideoFormat": "Select Video Format",
|
||||
"singleVideo": "Single Video",
|
||||
"speed": "Speed",
|
||||
@@ -209,10 +215,23 @@
|
||||
"clearCancelled": "Clear Cancelled",
|
||||
"clearCompleted": "Clear Completed",
|
||||
"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",
|
||||
"copyUrl": "Copy URL",
|
||||
"date": "Date",
|
||||
"deletePlaylist": "Remove Playlist",
|
||||
"deleteSelected": "Remove Selected",
|
||||
"description": "View and manage your download history",
|
||||
"doneSelecting": "Done",
|
||||
"duration": "Duration",
|
||||
"fileSize": "File Size",
|
||||
"filters": {
|
||||
@@ -228,7 +247,14 @@
|
||||
"openFileLocation": "Open File Location",
|
||||
"openFolder": "Open Folder",
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeAction": "Remove",
|
||||
"removeItem": "Remove Item",
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"selectVisible": "Select visible",
|
||||
"selectItem": "Select item",
|
||||
"selectedCount": "{{count}} selected",
|
||||
"selectionSummary": "{{selected}} of {{total}} visible selected",
|
||||
"stats": {
|
||||
"cancelled": "Cancelled",
|
||||
"completed": "Completed",
|
||||
@@ -257,9 +283,15 @@
|
||||
"downloadCompleted": "Download completed",
|
||||
"downloadFailed": "Download failed",
|
||||
"downloadStarted": "Download started",
|
||||
"historyCleared": "History cleared",
|
||||
"historyClearFailed": "Failed to clear history",
|
||||
"itemRemoved": "Item removed",
|
||||
"itemsRemoved": "Removed {{count}} items",
|
||||
"itemsRemoveFailed": "Failed to remove selected items",
|
||||
"openFileFailed": "Failed to open file",
|
||||
"openFolderFailed": "Failed to open folder",
|
||||
"playlistHistoryRemoved": "Playlist removed and files deleted",
|
||||
"playlistHistoryRemoveFailed": "Failed to remove playlist history",
|
||||
"removeFailed": "Failed to remove item",
|
||||
"settingsSaved": "Settings saved",
|
||||
"urlCopied": "URL copied to clipboard",
|
||||
@@ -268,6 +300,7 @@
|
||||
"playlist": {
|
||||
"badgeLabel": "Playlist",
|
||||
"clearPreview": "Clear preview",
|
||||
"collapsedProgress": "Downloading playlist: {{completed}} / {{total}} completed",
|
||||
"comingSoon": "Playlist download feature coming soon!",
|
||||
"completed": "Playlist downloaded",
|
||||
"description": "Download all videos from a YouTube playlist or channel",
|
||||
@@ -283,7 +316,9 @@
|
||||
"folderFormat": "Folder name format for playlists",
|
||||
"foundVideos": "Found {{count}} videos in playlist",
|
||||
"groupActive": "{{count}} active",
|
||||
"groupCollapse": "Collapse",
|
||||
"groupErrors": "{{count}} failed",
|
||||
"groupExpand": "Expand",
|
||||
"groupSummary": "{{completed}} / {{total}} completed",
|
||||
"linkLabel": "Playlist URL",
|
||||
"noEntries": "No videos were found in this playlist",
|
||||
@@ -298,6 +333,8 @@
|
||||
"range": "Range (Optional)",
|
||||
"resetToDefault": "Reset to default",
|
||||
"selectedRange": "Range: {{start}}-{{end}}",
|
||||
"selectedVideos": "{{count}} selected",
|
||||
"downloadCurrentRange": "Download Selected",
|
||||
"showingCount": "Showing {{count}} videos",
|
||||
"startIndex": "Start (1)",
|
||||
"title": "Download Playlist",
|
||||
@@ -392,7 +429,7 @@
|
||||
"title": "Automation defaults",
|
||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||
"downloadDirectory": "Download directory",
|
||||
"filenameTemplate": "Filename template (file only)",
|
||||
"filenameTemplate": "Filename template",
|
||||
"checkInterval": "Check interval (hours)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||
@@ -406,7 +443,7 @@
|
||||
"keywords": "Keyword filter (comma separated)",
|
||||
"tags": "Auto tags",
|
||||
"customDirectory": "Custom directory",
|
||||
"namingTemplate": "Custom filename template (file only)",
|
||||
"namingTemplate": "Custom filename template",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||
"enabled": "Enabled",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "Seleccionar Formato de Audio",
|
||||
"selectFormat": "Seleccionar Formato",
|
||||
"selectVideoFormat": "Seleccionar Formato de Video",
|
||||
"startDownload": "Iniciar descarga",
|
||||
"singleVideo": "Video Individual",
|
||||
"speed": "Velocidad",
|
||||
"title": "Título",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
"selectVideoFormat": "Sélectionner le Format Vidéo",
|
||||
"startDownload": "Démarrer le téléchargement",
|
||||
"showDetails": "Afficher les détails",
|
||||
"singleVideo": "Vidéo Unique",
|
||||
"speed": "Vitesse",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "Pilih Format Audio",
|
||||
"selectFormat": "Pilih Format",
|
||||
"selectVideoFormat": "Pilih Format Video",
|
||||
"startDownload": "Mulai unduhan",
|
||||
"singleVideo": "Video Tunggal",
|
||||
"speed": "Kecepatan",
|
||||
"title": "Judul",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "Seleziona Formato Audio",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
"selectVideoFormat": "Seleziona Formato Video",
|
||||
"startDownload": "Avvia download",
|
||||
"showDetails": "Mostra dettagli",
|
||||
"singleVideo": "Video Singolo",
|
||||
"speed": "Velocità",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||
"selectFormat": "フォーマットを選択",
|
||||
"selectVideoFormat": "ビデオフォーマットを選択",
|
||||
"startDownload": "ダウンロードを開始",
|
||||
"showDetails": "詳細を表示",
|
||||
"singleVideo": "単一ビデオ",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "오디오 형식 선택",
|
||||
"selectFormat": "형식 선택",
|
||||
"selectVideoFormat": "비디오 형식 선택",
|
||||
"startDownload": "다운로드 시작",
|
||||
"showDetails": "세부정보 표시",
|
||||
"singleVideo": "단일 비디오",
|
||||
"speed": "속도",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
"selectVideoFormat": "Selecionar Formato de Vídeo",
|
||||
"startDownload": "Iniciar download",
|
||||
"showDetails": "Mostrar detalhes",
|
||||
"singleVideo": "Vídeo Único",
|
||||
"speed": "Velocidade",
|
||||
|
||||
@@ -157,6 +157,7 @@
|
||||
"selectAudioFormat": "Выбрать формат аудио",
|
||||
"selectFormat": "Выбрать формат",
|
||||
"selectVideoFormat": "Выбрать формат видео",
|
||||
"startDownload": "Начать загрузку",
|
||||
"singleVideo": "Одно видео",
|
||||
"speed": "Скорость",
|
||||
"title": "Название",
|
||||
|
||||
@@ -180,8 +180,10 @@
|
||||
"processing": "處理中",
|
||||
"progress": "進度",
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectDownloadType": "選擇下載類型",
|
||||
"selectFormat": "選擇格式",
|
||||
"selectVideoFormat": "選擇影片格式",
|
||||
"startDownload": "開始下載",
|
||||
"showDetails": "顯示詳情",
|
||||
"singleVideo": "單個影片",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -180,8 +180,10 @@
|
||||
"processing": "处理中",
|
||||
"progress": "进度",
|
||||
"selectAudioFormat": "选择音频格式",
|
||||
"selectDownloadType": "选择下载类型",
|
||||
"selectFormat": "选择格式",
|
||||
"selectVideoFormat": "选择视频格式",
|
||||
"startDownload": "开始下载",
|
||||
"showDetails": "显示详情",
|
||||
"singleVideo": "单个视频",
|
||||
"speed": "速度",
|
||||
|
||||
@@ -1,127 +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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||
import { popularSites } from '@renderer/data/popularSites'
|
||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { AlertCircle, Download, List, Loader2, Play, 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 { 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 {
|
||||
onOpenSupportedSites?: () => void
|
||||
@@ -129,707 +6,18 @@ interface 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 (
|
||||
<div
|
||||
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||
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">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="single"
|
||||
aria-label={t('download.singleVideo')}
|
||||
className="flex items-center justify-center data-[state=inactive]:text-muted-foreground data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="sr-only">{t('download.singleVideo')}</span>
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('download.singleVideo')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<TabsTrigger
|
||||
value="playlist"
|
||||
aria-label={t('playlist.title')}
|
||||
className="flex items-center justify-center data-[state=inactive]:text-muted-foreground data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span className="sr-only">{t('playlist.title')}</span>
|
||||
</TabsTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('playlist.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</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 className="w-full h-full flex flex-col min-h-0">
|
||||
<div
|
||||
className="container mx-auto max-w-7xl p-6 w-full h-full flex flex-col min-h-0"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ function SubscriptionTab({
|
||||
<TabsTrigger
|
||||
value={subscription.id}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
@@ -338,7 +338,7 @@ export function Subscriptions() {
|
||||
))}
|
||||
{/* Add RSS 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"
|
||||
onClick={() => setAddDialogOpen(true)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -96,6 +96,31 @@ export const removeHistoryRecordAtom = atom(null, (get, set, id: string) => {
|
||||
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) => {
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
for (const [key, item] of downloads.entries()) {
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface PlaylistEntry {
|
||||
title: string
|
||||
url: string
|
||||
index: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
export interface PlaylistInfo {
|
||||
@@ -164,6 +165,7 @@ export interface PlaylistDownloadOptions {
|
||||
endIndex?: number
|
||||
filenameFormat?: string
|
||||
folderFormat?: string
|
||||
customDownloadPath?: string
|
||||
}
|
||||
|
||||
export interface PlaylistDownloadEntry {
|
||||
@@ -267,12 +269,13 @@ export interface AppSettings {
|
||||
hideDockIcon: boolean
|
||||
launchAtLogin: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionFilenameTemplate: string
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
downloadPath: '',
|
||||
showMoreFormats: false,
|
||||
@@ -289,9 +292,8 @@ export const defaultSettings: AppSettings = {
|
||||
oneClickQuality: 'best',
|
||||
closeToTray: false,
|
||||
hideDockIcon: false,
|
||||
launchAtLogin: true,
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
subscriptionCheckIntervalHours: 3,
|
||||
enableAnalytics: true
|
||||
|
||||
Reference in New Issue
Block a user