mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-02-04 22:22:23 +05:30
Compare commits
25 Commits
16
.github/banner.svg
vendored
Normal file
16
.github/banner.svg
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg width="600" height="130" viewBox="0 0 600 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_3_2)">
|
||||||
|
<path d="M86.9062 11H21.0938C9.44399 11 0 20.444 0 32.0938V97.9062C0 109.556 9.44399 119 21.0938 119H86.9062C98.556 119 108 109.556 108 97.9062V32.0938C108 20.444 98.556 11 86.9062 11Z" fill="url(#paint0_linear_3_2)"/>
|
||||||
|
<path d="M55.8196 96.5455C54.7881 97.5856 53.1065 97.5856 52.075 96.5455L27.028 71.2863C25.3778 69.6221 26.5566 66.793 28.9002 66.793H78.9943C81.3379 66.793 82.5168 69.6221 80.8666 71.2863L55.8196 96.5455Z" fill="#FAFAFA"/>
|
||||||
|
<path d="M67.8164 34.4141H40.0781C38.6219 34.4141 37.4414 35.5946 37.4414 37.0508V68.2695C37.4414 69.7257 38.6219 70.9062 40.0781 70.9062H67.8164C69.2726 70.9062 70.4531 69.7257 70.4531 68.2695V37.0508C70.4531 35.5946 69.2726 34.4141 67.8164 34.4141Z" fill="#FAFAFA"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_3_2" x1="13.6582" y1="26.6621" x2="97.1367" y2="102.02" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#4444FF"/>
|
||||||
|
<stop offset="1" stop-color="#FF43D0"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="clip0_3_2">
|
||||||
|
<rect width="108" height="108" fill="white" transform="translate(0 11)"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
138
.github/mockup.svg
vendored
Normal file
138
.github/mockup.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 541 KiB |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.github/workflows/.secrets
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -21,4 +22,4 @@ dist-ssr
|
|||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -1,14 +1,16 @@
|
|||||||
### ✨ Changelog
|
### ✨ Changelog
|
||||||
|
|
||||||
- Migrated to React 19, TailwindCSS 4 and ShadcnUI 2.6
|
- DOWNLOADER: Added 'Cancel Search' button
|
||||||
- Added new 'Extension' tab
|
- FIXED: Downloaded files are not moving from temp to download folder in some linux distros
|
||||||
- Fixed: Default download directory not updating
|
- FIXED: 'Stop' all ongoing downloads button not working on linux
|
||||||
- Fixed: MacOS large dock icon (#1)
|
- Improved search and download error handling
|
||||||
|
- Removed subtitle (CC) embeding restriction from M4A files
|
||||||
|
- Migrated to Zod v4 and improved form validations
|
||||||
- Other minor fixes and improvements
|
- Other minor fixes and improvements
|
||||||
|
|
||||||
### 📝 Notes
|
### 📝 Notes
|
||||||
|
|
||||||
> ⚠️ Linux Users: Make sure yt-dlp is not installed in your distro (otherwise you will get package installation conflict)
|
> ⚠️ Linux Users: Make sure yt-dlp is not installed in your distro (otherwise you will get package installation conflict). Don't worry, You can still use yt-dlp cli as before (the only difference is that now it will be installed and auto-updated by neo-dlp, which You can also disable from neo-dlp Settings if you don't want to auto-update yt-dlp)
|
||||||
|
|
||||||
> This is an Un-Signed Build (Windows doesn't trust this Certificate so, it may flag this as malicious software, in that case, disable Windows SmartScreen and Defender, install it, and then re-enable them)
|
> This is an Un-Signed Build (Windows doesn't trust this Certificate so, it may flag this as malicious software, in that case, disable Windows SmartScreen and Defender, install it, and then re-enable them)
|
||||||
|
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -1,30 +1,58 @@
|
|||||||
|

|
||||||
|
|
||||||
# NeoDLP - (Neo Downloader Plus)
|
# NeoDLP - (Neo Downloader Plus)
|
||||||
|
|
||||||
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration
|
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration
|
||||||
|
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
[](https://github.com/neosubhamoy/neodlp)
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
[](https://github.com/neosubhamoy/neodlp)
|
||||||
|
[](https://github.com/neosubhamoy/neodlp/releases)
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
[](https://github.com/neosubhamoy/neodlp)
|
||||||
|
|
||||||
> **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!**
|
> **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!**
|
||||||
|
|
||||||
|
[](https://repology.org/project/neodlp/versions)
|
||||||
|
|
||||||
|
### ✨ Highlighted Features
|
||||||
|
|
||||||
|
- Download Video/Audio from popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md))
|
||||||
|
- Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.)
|
||||||
|
- Supports both Video and Playlist download
|
||||||
|
- Supports Combining Video, Audio streams of your choice
|
||||||
|
- Supports Multi-Language Subtitle/Caption (CC) embeding
|
||||||
|
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
|
||||||
|
- Highly customizable and many more...😉
|
||||||
|
|
||||||
|
### 🧩 Browser Integration
|
||||||
|
|
||||||
|
You can integrate NeoDLP with your favourite browser (any Chrome/Chromium/Firefox based browser) Just, install [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension) to get started!
|
||||||
|
|
||||||
|
After installing the extension you can do the following directly from the browser:
|
||||||
|
|
||||||
|
- Quick Search (search current browser address with NeoDLP) (via pressing keyboard shortcut `ALT`+`SHIFT`+`Q`, You can also change this shortcut key combo from browser settings)
|
||||||
|
|
||||||
|
- Right Click Context Menu Action (Download with Neo Downloader Plus - Link, Selection, Media Source)
|
||||||
|
|
||||||
|
### 👀 Sneak Peek
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### 💻 Supported Platforms
|
### 💻 Supported Platforms
|
||||||
|
|
||||||
- Windows (10 / 11)
|
- Windows (10 / 11)
|
||||||
- Linux (Debian / Fedora / Arch Linux base)
|
- Linux (Debian / Fedora / Arch Linux base)
|
||||||
- MacOS (>10.3)
|
- MacOS (>10.3)
|
||||||
|
|
||||||
### 🌐 Supported Sites
|
> ⚠️ **NOTE:** Though most linux (debian/fedora/arch base) distros are supported but not all packages are tested on all these platforms, to save time (and some brain cells) and ship the software as fast as possible! (Currently only the debian package is tested on Ubuntu 24.04 - So, other linux packages may have issues, test it yourself and feel free to report issues if you found one)
|
||||||
|
|
||||||
- All [Supported Sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) by [yt-dlp](https://github.com/yt-dlp/yt-dlp) **(2.5K+)**
|
### 🤝 External Dependencies
|
||||||
|
|
||||||
### 🧩 External Dependencies
|
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) - The core CLI tool used to download Video/Audio from the Web (Hero of the show 😎)
|
||||||
|
- [FFmpeg](https://www.ffmpeg.org) - Used for Video/Audio post-processing
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The core CLI Tool used to download Video/Audio from the Web
|
|
||||||
- [FFmpeg](https://www.ffmpeg.org) - Used for Video/Audio Post-processing
|
|
||||||
|
|
||||||
### ⬇️ Download and Installation
|
### ⬇️ Download and Installation
|
||||||
|
|
||||||
1. Download the latest [NeoDLP](https://github.com/neosubhamoy/neodlp/releases/latest) release based on your OS and CPU Architecture then install it or install it directly from an available distribution channel
|
1. Download the latest [NeoDLP](https://github.com/neosubhamoy/neodlp/releases/latest) release based on your OS and CPU Architecture, then install it! -OR- Install it directly from an available distribution channel (listed below)
|
||||||
|
|
||||||
| Arch\OS | Windows | Linux | MacOS |
|
| Arch\OS | Windows | Linux | MacOS |
|
||||||
| :---- | :---- | :---- | :---- |
|
| :---- | :---- | :---- | :---- |
|
||||||
@@ -37,6 +65,18 @@ Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Int
|
|||||||
| MacOS Universal | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
|
| MacOS Universal | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
|
||||||
| Linux x86_64 (Arch Linux) | AUR | `yay -S neodlp` |
|
| Linux x86_64 (Arch Linux) | AUR | `yay -S neodlp` |
|
||||||
|
|
||||||
|
### 💝 Support the Development
|
||||||
|
|
||||||
|
NeoDLP is and will be always FREE to Use for Everyone and Open-Sourced. On the other hand the developent process of NeoDLP takes lots of time, effort and even sometimes money! So, if you appriciate my work and have the ability to donate, then please consider supporting the development by donating (even a very small donation matters and helps NeoDLP to be a better product!) Your support is the key to my motivation...🤗
|
||||||
|
|
||||||
|
<a href="https://buymeacoffee.com/neosubhamoy" target="_blank" title="buymeacoffee">
|
||||||
|
<img src="https://iili.io/JoQ0zN9.md.png" alt="buymeacoffee-orange-badge" style="width: 150px;">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<br></br>
|
||||||
|
|
||||||
|
> 📌 **NOTE:** You can also donate via UPI by sending donations to this UPI ID directly: **[subhamoybiswas636-2@oksbi](upi://pay?pa=subhamoybiswas636-2@oksbi&pn=Subhamoy%20Biswas)**
|
||||||
|
|
||||||
### ⚡ Technologies Used
|
### ⚡ Technologies Used
|
||||||
|
|
||||||

|

|
||||||
@@ -55,7 +95,7 @@ Want to be part of this? Feel free to contribute...!! Pull Requests are always w
|
|||||||
2. Git clone the forked repo in your local machine.
|
2. Git clone the forked repo in your local machine.
|
||||||
3. Install Node.js dependencies: `npm install`
|
3. Install Node.js dependencies: `npm install`
|
||||||
4. Run development / build process
|
4. Run development / build process
|
||||||
> ⚠️ Make sure to run the build command once before running the dev command for the first time to avoid build time errors
|
> ⚠️ **IMPORTANT:** Make sure to run the build command once before running the dev command for the first time to avoid compile time errors
|
||||||
```code
|
```code
|
||||||
# for windows and linux users
|
# for windows and linux users
|
||||||
npm run tauri dev # for development
|
npm run tauri dev # for development
|
||||||
|
|||||||
1211
package-lock.json
generated
1211
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neodlp",
|
"name": "neodlp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -38,50 +38,50 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.9",
|
"@radix-ui/react-toggle": "^1.1.9",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tanstack/react-query": "^5.80.7",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-query-devtools": "^5.80.7",
|
"@tanstack/react-query-devtools": "^5.83.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
"@tauri-apps/plugin-dialog": "^2.3.1",
|
||||||
"@tauri-apps/plugin-fs": "^2.3.0",
|
"@tauri-apps/plugin-fs": "^2.4.1",
|
||||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.3.0",
|
||||||
"@tauri-apps/plugin-process": "^2.2.1",
|
"@tauri-apps/plugin-process": "^2.3.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.1",
|
"@tauri-apps/plugin-shell": "^2.3.0",
|
||||||
"@tauri-apps/plugin-sql": "^2.2.0",
|
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.7.0",
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.60.0",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.3",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.7.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.4",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^3.25.64",
|
"zod": "^4.0.5",
|
||||||
"zustand": "^5.0.5"
|
"zustand": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^24.0.1",
|
"@types/node": "^24.0.15",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"postcss": "^8.5.5",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.11",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.5",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.3.5"
|
"vite": "^7.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
886
src-tauri/Cargo.lock
generated
886
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "neodlp"
|
name = "neodlp"
|
||||||
version = "0.1.1"
|
version = "0.2.1"
|
||||||
description = "NeoDLP"
|
description = "NeoDLP"
|
||||||
authors = ["neosubhamoy <hey@neosubhamoy.com>"]
|
authors = ["neosubhamoy <hey@neosubhamoy.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c20996d097127884243f4780d929b3769d55418c0efa9bd7a98999f387b5fbed
|
oid sha256:5fa4862eb50050941370fa78a8c56faa31516d5abaf1f7cf31bc56de166347d9
|
||||||
size 18113133
|
size 18123562
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<string>com.neosubhamoy.neodlp</string>
|
<string>com.neosubhamoy.neodlp</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/Applications/neodlp.app/Contents/MacOS/neodlp</string>
|
<string>/Applications/NeoDLP.app/Contents/MacOS/neodlp</string>
|
||||||
<string>--hidden</string>
|
<string>--hidden</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"description": "NeoDLP MsgHost",
|
"description": "NeoDLP MsgHost",
|
||||||
"path": "/usr/bin/neodlp-msghost",
|
"path": "/usr/bin/neodlp-msghost",
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
|
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"description": "NeoDLP MsgHost",
|
"description": "NeoDLP MsgHost",
|
||||||
"path": "/Applications/NeoDLP.app/Contents/Resources/neodlp-msghost",
|
"path": "/Applications/NeoDLP.app/Contents/Resources/neodlp-msghost",
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
|
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"description": "NeoDLP MsgHost",
|
"description": "NeoDLP MsgHost",
|
||||||
"path": "neodlp-msghost.exe",
|
"path": "neodlp-msghost.exe",
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/", "chrome-extension://agkddibgemhefmdhlnooiakfnhihhbdb/"]
|
"allowed_origins": ["chrome-extension://mehopeailfjmiloiiohgicphlcgpompf/"]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "NeoDLP",
|
"productName": "NeoDLP",
|
||||||
"mainBinaryName": "neodlp",
|
"mainBinaryName": "neodlp",
|
||||||
"version": "0.1.1",
|
"version": "0.2.1",
|
||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
186
src/App.tsx
186
src/App.tsx
@@ -7,7 +7,7 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { arch, exeExtension } from "@tauri-apps/plugin-os";
|
import { arch, exeExtension } from "@tauri-apps/plugin-os";
|
||||||
import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path";
|
import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path";
|
||||||
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
import { determineFileType, generateDownloadId, generateSafeFilePath, generateVideoId, isObjEmpty, parseProgressLine, sanitizeFilename } from "@/utils";
|
import { determineFileType, generateDownloadId, generateSafeFilePath, generateVideoId, isObjEmpty, parseProgressLine, sanitizeFilename } from "@/utils";
|
||||||
import { Command } from "@tauri-apps/plugin-shell";
|
import { Command } from "@tauri-apps/plugin-shell";
|
||||||
import { RawVideoInfo } from "@/types/video";
|
import { RawVideoInfo } from "@/types/video";
|
||||||
@@ -25,8 +25,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { platform } from "@tauri-apps/plugin-os";
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
import { useMacOsRegisterer } from "@/helpers/use-macos-registerer";
|
import { useMacOsRegisterer } from "@/helpers/use-macos-registerer";
|
||||||
import useAppUpdater from "@/helpers/use-app-updater";
|
import useAppUpdater from "@/helpers/use-app-updater";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function App({ children }: { children: React.ReactNode }) {
|
export default function App({ children }: { children: React.ReactNode }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
|
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
|
||||||
const { data: settings, isSuccess: isSuccessFetchingSettings } = useFetchAllSettings();
|
const { data: settings, isSuccess: isSuccessFetchingSettings } = useFetchAllSettings();
|
||||||
const { data: kvPairs, isSuccess: isSuccessFetchingKvPairs } = useFetchAllkVPairs();
|
const { data: kvPairs, isSuccess: isSuccessFetchingKvPairs } = useFetchAllkVPairs();
|
||||||
@@ -40,6 +43,8 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
|
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
|
||||||
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
|
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
|
||||||
|
|
||||||
|
const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid);
|
||||||
|
|
||||||
// const isUsingDefaultSettings = useSettingsPageStatesStore((state) => state.isUsingDefaultSettings);
|
// const isUsingDefaultSettings = useSettingsPageStatesStore((state) => state.isUsingDefaultSettings);
|
||||||
const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings);
|
const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings);
|
||||||
const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey);
|
const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey);
|
||||||
@@ -53,10 +58,27 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
const YTDLP_UPDATE_CHANNEL = useSettingsPageStatesStore(state => state.settings.ytdlp_update_channel);
|
const YTDLP_UPDATE_CHANNEL = useSettingsPageStatesStore(state => state.settings.ytdlp_update_channel);
|
||||||
const APP_THEME = useSettingsPageStatesStore(state => state.settings.theme);
|
const APP_THEME = useSettingsPageStatesStore(state => state.settings.theme);
|
||||||
const MAX_PARALLEL_DOWNLOADS = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
|
const MAX_PARALLEL_DOWNLOADS = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
|
||||||
|
const MAX_RETRIES = useSettingsPageStatesStore(state => state.settings.max_retries);
|
||||||
const DOWNLOAD_DIR = useSettingsPageStatesStore(state => state.settings.download_dir);
|
const DOWNLOAD_DIR = useSettingsPageStatesStore(state => state.settings.download_dir);
|
||||||
const PREFER_VIDEO_OVER_PLAYLIST = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
|
const PREFER_VIDEO_OVER_PLAYLIST = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
|
||||||
|
const STRICT_DOWNLOADABILITY_CHECK = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check);
|
||||||
const USE_PROXY = useSettingsPageStatesStore(state => state.settings.use_proxy);
|
const USE_PROXY = useSettingsPageStatesStore(state => state.settings.use_proxy);
|
||||||
const PROXY_URL = useSettingsPageStatesStore(state => state.settings.proxy_url);
|
const PROXY_URL = useSettingsPageStatesStore(state => state.settings.proxy_url);
|
||||||
|
const USE_RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.use_rate_limit);
|
||||||
|
const RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.rate_limit);
|
||||||
|
const VIDEO_FORMAT = useSettingsPageStatesStore(state => state.settings.video_format);
|
||||||
|
const AUDIO_FORMAT = useSettingsPageStatesStore(state => state.settings.audio_format);
|
||||||
|
const ALWAYS_REENCODE_VIDEO = useSettingsPageStatesStore(state => state.settings.always_reencode_video);
|
||||||
|
const EMBED_VIDEO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
|
||||||
|
const EMBED_AUDIO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
|
||||||
|
const EMBED_AUDIO_THUMBNAIL = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
|
||||||
|
|
||||||
|
const isErrored = useDownloaderPageStatesStore((state) => state.isErrored);
|
||||||
|
const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected);
|
||||||
|
const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId);
|
||||||
|
const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored);
|
||||||
|
const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected);
|
||||||
|
const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId);
|
||||||
|
|
||||||
const appWindow = getCurrentWebviewWindow()
|
const appWindow = getCurrentWebviewWindow()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -85,10 +107,12 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string): Promise<RawVideoInfo | null> => {
|
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string): Promise<RawVideoInfo | null> => {
|
||||||
try {
|
try {
|
||||||
const args = [url, '--dump-single-json'];
|
const args = [url, '--dump-single-json', '--no-warnings'];
|
||||||
if (formatId) args.push('-f', formatId);
|
if (formatId) args.push('-f', formatId);
|
||||||
if (playlistIndex) args.push('--playlist-items', playlistIndex);
|
if (playlistIndex) args.push('--playlist-items', playlistIndex);
|
||||||
if (PREFER_VIDEO_OVER_PLAYLIST) args.push('--no-playlist');
|
if (PREFER_VIDEO_OVER_PLAYLIST) args.push('--no-playlist');
|
||||||
|
if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats');
|
||||||
|
if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats');
|
||||||
if (USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
|
if (USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
|
||||||
const command = Command.sidecar('binaries/yt-dlp', args);
|
const command = Command.sidecar('binaries/yt-dlp', args);
|
||||||
|
|
||||||
@@ -99,14 +123,19 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
jsonOutput += line;
|
jsonOutput += line;
|
||||||
});
|
});
|
||||||
|
|
||||||
command.on('close', async () => {
|
command.on('close', async (data) => {
|
||||||
try {
|
if (data.code !== 0) {
|
||||||
const data: RawVideoInfo = JSON.parse(jsonOutput);
|
console.error(`yt-dlp failed to fetch metadata with code ${data.code}`);
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error(`Failed to parse JSON: ${e}`);
|
|
||||||
resolve(null);
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const parsedData: RawVideoInfo = JSON.parse(jsonOutput);
|
||||||
|
resolve(parsedData);
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(`Failed to parse JSON: ${e}`);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +144,9 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
command.spawn().catch(e => {
|
command.spawn().then(child => {
|
||||||
|
setSearchPid(child.pid);
|
||||||
|
}).catch(e => {
|
||||||
console.error(`Failed to spawn command: ${e}`);
|
console.error(`Failed to spawn command: ${e}`);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
@@ -127,6 +158,11 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const startDownload = async (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => {
|
const startDownload = async (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => {
|
||||||
|
// set error states to default
|
||||||
|
setIsErrored(false);
|
||||||
|
setIsErrorExpected(false);
|
||||||
|
setErroredDownloadId(null);
|
||||||
|
|
||||||
console.log('Starting download:', { url, selectedFormat, selectedSubtitles, resumeState, playlistItems });
|
console.log('Starting download:', { url, selectedFormat, selectedSubtitles, resumeState, playlistItems });
|
||||||
if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) {
|
if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) {
|
||||||
console.error('FFmpeg or download paths not found');
|
console.error('FFmpeg or download paths not found');
|
||||||
@@ -138,12 +174,24 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined);
|
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined);
|
||||||
if (!videoMetadata) {
|
if (!videoMetadata) {
|
||||||
console.error('Failed to fetch video metadata');
|
console.error('Failed to fetch video metadata');
|
||||||
|
toast({
|
||||||
|
title: 'Download Failed',
|
||||||
|
description: 'yt-dlp failed to fetch video metadata. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Video Metadata:', videoMetadata);
|
console.log('Video Metadata:', videoMetadata);
|
||||||
videoMetadata = isPlaylist ? videoMetadata.entries[0] : videoMetadata;
|
videoMetadata = isPlaylist ? videoMetadata.entries[0] : videoMetadata;
|
||||||
|
|
||||||
|
const fileType = determineFileType(videoMetadata.vcodec, videoMetadata.acodec);
|
||||||
|
|
||||||
|
if (fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) {
|
||||||
|
if (VIDEO_FORMAT !== 'auto' && (fileType === 'video+audio' || fileType === 'video')) videoMetadata.ext = VIDEO_FORMAT;
|
||||||
|
if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT;
|
||||||
|
}
|
||||||
|
|
||||||
const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain);
|
const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain);
|
||||||
const playlistId = isPlaylist ? (resumeState?.playlist_id || generateVideoId(videoMetadata.playlist_id, videoMetadata.webpage_url_domain)) : null;
|
const playlistId = isPlaylist ? (resumeState?.playlist_id || generateVideoId(videoMetadata.playlist_id, videoMetadata.webpage_url_domain)) : null;
|
||||||
const downloadId = resumeState?.download_id || generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain);
|
const downloadId = resumeState?.download_id || generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain);
|
||||||
@@ -163,6 +211,9 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
'-f',
|
'-f',
|
||||||
selectedFormat,
|
selectedFormat,
|
||||||
'--no-mtime',
|
'--no-mtime',
|
||||||
|
'--no-warnings',
|
||||||
|
'--retries',
|
||||||
|
MAX_RETRIES.toString(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (selectedSubtitles) {
|
if (selectedSubtitles) {
|
||||||
@@ -173,6 +224,39 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
args.push('--playlist-items', playlistIndex);
|
args.push('--playlist-items', playlistIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) {
|
||||||
|
if (VIDEO_FORMAT !== 'auto' && fileType === 'video+audio') {
|
||||||
|
if (ALWAYS_REENCODE_VIDEO) {
|
||||||
|
args.push('--recode-video', VIDEO_FORMAT);
|
||||||
|
} else {
|
||||||
|
args.push('--merge-output-format', VIDEO_FORMAT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (VIDEO_FORMAT !== 'auto' && fileType === 'video') {
|
||||||
|
if (ALWAYS_REENCODE_VIDEO) {
|
||||||
|
args.push('--recode-video', VIDEO_FORMAT);
|
||||||
|
} else {
|
||||||
|
args.push('--remux-video', VIDEO_FORMAT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') {
|
||||||
|
args.push('--extract-audio', '--audio-format', AUDIO_FORMAT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType !== 'unknown' && (EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) {
|
||||||
|
if (EMBED_VIDEO_METADATA && (fileType === 'video+audio' || fileType === 'video')) {
|
||||||
|
args.push('--embed-metadata');
|
||||||
|
}
|
||||||
|
if (EMBED_AUDIO_METADATA && fileType === 'audio') {
|
||||||
|
args.push('--embed-metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EMBED_AUDIO_THUMBNAIL && fileType === 'audio') {
|
||||||
|
args.push('--embed-thumbnail');
|
||||||
|
}
|
||||||
|
|
||||||
if (resumeState) {
|
if (resumeState) {
|
||||||
args.push('--continue');
|
args.push('--continue');
|
||||||
} else {
|
} else {
|
||||||
@@ -183,28 +267,27 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
args.push('--proxy', PROXY_URL);
|
args.push('--proxy', PROXY_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (USE_RATE_LIMIT && RATE_LIMIT) {
|
||||||
|
args.push('--limit-rate', `${RATE_LIMIT}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Starting download with args:', args);
|
console.log('Starting download with args:', args);
|
||||||
const command = Command.sidecar('binaries/yt-dlp', args);
|
const command = Command.sidecar('binaries/yt-dlp', args);
|
||||||
|
|
||||||
command.on('close', async data => {
|
command.on('close', async (data) => {
|
||||||
if (data.code !== 0) {
|
if (data.code !== 0) {
|
||||||
console.error(`Download failed with code ${data.code}`);
|
console.error(`Download failed with code ${data.code}`);
|
||||||
|
if (!isErrorExpected) {
|
||||||
|
setIsErrored(true);
|
||||||
|
setErroredDownloadId(downloadId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download status updated successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to update download status:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (await fs.exists(tempDownloadPath)) {
|
if (await fs.exists(tempDownloadPath)) {
|
||||||
downloadFilePath = await generateSafeFilePath(downloadFilePath);
|
downloadFilePath = await generateSafeFilePath(downloadFilePath);
|
||||||
await fs.rename(tempDownloadPath, downloadFilePath);
|
await fs.copyFile(tempDownloadPath, downloadFilePath);
|
||||||
|
await fs.remove(tempDownloadPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath }, {
|
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath }, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log("Download filepath updated successfully:", data);
|
console.log("Download filepath updated successfully:", data);
|
||||||
@@ -214,11 +297,23 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
console.error("Failed to update download filepath:", error);
|
console.error("Failed to update download filepath:", error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log("Download status updated successfully:", data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update download status:", error);
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
command.on('error', error => {
|
command.on('error', error => {
|
||||||
console.error(`Error: ${error}`);
|
console.error(`Error: ${error}`);
|
||||||
|
setIsErrored(true);
|
||||||
|
setErroredDownloadId(downloadId);
|
||||||
});
|
});
|
||||||
|
|
||||||
command.stdout.on('data', line => {
|
command.stdout.on('data', line => {
|
||||||
@@ -380,8 +475,11 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const pauseDownload = async (downloadState: DownloadState) => {
|
const pauseDownload = async (downloadState: DownloadState) => {
|
||||||
try {
|
try {
|
||||||
console.log("Killing process with PID:", downloadState.process_id);
|
if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) {
|
||||||
await invoke('kill_all_process', { pid: downloadState.process_id });
|
setIsErrorExpected(true); // Set error expected to true to handle UI state
|
||||||
|
console.log("Killing process with PID:", downloadState.process_id);
|
||||||
|
await invoke('kill_all_process', { pid: downloadState.process_id });
|
||||||
|
}
|
||||||
downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, {
|
downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log("Download status updated successfully:", data);
|
console.log("Download status updated successfully:", data);
|
||||||
@@ -424,6 +522,7 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
const cancelDownload = async (downloadState: DownloadState) => {
|
const cancelDownload = async (downloadState: DownloadState) => {
|
||||||
try {
|
try {
|
||||||
if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) {
|
if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) {
|
||||||
|
setIsErrorExpected(true); // Set error expected to true to handle UI state
|
||||||
console.log("Killing process with PID:", downloadState.process_id);
|
console.log("Killing process with PID:", downloadState.process_id);
|
||||||
await invoke('kill_all_process', { pid: downloadState.process_id });
|
await invoke('kill_all_process', { pid: downloadState.process_id });
|
||||||
}
|
}
|
||||||
@@ -757,6 +856,43 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [processQueuedDownloads, ongoingDownloads, queuedDownloads]);
|
}, [processQueuedDownloads, ongoingDownloads, queuedDownloads]);
|
||||||
|
|
||||||
|
// show a toast and pause the download when yt-dlp exits unexpectedly
|
||||||
|
useEffect(() => {
|
||||||
|
if (isErrored && !isErrorExpected) {
|
||||||
|
toast({
|
||||||
|
title: "Download Failed",
|
||||||
|
description: "yt-dlp exited unexpectedly. Please try again later",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
if (erroredDownloadId) {
|
||||||
|
downloadStatusUpdater.mutate({ download_id: erroredDownloadId, download_status: 'paused' }, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log("Download status updated successfully:", data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to update download status:", error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setErroredDownloadId(null);
|
||||||
|
}
|
||||||
|
setIsErrored(false);
|
||||||
|
setIsErrorExpected(false);
|
||||||
|
}
|
||||||
|
}, [isErrored, isErrorExpected, erroredDownloadId, setIsErrored, setIsErrorExpected, setErroredDownloadId]);
|
||||||
|
|
||||||
|
// auto reset error states after 3 seconds of expecting an error
|
||||||
|
useEffect(() => {
|
||||||
|
if (isErrorExpected) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setIsErrored(false);
|
||||||
|
setIsErrorExpected(false);
|
||||||
|
setErroredDownloadId(null);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isErrorExpected, setIsErrorExpected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload }}>
|
<AppContext.Provider value={{ fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload }}>
|
||||||
<ThemeProvider defaultTheme={APP_THEME || "system"} storageKey="vite-ui-theme">
|
<ThemeProvider defaultTheme={APP_THEME || "system"} storageKey="vite-ui-theme">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { config } from "@/config";
|
import { config } from "@/config";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
|
||||||
import { CircleArrowUp, Download, Puzzle, Settings, SquarePlay, } from "lucide-react";
|
import { CircleArrowUp, Download, Settings, SquarePlay, } from "lucide-react";
|
||||||
import { isActive as isActiveSidebarItem } from "@/utils";
|
import { isActive as isActiveSidebarItem } from "@/utils";
|
||||||
import { RoutesObj } from "@/types/route";
|
import { RoutesObj } from "@/types/route";
|
||||||
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
@@ -29,6 +29,7 @@ export function AppSidebar() {
|
|||||||
const { open } = useSidebar();
|
const { open } = useSidebar();
|
||||||
const { downloadAndInstallAppUpdate } = useAppUpdater();
|
const { downloadAndInstallAppUpdate } = useAppUpdater();
|
||||||
const [showBadge, setShowBadge] = useState(false);
|
const [showBadge, setShowBadge] = useState(false);
|
||||||
|
const [showUpdateCard, setShowUpdateCard] = useState(false);
|
||||||
|
|
||||||
const topItems: Array<RoutesObj> = [
|
const topItems: Array<RoutesObj> = [
|
||||||
{
|
{
|
||||||
@@ -40,11 +41,6 @@ export function AppSidebar() {
|
|||||||
title: "Library",
|
title: "Library",
|
||||||
url: "/library",
|
url: "/library",
|
||||||
icon: SquarePlay,
|
icon: SquarePlay,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Extension",
|
|
||||||
url: "/extension",
|
|
||||||
icon: Puzzle,
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -61,9 +57,11 @@ export function AppSidebar() {
|
|||||||
if (open) {
|
if (open) {
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
setShowBadge(true);
|
setShowBadge(true);
|
||||||
|
setShowUpdateCard(true);
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
setShowBadge(false);
|
setShowBadge(false);
|
||||||
|
setShowUpdateCard(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -153,13 +151,14 @@ export function AppSidebar() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{appUpdate && open && (
|
{appUpdate && open && showUpdateCard && (
|
||||||
<Card>
|
<Card className="gap-4 py-0">
|
||||||
<CardHeader className="p-4 pb-0">
|
<CardHeader className="p-4 pb-0">
|
||||||
<CardTitle className="text-sm">Update Available (v{appUpdate.version})</CardTitle>
|
<CardTitle className="text-sm">Update Available (v{appUpdate.version})</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A new version of {config.appName} is available. Please update to the latest version for the best experience.
|
A newer version of {config.appName} is available. Please update to the latest version for the best experience.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
<a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/neosubhamoy/neodlp/releases/tag/v${appUpdate.version}`} target="_blank">✨ Read Changelog</a>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2.5 p-4">
|
<CardContent className="grid gap-2.5 p-4">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
|
|||||||
@@ -31,15 +31,16 @@ export default function useAppUpdater() {
|
|||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'Started':
|
case 'Started':
|
||||||
contentLength = event.data.contentLength;
|
contentLength = event.data.contentLength;
|
||||||
console.log(`started downloading ${event.data.contentLength} bytes`);
|
console.log(`started downloading app update of ${event.data.contentLength} bytes`);
|
||||||
break;
|
break;
|
||||||
case 'Progress':
|
case 'Progress':
|
||||||
downloaded += event.data.chunkLength;
|
downloaded += event.data.chunkLength;
|
||||||
setDownloadProgress(downloaded / (contentLength || 0));
|
const progress = (downloaded / (contentLength || 1)) * 100;
|
||||||
console.log(`downloaded ${downloaded} from ${contentLength}`);
|
setDownloadProgress(Math.round(progress * 10) / 10);
|
||||||
|
console.log(`downloaded ${downloaded} bytes from ${contentLength} bytes of app update`);
|
||||||
break;
|
break;
|
||||||
case 'Finished':
|
case 'Finished':
|
||||||
console.log('download finished');
|
console.log('app update download finished');
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import RootLayout from "@/pages/layout/root";
|
|||||||
import DownloaderPage from "@/pages/downloader";
|
import DownloaderPage from "@/pages/downloader";
|
||||||
import LibraryPage from "@/pages/library";
|
import LibraryPage from "@/pages/library";
|
||||||
import SettingsPage from "@/pages/settings";
|
import SettingsPage from "@/pages/settings";
|
||||||
import ExtensionPage from "@/pages/extension";
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
@@ -19,7 +18,6 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|||||||
<Route path="/" element={<RootLayout />}>
|
<Route path="/" element={<RootLayout />}>
|
||||||
<Route index element={<DownloaderPage />} />
|
<Route index element={<DownloaderPage />} />
|
||||||
<Route path="/library" element={<LibraryPage />} />
|
<Route path="/library" element={<LibraryPage />} />
|
||||||
<Route path="/extension" element={<ExtensionPage />} />
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useAppContext } from "@/providers/appContextProvider";
|
import { useAppContext } from "@/providers/appContextProvider";
|
||||||
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore } from "@/services/store";
|
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
import { determineFileType, fileFormatFilter, formatBitrate, formatDurationString, formatFileSize, formatReleaseDate, formatYtStyleCount, isObjEmpty, sortByBitrate } from "@/utils";
|
import { determineFileType, fileFormatFilter, formatBitrate, formatDurationString, formatFileSize, formatReleaseDate, formatYtStyleCount, isObjEmpty, sortByBitrate } from "@/utils";
|
||||||
import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch } from "lucide-react";
|
import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch, AlertCircleIcon, X } from "lucide-react";
|
||||||
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
|
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup";
|
import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup";
|
||||||
@@ -21,10 +21,16 @@ import { useForm } from "react-hook-form";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
import { config } from "@/config";
|
import { config } from "@/config";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
const searchFormSchema = z.object({
|
const searchFormSchema = z.object({
|
||||||
url: z.string().min(1, { message: "URL is required" })
|
url: z.url({
|
||||||
.url({message: "Invalid URL format." }),
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
||||||
|
? "URL is required"
|
||||||
|
: "Invalid URL format"
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function DownloaderPage() {
|
export default function DownloaderPage() {
|
||||||
@@ -36,20 +42,32 @@ export default function DownloaderPage() {
|
|||||||
const isMetadataLoading = useCurrentVideoMetadataStore((state) => state.isMetadataLoading);
|
const isMetadataLoading = useCurrentVideoMetadataStore((state) => state.isMetadataLoading);
|
||||||
const requestedUrl = useCurrentVideoMetadataStore((state) => state.requestedUrl);
|
const requestedUrl = useCurrentVideoMetadataStore((state) => state.requestedUrl);
|
||||||
const autoSubmitSearch = useCurrentVideoMetadataStore((state) => state.autoSubmitSearch);
|
const autoSubmitSearch = useCurrentVideoMetadataStore((state) => state.autoSubmitSearch);
|
||||||
|
const searchPid = useCurrentVideoMetadataStore((state) => state.searchPid);
|
||||||
const setVideoUrl = useCurrentVideoMetadataStore((state) => state.setVideoUrl);
|
const setVideoUrl = useCurrentVideoMetadataStore((state) => state.setVideoUrl);
|
||||||
const setVideoMetadata = useCurrentVideoMetadataStore((state) => state.setVideoMetadata);
|
const setVideoMetadata = useCurrentVideoMetadataStore((state) => state.setVideoMetadata);
|
||||||
const setIsMetadataLoading = useCurrentVideoMetadataStore((state) => state.setIsMetadataLoading);
|
const setIsMetadataLoading = useCurrentVideoMetadataStore((state) => state.setIsMetadataLoading);
|
||||||
const setRequestedUrl = useCurrentVideoMetadataStore((state) => state.setRequestedUrl);
|
const setRequestedUrl = useCurrentVideoMetadataStore((state) => state.setRequestedUrl);
|
||||||
const setAutoSubmitSearch = useCurrentVideoMetadataStore((state) => state.setAutoSubmitSearch);
|
const setAutoSubmitSearch = useCurrentVideoMetadataStore((state) => state.setAutoSubmitSearch);
|
||||||
|
const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid);
|
||||||
|
const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError);
|
||||||
|
|
||||||
|
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
|
||||||
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
|
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
|
||||||
const selctedDownloadFormat = useDownloaderPageStatesStore((state) => state.selctedDownloadFormat);
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||||
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||||
|
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
|
||||||
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
||||||
|
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
|
||||||
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
|
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
|
||||||
const setSelctedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelctedDownloadFormat);
|
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||||
|
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||||
|
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
|
||||||
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
|
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
|
||||||
|
|
||||||
|
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
|
||||||
|
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
|
||||||
|
|
||||||
const audioOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('audio'))) : [];
|
const audioOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('audio'))) : [];
|
||||||
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
|
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
|
||||||
@@ -79,22 +97,44 @@ export default function DownloaderPage() {
|
|||||||
const allFilteredFormats = [...(audioOnlyFormats || []), ...(videoOnlyFormats || []), ...(combinedFormats || []), ...(qualityPresetFormats || [])];
|
const allFilteredFormats = [...(audioOnlyFormats || []), ...(videoOnlyFormats || []), ...(combinedFormats || []), ...(qualityPresetFormats || [])];
|
||||||
const selectedFormat = (() => {
|
const selectedFormat = (() => {
|
||||||
if (videoMetadata?._type === 'video') {
|
if (videoMetadata?._type === 'video') {
|
||||||
if (selctedDownloadFormat === 'best') {
|
if (selectedDownloadFormat === 'best') {
|
||||||
return videoMetadata?.requested_downloads[0];
|
return videoMetadata?.requested_downloads[0];
|
||||||
}
|
}
|
||||||
return allFilteredFormats.find(
|
return allFilteredFormats.find(
|
||||||
(format) => format.format_id === selctedDownloadFormat
|
(format) => format.format_id === selectedDownloadFormat
|
||||||
);
|
);
|
||||||
} else if (videoMetadata?._type === 'playlist') {
|
} else if (videoMetadata?._type === 'playlist') {
|
||||||
if (selctedDownloadFormat === 'best') {
|
if (selectedDownloadFormat === 'best') {
|
||||||
return videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0];
|
return videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0];
|
||||||
}
|
}
|
||||||
return allFilteredFormats.find(
|
return allFilteredFormats.find(
|
||||||
(format) => format.format_id === selctedDownloadFormat
|
(format) => format.format_id === selectedDownloadFormat
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
const selectedFormatFileType = determineFileType(selectedFormat?.vcodec, selectedFormat?.acodec);
|
const selectedFormatFileType = determineFileType(selectedFormat?.vcodec, selectedFormat?.acodec);
|
||||||
|
const selectedVideoFormat = (() => {
|
||||||
|
if (videoMetadata?._type === 'video') {
|
||||||
|
return allFilteredFormats.find(
|
||||||
|
(format) => format.format_id === selectedCombinableVideoFormat
|
||||||
|
);
|
||||||
|
} else if (videoMetadata?._type === 'playlist') {
|
||||||
|
return allFilteredFormats.find(
|
||||||
|
(format) => format.format_id === selectedCombinableVideoFormat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const selectedAudioFormat = (() => {
|
||||||
|
if (videoMetadata?._type === 'video') {
|
||||||
|
return allFilteredFormats.find(
|
||||||
|
(format) => format.format_id === selectedCombinableAudioFormat
|
||||||
|
);
|
||||||
|
} else if (videoMetadata?._type === 'playlist') {
|
||||||
|
return allFilteredFormats.find(
|
||||||
|
(format) => format.format_id === selectedCombinableAudioFormat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
const subtitles = videoMetadata?._type === 'video' ? (videoMetadata?.subtitles || {}) : videoMetadata?._type === 'playlist' ? (videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].subtitles || {}) : {};
|
const subtitles = videoMetadata?._type === 'video' ? (videoMetadata?.subtitles || {}) : videoMetadata?._type === 'playlist' ? (videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].subtitles || {}) : {};
|
||||||
const subtitleLanguages = Object.keys(subtitles).map(langCode => ({
|
const subtitleLanguages = Object.keys(subtitles).map(langCode => ({
|
||||||
@@ -105,6 +145,62 @@ export default function DownloaderPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomBarRef = useRef<HTMLDivElement>(null);
|
const bottomBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
let selectedFormatExtensionMsg = 'Auto - unknown';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (videoFormat !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormat.ext.toUpperCase()}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatExtensionMsg = `Combined - unknown`;
|
||||||
|
}
|
||||||
|
} else if (selectedFormat?.ext) {
|
||||||
|
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && videoFormat !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Forced - ${videoFormat.toUpperCase()}`;
|
||||||
|
} else if (selectedFormatFileType === 'audio' && audioFormat !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Forced - ${audioFormat.toUpperCase()}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatResolutionMsg = 'unknown';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${selectedAudioFormat?.tbr ? formatBitrate(selectedAudioFormat.tbr) : 'unknown'}`;
|
||||||
|
} else if (selectedFormat?.resolution) {
|
||||||
|
selectedFormatResolutionMsg = selectedFormat.resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatDynamicRangeMsg = '';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : '';
|
||||||
|
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') {
|
||||||
|
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatFileSizeMsg = 'unknown filesize';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
selectedFormatFileSizeMsg = selectedVideoFormat?.filesize_approx && selectedAudioFormat?.filesize_approx ? formatFileSize(selectedVideoFormat.filesize_approx + selectedAudioFormat.filesize_approx) : 'unknown filesize';
|
||||||
|
} else if (selectedFormat?.filesize_approx) {
|
||||||
|
selectedFormatFileSizeMsg = formatFileSize(selectedFormat.filesize_approx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatFinalMsg = '';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) {
|
||||||
|
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatFinalMsg = `Choose a video and audio stream to combine`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedFormat) {
|
||||||
|
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatFinalMsg = `Choose a stream to download`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchForm = useForm<z.infer<typeof searchFormSchema>>({
|
const searchForm = useForm<z.infer<typeof searchFormSchema>>({
|
||||||
resolver: zodResolver(searchFormSchema),
|
resolver: zodResolver(searchFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -113,20 +209,29 @@ export default function DownloaderPage() {
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
})
|
})
|
||||||
const watchedUrl = searchForm.watch("url");
|
const watchedUrl = searchForm.watch("url");
|
||||||
|
const { errors: searchFormErrors } = searchForm.formState;
|
||||||
|
|
||||||
function handleSearchSubmit(values: z.infer<typeof searchFormSchema>) {
|
function handleSearchSubmit(values: z.infer<typeof searchFormSchema>) {
|
||||||
setVideoMetadata(null);
|
setVideoMetadata(null);
|
||||||
|
setSearchPid(null);
|
||||||
|
setShowSearchError(true);
|
||||||
setIsMetadataLoading(true);
|
setIsMetadataLoading(true);
|
||||||
setSelctedDownloadFormat('best');
|
setSelectedDownloadFormat('best');
|
||||||
|
setSelectedCombinableVideoFormat('');
|
||||||
|
setSelectedCombinableAudioFormat('');
|
||||||
setSelectedSubtitles([]);
|
setSelectedSubtitles([]);
|
||||||
setSelectedPlaylistVideoIndex('1');
|
setSelectedPlaylistVideoIndex('1');
|
||||||
|
|
||||||
fetchVideoMetadata(values.url).then((metadata) => {
|
fetchVideoMetadata(values.url).then((metadata) => {
|
||||||
if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) {
|
if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) {
|
||||||
toast({
|
const showSearchError = useCurrentVideoMetadataStore.getState().showSearchError;
|
||||||
title: 'Opps! No results found',
|
if (showSearchError) {
|
||||||
description: 'The provided URL does not contain any downloadable content. Please check the URL and try again.',
|
toast({
|
||||||
variant: "destructive"
|
title: 'Oops! No results found',
|
||||||
});
|
description: 'The provided URL does not contain any downloadable content or you are not connected to the internet. Please check the URL, your network connection and try again.',
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (metadata && (metadata._type === 'video' || metadata._type === 'playlist') && ((metadata._type === 'video' && metadata.formats.length > 0) || (metadata._type === 'playlist' && metadata.entries.length > 0))) setVideoMetadata(metadata);
|
if (metadata && (metadata._type === 'video' || metadata._type === 'playlist') && ((metadata._type === 'video' && metadata.formats.length > 0) || (metadata._type === 'playlist' && metadata.entries.length > 0))) setVideoMetadata(metadata);
|
||||||
if (metadata) console.log(metadata);
|
if (metadata) console.log(metadata);
|
||||||
@@ -134,6 +239,16 @@ export default function DownloaderPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cancelSearch = async (pid: number | null) => {
|
||||||
|
setShowSearchError(false);
|
||||||
|
if (pid) {
|
||||||
|
console.log("Killing process with PID:", pid);
|
||||||
|
await invoke('kill_all_process', { pid: pid });
|
||||||
|
}
|
||||||
|
setVideoMetadata(null);
|
||||||
|
setIsMetadataLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateBottomBarWidth = (): void => {
|
const updateBottomBarWidth = (): void => {
|
||||||
if (containerRef.current && bottomBarRef.current) {
|
if (containerRef.current && bottomBarRef.current) {
|
||||||
@@ -247,9 +362,20 @@ export default function DownloaderPage() {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{isMetadataLoading && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
disabled={!isMetadataLoading}
|
||||||
|
onClick={() => cancelSearch(searchPid)}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!videoUrl || isMetadataLoading}
|
disabled={!videoUrl || Object.keys(searchFormErrors).length > 0 || isMetadataLoading}
|
||||||
>
|
>
|
||||||
{isMetadataLoading ? (
|
{isMetadataLoading ? (
|
||||||
<>
|
<>
|
||||||
@@ -267,11 +393,11 @@ export default function DownloaderPage() {
|
|||||||
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'video' && ( // === Single Video ===
|
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'video' && ( // === Single Video ===
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex flex-col w-[55%] border-r border-border pr-4">
|
<div className="flex flex-col w-[55%] border-r border-border pr-4">
|
||||||
<h3 className="text-sm mb-4 flex items-center gap-2">
|
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
|
||||||
<Info className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
<span>Metadata</span>
|
<span>Metadata</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<AspectRatio ratio={16 / 9} className={clsx("w-full rounded-lg overflow-hidden mb-2 border border-border", videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "relative")}>
|
<AspectRatio ratio={16 / 9} className={clsx("w-full rounded-lg overflow-hidden mb-2 border border-border", videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "relative")}>
|
||||||
<ProxyImage src={videoMetadata.thumbnail} alt="thumbnail" className={clsx(videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2")} />
|
<ProxyImage src={videoMetadata.thumbnail} alt="thumbnail" className={clsx(videoMetadata.aspect_ratio && videoMetadata.aspect_ratio === 0.56 && "absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2")} />
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
@@ -309,127 +435,221 @@ export default function DownloaderPage() {
|
|||||||
<Info className="w-3 h-3 mr-2" />
|
<Info className="w-3 h-3 mr-2" />
|
||||||
<span className="text-xs">Extracted from {videoMetadata.extractor ? videoMetadata.extractor.charAt(0).toUpperCase() + videoMetadata.extractor.slice(1) : 'Unknown'}</span>
|
<span className="text-xs">Extracted from {videoMetadata.extractor ? videoMetadata.extractor.charAt(0).toUpperCase() + videoMetadata.extractor.slice(1) : 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="spacer mb-14"></div>
|
<div className="spacer mb-10"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-full pl-4">
|
<div className="flex flex-col w-full pl-4">
|
||||||
<h3 className="text-sm mb-4 flex items-center gap-2">
|
<Tabs
|
||||||
<DownloadCloud className="w-4 h-4" />
|
className=""
|
||||||
<span>Download Options</span>
|
value={activeDownloadModeTab}
|
||||||
</h3>
|
onValueChange={(tab) => setActiveDownloadModeTab(tab)}
|
||||||
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
|
>
|
||||||
{subtitles && !isObjEmpty(subtitles) && (
|
<div className="flex items-center justify-between">
|
||||||
<ToggleGroup
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
type="multiple"
|
<DownloadCloud className="w-4 h-4" />
|
||||||
variant="outline"
|
<span>Download Options</span>
|
||||||
className="flex flex-col items-start gap-2 mb-2"
|
</h3>
|
||||||
value={selectedSubtitles}
|
<TabsList>
|
||||||
onValueChange={(value) => setSelectedSubtitles(value)}
|
<TabsTrigger value="selective">Selective</TabsTrigger>
|
||||||
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
<TabsTrigger value="combine">Combine</TabsTrigger>
|
||||||
>
|
</TabsList>
|
||||||
<p className="text-xs">Subtitle Languages</p>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<TabsContent value="selective">
|
||||||
{subtitleLanguages.map((lang) => (
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<ToggleGroupItem
|
{subtitles && !isObjEmpty(subtitles) && (
|
||||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
<ToggleGroup
|
||||||
value={lang.code}
|
type="multiple"
|
||||||
size="sm"
|
variant="outline"
|
||||||
aria-label={lang.lang}
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
key={lang.code}>
|
value={selectedSubtitles}
|
||||||
{lang.lang}
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
</ToggleGroupItem>
|
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
||||||
))}
|
>
|
||||||
</div>
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
</ToggleGroup>
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
)}
|
{subtitleLanguages.map((lang) => (
|
||||||
<FormatSelectionGroup
|
<ToggleGroupItem
|
||||||
value={selctedDownloadFormat}
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
||||||
onValueChange={(value) => {
|
value={lang.code}
|
||||||
setSelctedDownloadFormat(value);
|
size="sm"
|
||||||
const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
|
aria-label={lang.lang}
|
||||||
if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
|
key={lang.code}>
|
||||||
setSelectedSubtitles([]);
|
{lang.lang}
|
||||||
}
|
</ToggleGroupItem>
|
||||||
}}
|
))}
|
||||||
>
|
</div>
|
||||||
<p className="text-xs">Suggested</p>
|
</ToggleGroup>
|
||||||
<div className="">
|
)}
|
||||||
<FormatSelectionGroupItem
|
<FormatSelectionGroup
|
||||||
key="best"
|
value={selectedDownloadFormat}
|
||||||
value="best"
|
onValueChange={(value) => {
|
||||||
format={videoMetadata.requested_downloads[0]}
|
setSelectedDownloadFormat(value);
|
||||||
/>
|
// const currentlySelectedFormat = value === 'best' ? videoMetadata?.requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
|
||||||
|
// if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
|
||||||
|
// setSelectedSubtitles([]);
|
||||||
|
// }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Suggested</p>
|
||||||
|
<div className="">
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key="best"
|
||||||
|
value="best"
|
||||||
|
format={videoMetadata.requested_downloads[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Quality Presets</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{qualityPresetFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Audio</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{audioOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{videoOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{combinedFormats && combinedFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{combinedFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
<div className="spacer mb-10"></div>
|
||||||
</div>
|
</div>
|
||||||
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
|
</TabsContent>
|
||||||
<>
|
<TabsContent value="combine">
|
||||||
<p className="text-xs">Quality Presets</p>
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitles && !isObjEmpty(subtitles) && (
|
||||||
{qualityPresetFormats.map((format) => (
|
<ToggleGroup
|
||||||
<FormatSelectionGroupItem
|
type="multiple"
|
||||||
key={format.format_id}
|
variant="outline"
|
||||||
value={format.format_id}
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
format={format}
|
value={selectedSubtitles}
|
||||||
/>
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
))}
|
>
|
||||||
</div>
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
</>
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
)}
|
{subtitleLanguages.map((lang) => (
|
||||||
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
<ToggleGroupItem
|
||||||
<>
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
||||||
<p className="text-xs">Audio</p>
|
value={lang.code}
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
size="sm"
|
||||||
{audioOnlyFormats.map((format) => (
|
aria-label={lang.lang}
|
||||||
<FormatSelectionGroupItem
|
key={lang.code}>
|
||||||
key={format.format_id}
|
{lang.lang}
|
||||||
value={format.format_id}
|
</ToggleGroupItem>
|
||||||
format={format}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
</ToggleGroup>
|
||||||
</div>
|
)}
|
||||||
</>
|
<FormatSelectionGroup
|
||||||
)}
|
className="mb-2"
|
||||||
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
value={selectedCombinableAudioFormat}
|
||||||
<>
|
onValueChange={(value) => {
|
||||||
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
|
setSelectedCombinableAudioFormat(value);
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
}}
|
||||||
{videoOnlyFormats.map((format) => (
|
>
|
||||||
<FormatSelectionGroupItem
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
||||||
key={format.format_id}
|
<>
|
||||||
value={format.format_id}
|
<p className="text-xs">Audio</p>
|
||||||
format={format}
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
/>
|
{audioOnlyFormats.map((format) => (
|
||||||
))}
|
<FormatSelectionGroupItem
|
||||||
</div>
|
key={format.format_id}
|
||||||
</>
|
value={format.format_id}
|
||||||
)}
|
format={format}
|
||||||
{combinedFormats && combinedFormats.length > 0 && (
|
/>
|
||||||
<>
|
))}
|
||||||
<p className="text-xs">Video</p>
|
</div>
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
</>
|
||||||
{combinedFormats.map((format) => (
|
)}
|
||||||
<FormatSelectionGroupItem
|
</FormatSelectionGroup>
|
||||||
key={format.format_id}
|
<FormatSelectionGroup
|
||||||
value={format.format_id}
|
value={selectedCombinableVideoFormat}
|
||||||
format={format}
|
onValueChange={(value) => {
|
||||||
/>
|
setSelectedCombinableVideoFormat(value);
|
||||||
))}
|
}}
|
||||||
</div>
|
>
|
||||||
</>
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
)}
|
<>
|
||||||
</FormatSelectionGroup>
|
<p className="text-xs">Video</p>
|
||||||
<div className="spacer mb-14"></div>
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
</div>
|
{videoOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="spacer mb-10"></div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'playlist' && ( // === Playlists ===
|
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'playlist' && ( // === Playlists ===
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex flex-col w-[55%] border-r border-border pr-4">
|
<div className="flex flex-col w-[55%] border-r border-border pr-4">
|
||||||
<h3 className="text-sm mb-4 flex items-center gap-2">
|
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
|
||||||
<ListVideo className="w-4 h-4" />
|
<ListVideo className="w-4 h-4" />
|
||||||
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
|
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
|
||||||
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
|
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
|
||||||
{/* <PlaylistToggleGroup
|
{/* <PlaylistToggleGroup
|
||||||
@@ -451,8 +671,10 @@ export default function DownloaderPage() {
|
|||||||
value={selectedPlaylistVideoIndex}
|
value={selectedPlaylistVideoIndex}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setSelectedPlaylistVideoIndex(value);
|
setSelectedPlaylistVideoIndex(value);
|
||||||
setSelctedDownloadFormat('best');
|
setSelectedDownloadFormat('best');
|
||||||
setSelectedSubtitles([]);
|
setSelectedSubtitles([]);
|
||||||
|
setSelectedCombinableVideoFormat('');
|
||||||
|
setSelectedCombinableAudioFormat('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{videoMetadata.entries.map((entry) => entry ? (
|
{videoMetadata.entries.map((entry) => entry ? (
|
||||||
@@ -467,120 +689,215 @@ export default function DownloaderPage() {
|
|||||||
<Info className="w-3 h-3 mr-2" />
|
<Info className="w-3 h-3 mr-2" />
|
||||||
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
|
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="spacer mb-14"></div>
|
<div className="spacer mb-10"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col w-full pl-4">
|
<div className="flex flex-col w-full pl-4">
|
||||||
<h3 className="text-sm mb-4 flex items-center gap-2">
|
<Tabs
|
||||||
<DownloadCloud className="w-4 h-4" />
|
className=""
|
||||||
<span>Download Options</span>
|
value={activeDownloadModeTab}
|
||||||
</h3>
|
onValueChange={(tab) => setActiveDownloadModeTab(tab)}
|
||||||
<div className="flex flex-col overflow-y-scroll max-h-[53vh] no-scrollbar">
|
>
|
||||||
{subtitles && !isObjEmpty(subtitles) && (
|
<div className="flex items-center justify-between">
|
||||||
<ToggleGroup
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
type="multiple"
|
<DownloadCloud className="w-4 h-4" />
|
||||||
variant="outline"
|
<span>Download Options</span>
|
||||||
className="flex flex-col items-start gap-2 mb-2"
|
</h3>
|
||||||
value={selectedSubtitles}
|
<TabsList>
|
||||||
onValueChange={(value) => setSelectedSubtitles(value)}
|
<TabsTrigger value="selective">Selective</TabsTrigger>
|
||||||
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
<TabsTrigger value="combine">Combine</TabsTrigger>
|
||||||
>
|
</TabsList>
|
||||||
<p className="text-xs">Subtitle Languages</p>
|
</div>
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<TabsContent value="selective">
|
||||||
{subtitleLanguages.map((lang) => (
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<ToggleGroupItem
|
{subtitles && !isObjEmpty(subtitles) && (
|
||||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
<ToggleGroup
|
||||||
value={lang.code}
|
type="multiple"
|
||||||
size="sm"
|
variant="outline"
|
||||||
aria-label={lang.lang}
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
key={lang.code}>
|
value={selectedSubtitles}
|
||||||
{lang.lang}
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
</ToggleGroupItem>
|
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
||||||
))}
|
>
|
||||||
</div>
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
</ToggleGroup>
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
)}
|
{subtitleLanguages.map((lang) => (
|
||||||
<FormatSelectionGroup
|
<ToggleGroupItem
|
||||||
value={selctedDownloadFormat}
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
||||||
onValueChange={(value) => {
|
value={lang.code}
|
||||||
setSelctedDownloadFormat(value);
|
size="sm"
|
||||||
const currentlySelectedFormat = value === 'best' ? videoMetadata?.entries[Number(value) - 1].requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
|
aria-label={lang.lang}
|
||||||
if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
|
key={lang.code}>
|
||||||
setSelectedSubtitles([]);
|
{lang.lang}
|
||||||
}
|
</ToggleGroupItem>
|
||||||
}}
|
))}
|
||||||
>
|
</div>
|
||||||
<p className="text-xs">Suggested</p>
|
</ToggleGroup>
|
||||||
<div className="">
|
)}
|
||||||
<FormatSelectionGroupItem
|
<FormatSelectionGroup
|
||||||
key="best"
|
value={selectedDownloadFormat}
|
||||||
value="best"
|
onValueChange={(value) => {
|
||||||
format={videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]}
|
setSelectedDownloadFormat(value);
|
||||||
/>
|
const currentlySelectedFormat = value === 'best' ? videoMetadata?.entries[Number(value) - 1].requested_downloads[0] : allFilteredFormats.find((format) => format.format_id === value);
|
||||||
|
if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
|
||||||
|
setSelectedSubtitles([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Suggested</p>
|
||||||
|
<div className="">
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key="best"
|
||||||
|
value="best"
|
||||||
|
format={videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Quality Presets</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{qualityPresetFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Audio</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{audioOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{videoOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{combinedFormats && combinedFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video</p>
|
||||||
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{combinedFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
<div className="spacer mb-10"></div>
|
||||||
</div>
|
</div>
|
||||||
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
|
</TabsContent>
|
||||||
<>
|
<TabsContent value="combine">
|
||||||
<p className="text-xs">Quality Presets</p>
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] no-scrollbar">
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitles && !isObjEmpty(subtitles) && (
|
||||||
{qualityPresetFormats.map((format) => (
|
<ToggleGroup
|
||||||
<FormatSelectionGroupItem
|
type="multiple"
|
||||||
key={format.format_id}
|
variant="outline"
|
||||||
value={format.format_id}
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
format={format}
|
value={selectedSubtitles}
|
||||||
/>
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
))}
|
disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
||||||
</div>
|
>
|
||||||
</>
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
)}
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
{audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
{subtitleLanguages.map((lang) => (
|
||||||
<>
|
<ToggleGroupItem
|
||||||
<p className="text-xs">Audio</p>
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-muted/70 hover:bg-muted/70"
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
value={lang.code}
|
||||||
{audioOnlyFormats.map((format) => (
|
size="sm"
|
||||||
<FormatSelectionGroupItem
|
aria-label={lang.lang}
|
||||||
key={format.format_id}
|
key={lang.code}>
|
||||||
value={format.format_id}
|
{lang.lang}
|
||||||
format={format}
|
</ToggleGroupItem>
|
||||||
/>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</ToggleGroup>
|
||||||
</>
|
)}
|
||||||
)}
|
<FormatSelectionGroup
|
||||||
{videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
className="mb-2"
|
||||||
<>
|
value={selectedCombinableAudioFormat}
|
||||||
<p className="text-xs">Video {videoOnlyFormats.every(format => format.acodec === 'none') ? '(no audio)' : ''}</p>
|
onValueChange={(value) => {
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
setSelectedCombinableAudioFormat(value);
|
||||||
{videoOnlyFormats.map((format) => (
|
}}
|
||||||
<FormatSelectionGroupItem
|
>
|
||||||
key={format.format_id}
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
|
||||||
value={format.format_id}
|
<>
|
||||||
format={format}
|
<p className="text-xs">Audio</p>
|
||||||
/>
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
))}
|
{audioOnlyFormats.map((format) => (
|
||||||
</div>
|
<FormatSelectionGroupItem
|
||||||
</>
|
key={format.format_id}
|
||||||
)}
|
value={format.format_id}
|
||||||
{combinedFormats && combinedFormats.length > 0 && (
|
format={format}
|
||||||
<>
|
/>
|
||||||
<p className="text-xs">Video</p>
|
))}
|
||||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
</div>
|
||||||
{combinedFormats.map((format) => (
|
</>
|
||||||
<FormatSelectionGroupItem
|
)}
|
||||||
key={format.format_id}
|
</FormatSelectionGroup>
|
||||||
value={format.format_id}
|
<FormatSelectionGroup
|
||||||
format={format}
|
value={selectedCombinableVideoFormat}
|
||||||
/>
|
onValueChange={(value) => {
|
||||||
))}
|
setSelectedCombinableVideoFormat(value);
|
||||||
</div>
|
}}
|
||||||
</>
|
>
|
||||||
)}
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
</FormatSelectionGroup>
|
<>
|
||||||
<div className="spacer mb-14"></div>
|
<p className="text-xs">Video</p>
|
||||||
</div>
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
|
||||||
|
{videoOnlyFormats.map((format) => (
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Unable to use Combine Mode!</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Cannot use combine mode for this video as it does not have both audio and video formats available. Use Selective Mode or try another video.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="spacer mb-10"></div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isMetadataLoading && videoMetadata && selctedDownloadFormat && ( // === Bottom Bar ===
|
{!isMetadataLoading && videoMetadata && selectedDownloadFormat && ( // === Bottom Bar ===
|
||||||
<div className="flex justify-between items-center gap-2 fixed bottom-0 right-0 p-4 w-full bg-background rounded-t-lg border-t border-border z-20" ref={bottomBarRef}>
|
<div className="flex justify-between items-center gap-2 fixed bottom-0 right-0 p-4 w-full bg-background rounded-t-lg border-t border-border z-20" ref={bottomBarRef}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex justify-center items-center p-3 rounded-md border border-border">
|
<div className="flex justify-center items-center p-3 rounded-md border border-border">
|
||||||
@@ -596,7 +913,7 @@ export default function DownloaderPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm text-nowrap max-w-[30rem] xl:max-w-[50rem] overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' }</span>
|
<span className="text-sm text-nowrap max-w-[30rem] xl:max-w-[50rem] overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' }</span>
|
||||||
<span className="text-xs text-muted-foreground">{selectedFormat?.ext ? selectedFormat.ext.toUpperCase() : 'unknown'} ({selectedFormat?.resolution ? selectedFormat.resolution : 'unknown'}) {selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' ? selectedFormat.dynamic_range : null } {selectedSubtitles.length > 0 ? `• ESUB` : null} • {selectedFormat?.filesize_approx ? formatFileSize(selectedFormat?.filesize_approx) : 'unknown filesize'}</span>
|
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -606,7 +923,7 @@ export default function DownloaderPage() {
|
|||||||
if (videoMetadata._type === 'playlist') {
|
if (videoMetadata._type === 'playlist') {
|
||||||
await startDownload(
|
await startDownload(
|
||||||
videoMetadata.original_url,
|
videoMetadata.original_url,
|
||||||
selctedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selctedDownloadFormat,
|
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
|
||||||
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
|
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
|
||||||
undefined,
|
undefined,
|
||||||
selectedPlaylistVideoIndex
|
selectedPlaylistVideoIndex
|
||||||
@@ -614,7 +931,7 @@ export default function DownloaderPage() {
|
|||||||
} else if (videoMetadata._type === 'video') {
|
} else if (videoMetadata._type === 'video') {
|
||||||
await startDownload(
|
await startDownload(
|
||||||
videoMetadata.webpage_url,
|
videoMetadata.webpage_url,
|
||||||
selctedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selctedDownloadFormat,
|
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
|
||||||
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
|
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -633,12 +950,12 @@ export default function DownloaderPage() {
|
|||||||
setIsStartingDownload(false);
|
setIsStartingDownload(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isStartingDownload}
|
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat))}
|
||||||
>
|
>
|
||||||
{isStartingDownload ? (
|
{isStartingDownload ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
Starting
|
Starting Download
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Start Download'
|
'Start Download'
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { SlidingButton } from "@/components/custom/slidingButton";
|
|
||||||
import Heading from "@/components/heading";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export default function ExtensionPage() {
|
|
||||||
const { toast } = useToast();
|
|
||||||
const openLink = async (url: string, app: string | null) => {
|
|
||||||
try {
|
|
||||||
await invoke('open_file_with_app', { filePath: url, appName: app }).then(() => {
|
|
||||||
toast({
|
|
||||||
title: 'Opening Link',
|
|
||||||
description: `Opening link with ${app ? app : 'default app'}.`,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
title: 'Failed to open link',
|
|
||||||
description: 'An error occurred while trying to open the link.',
|
|
||||||
variant: "destructive"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto p-4 space-y-4">
|
|
||||||
<Heading title="Extension" description="Integrate NeoDLP with your favourite browser" />
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<SlidingButton
|
|
||||||
slidingContent={
|
|
||||||
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
<span>Get Now</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'chrome')}
|
|
||||||
>
|
|
||||||
<span className="font-semibold flex items-center gap-2">
|
|
||||||
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<path d="M0 256C0 209.4 12.5 165.6 34.3 127.1L144.1 318.3C166 357.5 207.9 384 256 384C270.3 384 283.1 381.7 296.8 377.4L220.5 509.6C95.9 492.3 0 385.3 0 256zM365.1 321.6C377.4 302.4 384 279.1 384 256C384 217.8 367.2 183.5 340.7 160H493.4C505.4 189.6 512 222.1 512 256C512 397.4 397.4 511.1 256 512L365.1 321.6zM477.8 128H256C193.1 128 142.3 172.1 130.5 230.7L54.2 98.5C101 38.5 174 0 256 0C350.8 0 433.5 51.5 477.8 128V128zM168 256C168 207.4 207.4 168 256 168C304.6 168 344 207.4 344 256C344 304.6 304.6 344 256 344C207.4 344 168 304.6 168 256z"/>
|
|
||||||
</svg>
|
|
||||||
Get Chrome Extension
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">from Chrome Web Store</span>
|
|
||||||
</SlidingButton>
|
|
||||||
<SlidingButton
|
|
||||||
slidingContent={
|
|
||||||
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
<span>Get Now</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'firefox')}
|
|
||||||
>
|
|
||||||
<span className="font-semibold flex items-center gap-2">
|
|
||||||
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
||||||
<path d="M130.2 127.5C130.4 127.6 130.3 127.6 130.2 127.5V127.5zM481.6 172.9C471 147.4 449.6 119.9 432.7 111.2C446.4 138.1 454.4 165 457.4 185.2C457.4 185.3 457.4 185.4 457.5 185.6C429.9 116.8 383.1 89.1 344.9 28.7C329.9 5.1 334 3.5 331.8 4.1L331.7 4.2C285 30.1 256.4 82.5 249.1 126.9C232.5 127.8 216.2 131.9 201.2 139C199.8 139.6 198.7 140.7 198.1 142C197.4 143.4 197.2 144.9 197.5 146.3C197.7 147.2 198.1 148 198.6 148.6C199.1 149.3 199.8 149.9 200.5 150.3C201.3 150.7 202.1 151 203 151.1C203.8 151.1 204.7 151 205.5 150.8L206 150.6C221.5 143.3 238.4 139.4 255.5 139.2C318.4 138.7 352.7 183.3 363.2 201.5C350.2 192.4 326.8 183.3 304.3 187.2C392.1 231.1 368.5 381.8 247 376.4C187.5 373.8 149.9 325.5 146.4 285.6C146.4 285.6 157.7 243.7 227 243.7C234.5 243.7 256 222.8 256.4 216.7C256.3 214.7 213.8 197.8 197.3 181.5C188.4 172.8 184.2 168.6 180.5 165.5C178.5 163.8 176.4 162.2 174.2 160.7C168.6 141.2 168.4 120.6 173.5 101.1C148.5 112.5 129 130.5 114.8 146.4H114.7C105 134.2 105.7 93.8 106.3 85.3C106.1 84.8 99 89 98.1 89.7C89.5 95.7 81.6 102.6 74.3 110.1C58 126.7 30.1 160.2 18.8 211.3C14.2 231.7 12 255.7 12 263.6C12 398.3 121.2 507.5 255.9 507.5C376.6 507.5 478.9 420.3 496.4 304.9C507.9 228.2 481.6 173.8 481.6 172.9z"/>
|
|
||||||
</svg>
|
|
||||||
Get Firefox Extension
|
|
||||||
</span>
|
|
||||||
<span className="text-xs">from Mozilla Addons Store</span>
|
|
||||||
</SlidingButton>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'msedge')}>Edge</Button>
|
|
||||||
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'opera')}>Opera</Button>
|
|
||||||
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'brave')}>Brave</Button>
|
|
||||||
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'arc')}>Arc</Button>
|
|
||||||
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'zen')}>Zen</Button>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed befor clicking the link</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,9 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useAppContext } from "@/providers/appContextProvider";
|
import { useAppContext } from "@/providers/appContextProvider";
|
||||||
import { useDownloadActionStatesStore, useDownloadStatesStore } from "@/services/store";
|
import { useDownloadActionStatesStore, useDownloadStatesStore, useLibraryPageStatesStore } from "@/services/store";
|
||||||
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
|
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
|
||||||
import { AudioLines, Clock, CloudDownload, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Loader2, Music, PackageCheck, Pause, Play, Trash2, Video, X } from "lucide-react";
|
import { AudioLines, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Loader2, Music, Pause, Play, Square, Trash2, Video, X } from "lucide-react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import * as fs from "@tauri-apps/plugin-fs";
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
import { DownloadState } from "@/types/download";
|
import { DownloadState } from "@/types/download";
|
||||||
@@ -18,9 +18,14 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import Heading from "@/components/heading";
|
import Heading from "@/components/heading";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
|
||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
|
const activeTab = useLibraryPageStatesStore(state => state.activeTab);
|
||||||
|
const setActiveTab = useLibraryPageStatesStore(state => state.setActiveTab);
|
||||||
|
|
||||||
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
|
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
|
||||||
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
|
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
|
||||||
const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload);
|
const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload);
|
||||||
@@ -36,6 +41,9 @@ export default function LibraryPage() {
|
|||||||
|
|
||||||
const incompleteDownloads = downloadStates.filter(state => state.download_status !== 'completed');
|
const incompleteDownloads = downloadStates.filter(state => state.download_status !== 'completed');
|
||||||
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed');
|
const completedDownloads = downloadStates.filter(state => state.download_status === 'completed');
|
||||||
|
const ongoingDownloads = downloadStates.filter(state =>
|
||||||
|
['starting', 'downloading', 'queued'].includes(state.download_status)
|
||||||
|
);
|
||||||
|
|
||||||
const openFile = async (filePath: string | null, app: string | null) => {
|
const openFile = async (filePath: string | null, app: string | null) => {
|
||||||
if (filePath && await fs.exists(filePath)) {
|
if (filePath && await fs.exists(filePath)) {
|
||||||
@@ -96,329 +104,394 @@ export default function LibraryPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopOngoingDownloads = async () => {
|
||||||
|
if (ongoingDownloads.length > 0) {
|
||||||
|
for (const state of ongoingDownloads) {
|
||||||
|
setIsPausingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await pauseDownload(state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to stop download',
|
||||||
|
description: `An error occurred while trying to stop the download for ${state.title}.`,
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsPausingDownload(state.download_id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ongoingDownloads.length === 0) {
|
||||||
|
toast({
|
||||||
|
title: 'Stopped ongoing downloads',
|
||||||
|
description: 'All ongoing downloads have been stopped successfully.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'No ongoing downloads',
|
||||||
|
description: 'There are no ongoing downloads to stop.',
|
||||||
|
variant: "destructive"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4 space-y-4">
|
<div className="container mx-auto p-4 space-y-4">
|
||||||
<Heading title="Library" description="Manage all your downloads in one place" />
|
<Heading title="Library" description="Manage all your downloads in one place" />
|
||||||
<div className="w-full fle flex-col">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<div className="flex w-full items-center gap-3 mb-2">
|
<div className="w-full flex items-center justify-between mb-4">
|
||||||
<CloudDownload className="size-5" />
|
<TabsList>
|
||||||
<h3 className="text-nowrap font-semibold">Incomplete Downloads</h3>
|
<TabsTrigger value="completed">Completed {completedDownloads.length > 0 && (`(${completedDownloads.length})`)}</TabsTrigger>
|
||||||
|
<TabsTrigger value="incomplete">Incomplete {(incompleteDownloads.length > 0 && ongoingDownloads.length <= 0) && (`(${incompleteDownloads.length})`)} {ongoingDownloads.length > 0 && (<Badge className="h-4 min-w-4 rounded-full px-1 font-mono tabular-nums ml-1">{ongoingDownloads.length}</Badge>)}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={ongoingDownloads.length <= 0}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Stop all ongoing downloads?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to stop all ongoing downloads? This will pause all downloads including the download queue.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => stopOngoingDownloads()}
|
||||||
|
>Stop</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="horizontal" className="" />
|
<TabsContent value="completed">
|
||||||
</div>
|
<div className="w-full flex flex-col gap-2">
|
||||||
<div className="w-full flex flex-col gap-2">
|
{completedDownloads.length > 0 ? (
|
||||||
{incompleteDownloads.length > 0 ? (
|
completedDownloads.map((state) => {
|
||||||
incompleteDownloads.map((state) => {
|
const itemActionStates = downloadActions[state.download_id] || {
|
||||||
const itemActionStates = downloadActions[state.download_id] || {
|
isResuming: false,
|
||||||
isResuming: false,
|
isPausing: false,
|
||||||
isPausing: false,
|
isCanceling: false,
|
||||||
isCanceling: false,
|
isDeleteFileChecked: false,
|
||||||
isDeleteFileChecked: false,
|
};
|
||||||
};
|
return (
|
||||||
return (
|
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
||||||
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
<div className="w-[30%] flex flex-col justify-between gap-2">
|
||||||
<div className="w-[30%] flex flex-col justify-between gap-2">
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
</AspectRatio>
|
||||||
</AspectRatio>
|
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||||
{state.ext && (
|
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
<Video className="w-4 h-4 mr-2" />
|
||||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
)}
|
||||||
<Video className="w-4 h-4 mr-2" />
|
{state.filetype && state.filetype === 'audio' && (
|
||||||
)}
|
<Music className="w-4 h-4 mr-2" />
|
||||||
{state.filetype && state.filetype === 'audio' && (
|
)}
|
||||||
<Music className="w-4 h-4 mr-2" />
|
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||||
)}
|
<File className="w-4 h-4 mr-2" />
|
||||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
)}
|
||||||
<File className="w-4 h-4 mr-2" />
|
{state.ext?.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
|
||||||
)}
|
</span>
|
||||||
{state.ext.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
|
</div>
|
||||||
</span>
|
<div className="w-full flex flex-col justify-between gap-2">
|
||||||
)}
|
<div className="flex flex-col gap-1">
|
||||||
</div>
|
<h4 className="">{state.title}</h4>
|
||||||
<div className="w-full flex flex-col justify-between">
|
<p className="text-xs text-muted-foreground">{state.channel ? state.channel : 'unknown'} {state.host ? `• ${state.host}` : 'unknown'}</p>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center mt-1">
|
||||||
<h4>{state.title}</h4>
|
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</span>
|
||||||
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
|
<Separator orientation="vertical" />
|
||||||
<IndeterminateProgress indeterminate={true} className="w-full" />
|
<span className="text-xs text-muted-foreground flex items-center px-3">
|
||||||
)}
|
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||||
{(state.download_status === 'downloading' || state.download_status === 'paused') && state.progress && state.status !== 'finished' && (
|
<FileVideo2 className="w-4 h-4 mr-2"/>
|
||||||
<div className="w-full flex items-center gap-2">
|
)}
|
||||||
<span className="text-sm text-nowrap">{state.progress}%</span>
|
{state.filetype && state.filetype === 'audio' && (
|
||||||
<Progress value={state.progress} />
|
<FileAudio2 className="w-4 h-4 mr-2" />
|
||||||
<span className="text-sm text-nowrap">{
|
)}
|
||||||
state.downloaded && state.total
|
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||||
? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})`
|
<FileQuestion className="w-4 h-4 mr-2" />
|
||||||
: null
|
)}
|
||||||
}</span>
|
{state.filesize ? formatFileSize(state.filesize) : 'unknown'}
|
||||||
|
</span>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center pl-3"><AudioLines className="w-4 h-4 mr-2"/>
|
||||||
|
{state.vbr && state.abr ? (
|
||||||
|
formatBitrate(state.vbr + state.abr)
|
||||||
|
) : state.vbr ? (
|
||||||
|
formatBitrate(state.vbr)
|
||||||
|
) : state.abr ? (
|
||||||
|
formatBitrate(state.abr)
|
||||||
|
) : (
|
||||||
|
'unknown'
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs">
|
||||||
|
{state.playlist_id && state.playlist_index && (
|
||||||
|
<span
|
||||||
|
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer"
|
||||||
|
title={`${state.playlist_title ?? 'UNKNOWN PLAYLIST'}` + ' by ' + `${state.playlist_channel ?? 'UNKNOWN CHANNEL'}`}
|
||||||
|
>
|
||||||
|
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_index} of {state.playlist_n_entries})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.vcodec && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
|
||||||
|
)}
|
||||||
|
{state.acodec && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
|
||||||
|
)}
|
||||||
|
{state.dynamic_range && state.dynamic_range !== 'SDR' && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
|
||||||
|
)}
|
||||||
|
{state.subtitle_id && (
|
||||||
|
<span
|
||||||
|
className="border border-border py-1 px-2 rounded cursor-pointer"
|
||||||
|
title={`EMBEDED SUBTITLE (${state.subtitle_id})`}
|
||||||
|
>
|
||||||
|
ESUB
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="w-full flex items-center gap-2">
|
||||||
<div className="text-xs text-muted-foreground">{ state.download_status && (
|
<Button size="sm" onClick={() => openFile(state.filepath, null)}>
|
||||||
`${state.download_status === 'downloading' && state.status === 'finished' ? 'Processing' : state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)} ${state.download_status === 'downloading' && state.status !== 'finished' && state.speed ? `• Speed: ${formatSpeed(state.speed)}` : ""} ${state.download_status === 'downloading' && state.eta ? `• ETA: ${formatSecToTimeString(state.eta)}` : ""}`
|
<Play className="w-4 h-4" />
|
||||||
)}</div>
|
Open
|
||||||
</div>
|
|
||||||
<div className="w-full flex items-center gap-2 mt-2">
|
|
||||||
{state.download_status === 'paused' ? (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-fill"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsResumingDownload(state.download_id, true);
|
|
||||||
try {
|
|
||||||
await resumeDownload(state)
|
|
||||||
// toast({
|
|
||||||
// title: 'Resumed Download',
|
|
||||||
// description: 'Download resumed, it will re-start shortly.',
|
|
||||||
// })
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
title: 'Failed to Resume Download',
|
|
||||||
description: 'An error occurred while trying to resume the download.',
|
|
||||||
variant: "destructive"
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsResumingDownload(state.download_id, false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
|
|
||||||
>
|
|
||||||
{itemActionStates.isResuming ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Resuming
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
Resume
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-fill"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsPausingDownload(state.download_id, true);
|
|
||||||
try {
|
|
||||||
await pauseDownload(state)
|
|
||||||
// toast({
|
|
||||||
// title: 'Paused Download',
|
|
||||||
// description: 'Download paused successfully.',
|
|
||||||
// })
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
title: 'Failed to Pause Download',
|
|
||||||
description: 'An error occurred while trying to pause the download.',
|
|
||||||
variant: "destructive"
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsPausingDownload(state.download_id, false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={itemActionStates.isPausing || itemActionStates.isCanceling || state.download_status !== 'downloading' || (state.download_status === 'downloading' && state.status === 'finished')}
|
|
||||||
>
|
|
||||||
{itemActionStates.isPausing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Pausing
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Pause className="w-4 h-4" />
|
|
||||||
Pause
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsCancelingDownload(state.download_id, true);
|
|
||||||
try {
|
|
||||||
await cancelDownload(state)
|
|
||||||
toast({
|
|
||||||
title: 'Canceled Download',
|
|
||||||
description: 'Download canceled successfully.',
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
title: 'Failed to Cancel Download',
|
|
||||||
description: 'An error occurred while trying to cancel the download.',
|
|
||||||
variant: "destructive"
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsCancelingDownload(state.download_id, false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={itemActionStates.isCanceling || itemActionStates.isResuming || itemActionStates.isPausing || state.download_status === 'starting' || (state.download_status === 'downloading' && state.status === 'finished')}
|
|
||||||
>
|
|
||||||
{itemActionStates.isCanceling ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Canceling
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
Cancel
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="w-full flex items-center justify-center text-muted-foreground text-sm">No Incomplete downloads!</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="w-full fle flex-col">
|
|
||||||
<div className="flex w-full items-center gap-3 mb-2">
|
|
||||||
<PackageCheck className="size-5" />
|
|
||||||
<h3 className="text-nowrap font-semibold">Completed Downloads</h3>
|
|
||||||
</div>
|
|
||||||
<Separator orientation="horizontal" className="" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col gap-2">
|
|
||||||
{completedDownloads.length > 0 ? (
|
|
||||||
completedDownloads.map((state) => {
|
|
||||||
const itemActionStates = downloadActions[state.download_id] || {
|
|
||||||
isResuming: false,
|
|
||||||
isPausing: false,
|
|
||||||
isCanceling: false,
|
|
||||||
isDeleteFileChecked: false,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
|
||||||
<div className="w-[30%] flex flex-col justify-between gap-2">
|
|
||||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
|
||||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
|
||||||
</AspectRatio>
|
|
||||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
|
||||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
|
||||||
<Video className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{state.filetype && state.filetype === 'audio' && (
|
|
||||||
<Music className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
|
||||||
<File className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{state.ext?.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex flex-col justify-between gap-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h4 className="">{state.title}</h4>
|
|
||||||
<p className="text-xs text-muted-foreground">{state.channel ? state.channel : 'unknown'} {state.host ? `• ${state.host}` : 'unknown'}</p>
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</span>
|
|
||||||
<Separator orientation="vertical" />
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center px-3">
|
|
||||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
|
||||||
<FileVideo2 className="w-4 h-4 mr-2"/>
|
|
||||||
)}
|
|
||||||
{state.filetype && state.filetype === 'audio' && (
|
|
||||||
<FileAudio2 className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
|
||||||
<FileQuestion className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{state.filesize ? formatFileSize(state.filesize) : 'unknown'}
|
|
||||||
</span>
|
|
||||||
<Separator orientation="vertical" />
|
|
||||||
<span className="text-xs text-muted-foreground flex items-center pl-3"><AudioLines className="w-4 h-4 mr-2"/>
|
|
||||||
{state.vbr && state.abr ? (
|
|
||||||
formatBitrate(state.vbr + state.abr)
|
|
||||||
) : state.vbr ? (
|
|
||||||
formatBitrate(state.vbr)
|
|
||||||
) : state.abr ? (
|
|
||||||
formatBitrate(state.abr)
|
|
||||||
) : (
|
|
||||||
'unknown'
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs">
|
|
||||||
{state.playlist_id && state.playlist_index && (
|
|
||||||
<span
|
|
||||||
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer"
|
|
||||||
title={`${state.playlist_title ?? 'UNKNOWN PLAYLIST'}` + ' by ' + `${state.playlist_channel ?? 'UNKNOWN CHANNEL'}`}
|
|
||||||
>
|
|
||||||
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_index} of {state.playlist_n_entries})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{state.vcodec && (
|
|
||||||
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
|
|
||||||
)}
|
|
||||||
{state.acodec && (
|
|
||||||
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
|
|
||||||
)}
|
|
||||||
{state.dynamic_range && state.dynamic_range !== 'SDR' && (
|
|
||||||
<span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
|
|
||||||
)}
|
|
||||||
{state.subtitle_id && (
|
|
||||||
<span
|
|
||||||
className="border border-border py-1 px-2 rounded cursor-pointer"
|
|
||||||
title={`EMBEDED SUBTITLE (${state.subtitle_id})`}
|
|
||||||
>
|
|
||||||
ESUB
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full flex items-center gap-2">
|
|
||||||
<Button size="sm" onClick={() => openFile(state.filepath, null)}>
|
|
||||||
<Play className="w-4 h-4" />
|
|
||||||
Open
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
|
|
||||||
<FolderInput className="w-4 h-4" />
|
|
||||||
Open in Explorer
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button size="sm" variant="destructive">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
Remove
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
|
||||||
<AlertDialogContent>
|
<FolderInput className="w-4 h-4" />
|
||||||
<AlertDialogHeader>
|
Open in Explorer
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
</Button>
|
||||||
<AlertDialogDescription>
|
<AlertDialog>
|
||||||
This action cannot be undone! it will permanently remove this from downloads.
|
<AlertDialogTrigger asChild>
|
||||||
</AlertDialogDescription>
|
<Button size="sm" variant="destructive">
|
||||||
<div className="flex items-center space-x-2">
|
<Trash2 className="w-4 h-4" />
|
||||||
<Checkbox id="delete-file" checked={itemActionStates.isDeleteFileChecked} onCheckedChange={() => {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} />
|
Remove
|
||||||
<Label htmlFor="delete-file">Delete the downloaded file</Label>
|
</Button>
|
||||||
</div>
|
</AlertDialogTrigger>
|
||||||
</AlertDialogHeader>
|
<AlertDialogContent>
|
||||||
<AlertDialogFooter>
|
<AlertDialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogTitle>Remove from library?</AlertDialogTitle>
|
||||||
<AlertDialogAction onClick={
|
<AlertDialogDescription>
|
||||||
() => removeFromDownloads(state, itemActionStates.isDeleteFileChecked).then(() => {
|
Are you sure you want to remove this download from the library? You can also delete the downloaded file by cheking the box below. This action cannot be undone.
|
||||||
setIsDeleteFileChecked(state.download_id, false);
|
</AlertDialogDescription>
|
||||||
})
|
<div className="flex items-center space-x-2">
|
||||||
}>Remove</AlertDialogAction>
|
<Checkbox id="delete-file" checked={itemActionStates.isDeleteFileChecked} onCheckedChange={() => {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} />
|
||||||
</AlertDialogFooter>
|
<Label htmlFor="delete-file">Delete the downloaded file</Label>
|
||||||
</AlertDialogContent>
|
</div>
|
||||||
</AlertDialog>
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={
|
||||||
|
() => removeFromDownloads(state, itemActionStates.isDeleteFileChecked).then(() => {
|
||||||
|
setIsDeleteFileChecked(state.download_id, false);
|
||||||
|
})
|
||||||
|
}>Remove</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col items-center gap-2 justify-center mt-27">
|
||||||
|
<h4 className="text-4xl font-bold text-muted-foreground/80 dark:text-muted">Nothing!</h4>
|
||||||
|
<p className="text-lg font-semibold text-muted-foreground/50">No Completed Downloads</p>
|
||||||
|
<p className="max-w-[50%] text-center text-xs text-muted-foreground/70">You have not completed any downloads yet. Complete downloading something to see here :)</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})
|
</div>
|
||||||
) : (
|
</TabsContent>
|
||||||
<div className="w-full flex items-center justify-center text-muted-foreground text-sm">No Completed downloads!</div>
|
<TabsContent value="incomplete">
|
||||||
)}
|
<div className="w-full flex flex-col gap-2">
|
||||||
</div>
|
{incompleteDownloads.length > 0 ? (
|
||||||
|
incompleteDownloads.map((state) => {
|
||||||
|
const itemActionStates = downloadActions[state.download_id] || {
|
||||||
|
isResuming: false,
|
||||||
|
isPausing: false,
|
||||||
|
isCanceling: false,
|
||||||
|
isDeleteFileChecked: false,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
||||||
|
<div className="w-[30%] flex flex-col justify-between gap-2">
|
||||||
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
|
</AspectRatio>
|
||||||
|
{state.ext && (
|
||||||
|
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||||
|
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||||
|
<Video className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{state.filetype && state.filetype === 'audio' && (
|
||||||
|
<Music className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||||
|
<File className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{state.ext.toUpperCase()} {state.resolution ? `(${state.resolution})` : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4>{state.title}</h4>
|
||||||
|
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
|
||||||
|
<IndeterminateProgress indeterminate={true} className="w-full" />
|
||||||
|
)}
|
||||||
|
{(state.download_status === 'downloading' || state.download_status === 'paused') && state.progress && state.status !== 'finished' && (
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
<span className="text-sm text-nowrap">{state.progress}%</span>
|
||||||
|
<Progress value={state.progress} />
|
||||||
|
<span className="text-sm text-nowrap">{
|
||||||
|
state.downloaded && state.total
|
||||||
|
? `(${formatFileSize(state.downloaded)} / ${formatFileSize(state.total)})`
|
||||||
|
: null
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground">{ state.download_status && (
|
||||||
|
`${state.download_status === 'downloading' && state.status === 'finished' ? 'Processing' : state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)} ${state.download_status === 'downloading' && state.status !== 'finished' && state.speed ? `• Speed: ${formatSpeed(state.speed)}` : ""} ${state.download_status === 'downloading' && state.eta ? `• ETA: ${formatSecToTimeString(state.eta)}` : ""}`
|
||||||
|
)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex items-center gap-2 mt-2">
|
||||||
|
{state.download_status === 'paused' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-fill"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsResumingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await resumeDownload(state)
|
||||||
|
// toast({
|
||||||
|
// title: 'Resumed Download',
|
||||||
|
// description: 'Download resumed, it will re-start shortly.',
|
||||||
|
// })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to Resume Download',
|
||||||
|
description: 'An error occurred while trying to resume the download.',
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsResumingDownload(state.download_id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
|
||||||
|
>
|
||||||
|
{itemActionStates.isResuming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Resuming
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
Resume
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-fill"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsPausingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await pauseDownload(state)
|
||||||
|
// toast({
|
||||||
|
// title: 'Paused Download',
|
||||||
|
// description: 'Download paused successfully.',
|
||||||
|
// })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to Pause Download',
|
||||||
|
description: 'An error occurred while trying to pause the download.',
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsPausingDownload(state.download_id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={itemActionStates.isPausing || itemActionStates.isCanceling || state.download_status !== 'downloading' || (state.download_status === 'downloading' && state.status === 'finished')}
|
||||||
|
>
|
||||||
|
{itemActionStates.isPausing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Pausing
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
Pause
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsCancelingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await cancelDownload(state)
|
||||||
|
toast({
|
||||||
|
title: 'Canceled Download',
|
||||||
|
description: 'Download canceled successfully.',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to Cancel Download',
|
||||||
|
description: 'An error occurred while trying to cancel the download.',
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsCancelingDownload(state.download_id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={itemActionStates.isCanceling || itemActionStates.isResuming || itemActionStates.isPausing || state.download_status === 'starting' || (state.download_status === 'downloading' && state.status === 'finished')}
|
||||||
|
>
|
||||||
|
{itemActionStates.isCanceling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Canceling
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Cancel
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col items-center gap-2 justify-center mt-27">
|
||||||
|
<h4 className="text-4xl font-bold text-muted-foreground/80 dark:text-muted">Nothing!</h4>
|
||||||
|
<p className="text-lg font-semibold text-muted-foreground/50">No Incomplete Downloads</p>
|
||||||
|
<p className="max-w-[50%] text-center text-xs text-muted-foreground/70">You have all caught up! Sit back and relax or just spin up a new download to see here :)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ExternalLink, FolderOpen, Loader2, LucideIcon, Monitor, Moon, Radio, RotateCcw, RotateCw, Sun, Terminal } from "lucide-react";
|
import { ArrowDownToLine, ArrowRight, BrushCleaning, EthernetPort, ExternalLink, FileVideo, Folder, FolderOpen, Info, Loader2, LucideIcon, Monitor, Moon, Radio, RotateCcw, RotateCw, Sun, Terminal, WandSparkles, Wifi, Wrench } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTheme } from "@/providers/themeProvider";
|
import { useTheme } from "@/providers/themeProvider";
|
||||||
@@ -22,11 +22,19 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
|||||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
|
import { SlidingButton } from "@/components/custom/slidingButton";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
|
import { join } from "@tauri-apps/api/path";
|
||||||
|
import { formatSpeed } from "@/utils";
|
||||||
|
|
||||||
const websocketPortSchema = z.object({
|
const websocketPortSchema = z.object({
|
||||||
port: z.coerce.number({
|
port: z.coerce.number<number>({
|
||||||
required_error: "Websocket Port is required",
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
||||||
invalid_type_error: "Websocket Port must be a valid number",
|
? "Websocket Port is required"
|
||||||
|
: "Websocket Port must be a valid number"
|
||||||
|
}).int({
|
||||||
|
message: "Websocket Port must be an integer"
|
||||||
}).min(50000, {
|
}).min(50000, {
|
||||||
message: "Websocket Port must be at least 50000"
|
message: "Websocket Port must be at least 50000"
|
||||||
}).max(60000, {
|
}).max(60000, {
|
||||||
@@ -35,7 +43,25 @@ const websocketPortSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const proxyUrlSchema = z.object({
|
const proxyUrlSchema = z.object({
|
||||||
url: z.string().min(1, { message: "Proxy URL is required" }).url({ message: "Invalid URL format" })
|
url: z.url({
|
||||||
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
||||||
|
? "Proxy URL is required"
|
||||||
|
: "Invalid URL format"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const rateLimitSchema = z.object({
|
||||||
|
rate_limit: z.coerce.number<number>({
|
||||||
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
||||||
|
? "Rate Limit is required"
|
||||||
|
: "Rate Limit must be a valid number"
|
||||||
|
}).int({
|
||||||
|
message: "Rate Limit must be an integer"
|
||||||
|
}).min(1024, {
|
||||||
|
message: "Rate Limit must be at least 1024 bytes/s (1 KB/s)"
|
||||||
|
}).max(104857600, {
|
||||||
|
message: "Rate Limit must be at most 104857600 bytes/s (100 MB/s)"
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -43,7 +69,11 @@ export default function SettingsPage() {
|
|||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
const activeTab = useSettingsPageStatesStore(state => state.activeTab);
|
const activeTab = useSettingsPageStatesStore(state => state.activeTab);
|
||||||
|
const activeSubAppTab = useSettingsPageStatesStore(state => state.activeSubAppTab);
|
||||||
|
const activeSubExtTab = useSettingsPageStatesStore(state => state.activeSubExtTab);
|
||||||
const setActiveTab = useSettingsPageStatesStore(state => state.setActiveTab);
|
const setActiveTab = useSettingsPageStatesStore(state => state.setActiveTab);
|
||||||
|
const setActiveSubAppTab = useSettingsPageStatesStore(state => state.setActiveSubAppTab);
|
||||||
|
const setActiveSubExtTab = useSettingsPageStatesStore(state => state.setActiveSubExtTab);
|
||||||
|
|
||||||
const isUsingDefaultSettings = useSettingsPageStatesStore(state => state.isUsingDefaultSettings);
|
const isUsingDefaultSettings = useSettingsPageStatesStore(state => state.isUsingDefaultSettings);
|
||||||
const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion);
|
const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion);
|
||||||
@@ -53,9 +83,19 @@ export default function SettingsPage() {
|
|||||||
const ytDlpAutoUpdate = useSettingsPageStatesStore(state => state.settings.ytdlp_auto_update);
|
const ytDlpAutoUpdate = useSettingsPageStatesStore(state => state.settings.ytdlp_auto_update);
|
||||||
const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
|
const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
|
||||||
const maxParallelDownloads = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
|
const maxParallelDownloads = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
|
||||||
|
const maxRetries = useSettingsPageStatesStore(state => state.settings.max_retries);
|
||||||
const preferVideoOverPlaylist = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
|
const preferVideoOverPlaylist = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
|
||||||
|
const strictDownloadabilityCheck = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check);
|
||||||
const useProxy = useSettingsPageStatesStore(state => state.settings.use_proxy);
|
const useProxy = useSettingsPageStatesStore(state => state.settings.use_proxy);
|
||||||
const proxyUrl = useSettingsPageStatesStore(state => state.settings.proxy_url);
|
const proxyUrl = useSettingsPageStatesStore(state => state.settings.proxy_url);
|
||||||
|
const useRateLimit = useSettingsPageStatesStore(state => state.settings.use_rate_limit);
|
||||||
|
const rateLimit = useSettingsPageStatesStore(state => state.settings.rate_limit);
|
||||||
|
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
|
||||||
|
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
|
||||||
|
const alwaysReencodeVideo = useSettingsPageStatesStore(state => state.settings.always_reencode_video);
|
||||||
|
const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
|
||||||
|
const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
|
||||||
|
const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
|
||||||
const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port);
|
const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port);
|
||||||
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
|
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
|
||||||
const setIsChangingWebSocketPort = useSettingsPageStatesStore(state => state.setIsChangingWebSocketPort);
|
const setIsChangingWebSocketPort = useSettingsPageStatesStore(state => state.setIsChangingWebSocketPort);
|
||||||
@@ -68,6 +108,7 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
|
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
|
||||||
|
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
|
||||||
const setPath = useBasePathsStore((state) => state.setPath);
|
const setPath = useBasePathsStore((state) => state.setPath);
|
||||||
const { saveSettingsKey, resetSettings } = useSettings();
|
const { saveSettingsKey, resetSettings } = useSettings();
|
||||||
const { updateYtDlp } = useYtDlpUpdater();
|
const { updateYtDlp } = useYtDlpUpdater();
|
||||||
@@ -79,6 +120,53 @@ export default function SettingsPage() {
|
|||||||
{ value: 'system', icon: Monitor, label: 'System' },
|
{ value: 'system', icon: Monitor, label: 'System' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const openLink = async (url: string, app: string | null) => {
|
||||||
|
try {
|
||||||
|
await invoke('open_file_with_app', { filePath: url, appName: app }).then(() => {
|
||||||
|
toast({
|
||||||
|
title: 'Opening Link',
|
||||||
|
description: `Opening link with ${app ? app : 'default app'}.`,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
title: 'Failed to open link',
|
||||||
|
description: 'An error occurred while trying to open the link.',
|
||||||
|
variant: "destructive"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanTemporaryDownloads = async () => {
|
||||||
|
const tempFiles = await fs.readDir(tempDownloadDirPath ?? '');
|
||||||
|
if (tempFiles.length > 0) {
|
||||||
|
try {
|
||||||
|
for (const file of tempFiles) {
|
||||||
|
if (file.isFile) {
|
||||||
|
const filePath = await join(tempDownloadDirPath ?? '', file.name);
|
||||||
|
await fs.remove(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: "Temporary Downloads Cleaned",
|
||||||
|
description: "All temporary downloads have been successfully cleaned up.",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Temporary Downloads Cleanup Failed",
|
||||||
|
description: "An error occurred while trying to clean up temporary downloads. Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "No Temporary Downloads",
|
||||||
|
description: "There are no temporary downloads to clean up.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const proxyUrlForm = useForm<z.infer<typeof proxyUrlSchema>>({
|
const proxyUrlForm = useForm<z.infer<typeof proxyUrlSchema>>({
|
||||||
resolver: zodResolver(proxyUrlSchema),
|
resolver: zodResolver(proxyUrlSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -106,6 +194,33 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rateLimitForm = useForm<z.infer<typeof rateLimitSchema>>({
|
||||||
|
resolver: zodResolver(rateLimitSchema),
|
||||||
|
defaultValues: {
|
||||||
|
rate_limit: rateLimit,
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const watchedRateLimit = rateLimitForm.watch("rate_limit");
|
||||||
|
const { errors: rateLimitFormErrors } = rateLimitForm.formState;
|
||||||
|
|
||||||
|
function handleRateLimitSubmit(values: z.infer<typeof rateLimitSchema>) {
|
||||||
|
try {
|
||||||
|
saveSettingsKey('rate_limit', values.rate_limit);
|
||||||
|
toast({
|
||||||
|
title: "Rate Limit updated",
|
||||||
|
description: `Rate Limit changed to ${values.rate_limit} bytes/s`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error changing rate limit:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to change rate limit",
|
||||||
|
description: "Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface Config {
|
interface Config {
|
||||||
port: number;
|
port: number;
|
||||||
}
|
}
|
||||||
@@ -157,11 +272,40 @@ export default function SettingsPage() {
|
|||||||
<div className="container mx-auto p-4 space-y-4 min-h-screen">
|
<div className="container mx-auto p-4 space-y-4 min-h-screen">
|
||||||
<Heading title="Settings" description="Manage your preferences and app settings" />
|
<Heading title="Settings" description="Manage your preferences and app settings" />
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList>
|
<div className="w-full flex items-center justify-between">
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsList>
|
||||||
<TabsTrigger value="extension">Extension</TabsTrigger>
|
<TabsTrigger value="app">Application</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="extension">Extension</TabsTrigger>
|
||||||
<TabsContent value="general">
|
</TabsList>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-fit"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={isUsingDefaultSettings}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Reset settings to default?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to reset all settings to their default values? This action cannot be undone!
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={
|
||||||
|
() => resetSettings()
|
||||||
|
}>Reset</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="app">
|
||||||
<Card className="p-4 space-y-4 my-4">
|
<Card className="p-4 space-y-4 my-4">
|
||||||
<div className="w-full flex gap-4 items-center justify-between">
|
<div className="w-full flex gap-4 items-center justify-between">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
@@ -220,124 +364,356 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col w-[50%] gap-4">
|
<Tabs
|
||||||
<div className="app-theme">
|
className="w-full flex flex-row items-start gap-4 mt-7"
|
||||||
<h3 className="font-semibold">Theme</h3>
|
orientation="vertical"
|
||||||
<p className="text-sm text-muted-foreground mb-3">Choose app interface theme</p>
|
value={activeSubAppTab}
|
||||||
<div className={cn('inline-flex gap-1 rounded-lg p-1 bg-muted')}>
|
onValueChange={setActiveSubAppTab}
|
||||||
{themeOptions.map(({ value, icon: Icon, label }) => (
|
>
|
||||||
<button
|
<TabsList className="shrink-0 grid grid-cols-1 gap-1 p-0 bg-background min-w-45">
|
||||||
key={value}
|
<TabsTrigger
|
||||||
onClick={() => saveSettingsKey('theme', value)}
|
key="general"
|
||||||
className={cn(
|
value="general"
|
||||||
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
appTheme === value
|
><Wrench className="size-4" /> General</TabsTrigger>
|
||||||
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
|
<TabsTrigger
|
||||||
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
key="appearance"
|
||||||
)}
|
value="appearance"
|
||||||
>
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
<Icon className="-ml-1 h-4 w-4" />
|
><WandSparkles className="size-4" /> Appearance</TabsTrigger>
|
||||||
<span className="ml-1.5 text-sm">{label}</span>
|
<TabsTrigger
|
||||||
</button>
|
key="folders"
|
||||||
))}
|
value="folders"
|
||||||
</div>
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
</div>
|
><Folder className="size-4" /> Folders</TabsTrigger>
|
||||||
<div className="download-dir">
|
<TabsTrigger
|
||||||
<h3 className="font-semibold">Download Directory</h3>
|
key="formats"
|
||||||
<p className="text-sm text-muted-foreground mb-3">Set default download directory</p>
|
value="formats"
|
||||||
<div className="flex items-center gap-4">
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
<Input className="focus-visible:ring-0" type="text" placeholder="Select download directory" value={downloadDirPath ?? 'Unknown'} readOnly/>
|
><FileVideo className="size-4" /> Formats</TabsTrigger>
|
||||||
<Button
|
<TabsTrigger
|
||||||
variant="outline"
|
key="metadata"
|
||||||
onClick={async () => {
|
value="metadata"
|
||||||
try {
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
const folder = await open({
|
><Info className="size-4" /> Metadata</TabsTrigger>
|
||||||
multiple: false,
|
<TabsTrigger
|
||||||
directory: true,
|
key="network"
|
||||||
});
|
value="network"
|
||||||
if (folder) {
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
saveSettingsKey('download_dir', folder);
|
><Wifi className="size-4" /> Network</TabsTrigger>
|
||||||
setPath('downloadDirPath', folder);
|
</TabsList>
|
||||||
}
|
<div className="min-h-full flex flex-col max-w-[55%] w-full border-l border-border pl-4">
|
||||||
} catch (error) {
|
<TabsContent key="general" value="general" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
console.error("Error selecting folder:", error);
|
<div className="max-parallel-downloads">
|
||||||
toast({
|
<h3 className="font-semibold">Max Parallel Downloads</h3>
|
||||||
title: "Failed to select folder",
|
<p className="text-xs text-muted-foreground mb-3">Set maximum number of allowed parallel downloads</p>
|
||||||
description: "Please try again.",
|
<Slider
|
||||||
variant: "destructive",
|
id="max-parallel-downloads"
|
||||||
});
|
className="w-[350px] mb-2"
|
||||||
}
|
value={[maxParallelDownloads]}
|
||||||
}}
|
min={1}
|
||||||
>
|
max={5}
|
||||||
<FolderOpen className="w-4 h-4" /> Browse
|
onValueChange={(value) => saveSettingsKey('max_parallel_downloads', value[0])}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
<Label htmlFor="max-parallel-downloads" className="text-xs text-muted-foreground">(Current: {maxParallelDownloads}) (Default: 2, Maximum: 5)</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-parallel-downloads">
|
<div className="prefer-video-over-playlist">
|
||||||
<h3 className="font-semibold">Max Parallel Downloads</h3>
|
<h3 className="font-semibold">Prefer Video Over Playlist</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-3">Set maximum number of allowed parallel downloads</p>
|
<p className="text-xs text-muted-foreground mb-3">Prefer only the video, if the URL refers to a video and a playlist</p>
|
||||||
<Slider
|
<Switch
|
||||||
id="max-parallel-downloads"
|
id="prefer-video-over-playlist"
|
||||||
className="w-[350px] mb-2"
|
checked={preferVideoOverPlaylist}
|
||||||
value={[maxParallelDownloads]}
|
onCheckedChange={(checked) => saveSettingsKey('prefer_video_over_playlist', checked)}
|
||||||
min={1}
|
/>
|
||||||
max={5}
|
</div>
|
||||||
onValueChange={(value) => saveSettingsKey('max_parallel_downloads', value[0])}
|
<div className="strict-downloadability-check">
|
||||||
/>
|
<h3 className="font-semibold">Strict Downloadablity Check</h3>
|
||||||
<Label htmlFor="max-parallel-downloads" className="text-xs text-muted-foreground">(Current: {maxParallelDownloads}) (Default: 2, Maximum: 5)</Label>
|
<p className="text-xs text-muted-foreground mb-3">Only show streams that are actualy downloadable, also check formats before downloading (high quality results, takes longer time to search)</p>
|
||||||
</div>
|
<Switch
|
||||||
<div className="prefer-video-over-playlist">
|
id="strict-downloadablity-check"
|
||||||
<h3 className="font-semibold">Prefer Video Over Playlist</h3>
|
checked={strictDownloadabilityCheck}
|
||||||
<p className="text-sm text-muted-foreground mb-3">Prefer only the video, if the URL refers to a video and a playlist</p>
|
onCheckedChange={(checked) => saveSettingsKey('strict_downloadablity_check', checked)}
|
||||||
<Switch
|
/>
|
||||||
id="prefer-video-over-playlist"
|
</div>
|
||||||
checked={preferVideoOverPlaylist}
|
<div className="max-retries">
|
||||||
onCheckedChange={(checked) => saveSettingsKey('prefer_video_over_playlist', checked)}
|
<h3 className="font-semibold">Max Retries</h3>
|
||||||
/>
|
<p className="text-xs text-muted-foreground mb-3">Set maximum number of retries for a download before giving up</p>
|
||||||
</div>
|
<Slider
|
||||||
<div className="proxy">
|
id="max-retries"
|
||||||
<h3 className="font-semibold">Proxy</h3>
|
className="w-[350px] mb-2"
|
||||||
<p className="text-sm text-muted-foreground mb-3">Use proxy for downloads, Unblocks blocked sites in your region (Download speed may affect, Some sites may not work)</p>
|
value={[maxRetries]}
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
min={1}
|
||||||
<Switch
|
max={100}
|
||||||
id="use-proxy"
|
onValueChange={(value) => saveSettingsKey('max_retries', value[0])}
|
||||||
checked={useProxy}
|
/>
|
||||||
onCheckedChange={(checked) => saveSettingsKey('use_proxy', checked)}
|
<Label htmlFor="max-retries" className="text-xs text-muted-foreground">(Current: {maxRetries}) (Default: 5, Maximum: 100)</Label>
|
||||||
/>
|
</div>
|
||||||
<Label htmlFor="use-proxy">Use Proxy</Label>
|
</TabsContent>
|
||||||
</div>
|
<TabsContent key="appearance" value="appearance" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
<div className="flex items-center gap-4">
|
<div className="app-theme">
|
||||||
<Form {...proxyUrlForm}>
|
<h3 className="font-semibold">Theme</h3>
|
||||||
<form onSubmit={proxyUrlForm.handleSubmit(handleProxyUrlSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
<p className="text-xs text-muted-foreground mb-3">Choose app interface theme</p>
|
||||||
<FormField
|
<div className={cn('inline-flex gap-1 rounded-lg p-1 bg-muted')}>
|
||||||
control={proxyUrlForm.control}
|
{themeOptions.map(({ value, icon: Icon, label }) => (
|
||||||
name="url"
|
<button
|
||||||
disabled={!useProxy}
|
key={value}
|
||||||
render={({ field }) => (
|
onClick={() => saveSettingsKey('theme', value)}
|
||||||
<FormItem className="w-full">
|
className={cn(
|
||||||
<FormControl>
|
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
|
||||||
<Input
|
appTheme === value
|
||||||
className="focus-visible:ring-0"
|
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
|
||||||
placeholder="Enter proxy URL"
|
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
|
||||||
{...field}
|
)}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
<Icon className="-ml-1 h-4 w-4" />
|
||||||
<Label htmlFor="url" className="text-xs text-muted-foreground">(Configured: {proxyUrl ? 'Yes' : 'No'}, Status: {useProxy ? 'Enabled' : 'Disabled'})</Label>
|
<span className="ml-1.5 text-sm">{label}</span>
|
||||||
<FormMessage />
|
</button>
|
||||||
</FormItem>
|
))}
|
||||||
)}
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent key="folders" value="folders" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
|
<div className="download-dir">
|
||||||
|
<h3 className="font-semibold">Download Folder</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Set default download folder (directory)</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input className="focus-visible:ring-0" type="text" placeholder="Select download directory" value={downloadDirPath ?? 'Unknown'} readOnly/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
variant="outline"
|
||||||
disabled={!watchedProxyUrl || watchedProxyUrl === proxyUrl || Object.keys(proxyUrlFormErrors).length > 0 || !useProxy}
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const folder = await open({
|
||||||
|
multiple: false,
|
||||||
|
directory: true,
|
||||||
|
});
|
||||||
|
if (folder) {
|
||||||
|
saveSettingsKey('download_dir', folder);
|
||||||
|
setPath('downloadDirPath', folder);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error selecting folder:", error);
|
||||||
|
toast({
|
||||||
|
title: "Failed to select folder",
|
||||||
|
description: "Please try again.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Save
|
<FolderOpen className="w-4 h-4" /> Browse
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
</div>
|
<div className="temporary-download-dir">
|
||||||
|
<h3 className="font-semibold">Temporary Download Folder</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Clean up temporary downloads (broken, cancelled, paused downloads)</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Input className="focus-visible:ring-0" type="text" placeholder="Temporary download directory" value={tempDownloadDirPath ?? 'Unknown'} readOnly/>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={ongoingDownloads.length > 0}
|
||||||
|
>
|
||||||
|
<BrushCleaning className="size-4" /> Clean
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Clean up all temporary downloads?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Are you sure you want to clean up all temporary downloads? This will remove all broken, cancelled and paused downloads from the temporary folder. Paused downloads will re-start from the begining. This action cannot be undone!</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => cleanTemporaryDownloads()}
|
||||||
|
>Clean</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent key="formats" value="formats" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
|
<div className="video-format">
|
||||||
|
<h3 className="font-semibold">Video Format</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Choose in which format the final video file will be saved</p>
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
value={videoFormat}
|
||||||
|
onValueChange={(value) => saveSettingsKey('video_format', value)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="v-auto" />
|
||||||
|
<Label htmlFor="v-auto">Auto (Default)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mp4" id="v-mp4" />
|
||||||
|
<Label htmlFor="v-mp4">MP4</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="webm" id="v-webm" />
|
||||||
|
<Label htmlFor="v-webm">WEBM</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mkv" id="v-mkv" />
|
||||||
|
<Label htmlFor="v-mkv">MKV</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div className="audio-format">
|
||||||
|
<h3 className="font-semibold">Audio Format</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Choose in which format the final audio file will be saved</p>
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
value={audioFormat}
|
||||||
|
onValueChange={(value) => saveSettingsKey('audio_format', value)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="a-auto" />
|
||||||
|
<Label htmlFor="a-auto">Auto (Default)</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="m4a" id="a-m4a" />
|
||||||
|
<Label htmlFor="a-m4a">M4A</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="opus" id="a-opus" />
|
||||||
|
<Label htmlFor="a-opus">OPUS</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mp3" id="a-mp3" />
|
||||||
|
<Label htmlFor="a-mp3">MP3</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div className="always-reencode-video">
|
||||||
|
<h3 className="font-semibold">Always Re-Encode Video</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Instead of remuxing (simple container change) always re-encode the video to the target format with best compatible codecs (better compatibility, takes longer processing time)</p>
|
||||||
|
<Switch
|
||||||
|
id="always-reencode-video"
|
||||||
|
checked={alwaysReencodeVideo}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('always_reencode_video', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent key="metadata" value="metadata" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
|
<div className="embed-video-metadata">
|
||||||
|
<h3 className="font-semibold">Embed Metadata</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Wheather to embed metadata in video/audio files (info, chapters)</p>
|
||||||
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
|
<Switch
|
||||||
|
id="embed-video-metadata"
|
||||||
|
checked={embedVideoMetadata}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('embed_video_metadata', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="embed-video-metadata">Video</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="embed-audio-metadata"
|
||||||
|
checked={embedAudioMetadata}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('embed_audio_metadata', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="embed-audio-metadata">Audio</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="embed-audio-thumbnail">
|
||||||
|
<h3 className="font-semibold">Embed Thumbnail in Audio</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Wheather to embed thumbnail in audio files (as cover art)</p>
|
||||||
|
<Switch
|
||||||
|
id="embed-audio-thumbnail"
|
||||||
|
checked={embedAudioThumbnail}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('embed_audio_thumbnail', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent key="network" value="network" className="flex flex-col gap-4 min-h-[235px]">
|
||||||
|
<div className="proxy">
|
||||||
|
<h3 className="font-semibold">Proxy</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Use proxy for downloads, Unblocks blocked sites in your region (download speed may affect, some sites may not work)</p>
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<Switch
|
||||||
|
id="use-proxy"
|
||||||
|
checked={useProxy}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('use_proxy', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="use-proxy">Use Proxy</Label>
|
||||||
|
</div>
|
||||||
|
<Form {...proxyUrlForm}>
|
||||||
|
<form onSubmit={proxyUrlForm.handleSubmit(handleProxyUrlSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
||||||
|
<FormField
|
||||||
|
control={proxyUrlForm.control}
|
||||||
|
name="url"
|
||||||
|
disabled={!useProxy}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="focus-visible:ring-0"
|
||||||
|
placeholder="Enter proxy URL"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Label htmlFor="url" className="text-xs text-muted-foreground">(Configured: {proxyUrl ? 'Yes' : 'No'}, Status: {useProxy ? 'Enabled' : 'Disabled'})</Label>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!watchedProxyUrl || watchedProxyUrl === proxyUrl || Object.keys(proxyUrlFormErrors).length > 0 || !useProxy}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<div className="rate-limit">
|
||||||
|
<h3 className="font-semibold">Rate Limit</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Limit download speed to prevent network congestion. Rate limit is applied per-download basis (not in the whole app)</p>
|
||||||
|
<div className="flex items-center space-x-2 mb-4">
|
||||||
|
<Switch
|
||||||
|
id="use-rate-limit"
|
||||||
|
checked={useRateLimit}
|
||||||
|
onCheckedChange={(checked) => saveSettingsKey('use_rate_limit', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="use-rate-limit">Use Rate Limit</Label>
|
||||||
|
</div>
|
||||||
|
<Form {...rateLimitForm}>
|
||||||
|
<form onSubmit={rateLimitForm.handleSubmit(handleRateLimitSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
||||||
|
<FormField
|
||||||
|
control={rateLimitForm.control}
|
||||||
|
name="rate_limit"
|
||||||
|
disabled={!useRateLimit}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="focus-visible:ring-0"
|
||||||
|
placeholder="Enter rate limit in bytes/s"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Label htmlFor="rate_limit" className="text-xs text-muted-foreground">(Configured: {rateLimit ? `${rateLimit} = ${formatSpeed(rateLimit)}` : 'No'}, Status: {useRateLimit ? 'Enabled' : 'Disabled'}) (Default: 1048576, Range: 1024-104857600)</Label>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!watchedRateLimit || Number(watchedRateLimit) === rateLimit || Object.keys(rateLimitFormErrors).length > 0 || !useRateLimit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="extension">
|
<TabsContent value="extension">
|
||||||
<Card className="p-4 space-y-4 my-4">
|
<Card className="p-4 space-y-4 my-4">
|
||||||
@@ -348,7 +724,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h3 className="">Extension Websocket Server</h3>
|
<h3 className="">Extension Websocket Server</h3>
|
||||||
<p className="text-xs text-muted-foreground">{isChangingWebSocketPort || isRestartingWebSocketServer ? 'Restarting...' : 'Running' }</p>
|
<div className="text-xs flex items-center">
|
||||||
|
{isChangingWebSocketPort || isRestartingWebSocketServer ? (
|
||||||
|
<><div className="h-1.5 w-1.5 rounded-full bg-amber-600 dark:bg-amber-500 mr-1.5 mt-0.5" /><span className="text-amber-600 dark:text-amber-500">Restarting...</span></>
|
||||||
|
) : (
|
||||||
|
<><div className="h-1.5 w-1.5 rounded-full bg-emerald-600 dark:bg-emerald-500 mr-1.5 mt-0.5" /><span className="text-emerald-600 dark:text-emerald-500">Running</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
@@ -389,81 +771,122 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="flex flex-col w-[50%] gap-4">
|
<Tabs
|
||||||
<div className="websocket-port">
|
className="w-full flex flex-row items-start gap-4 mt-7"
|
||||||
<h3 className="font-semibold">Websocket Port</h3>
|
orientation="vertical"
|
||||||
<p className="text-sm text-muted-foreground mb-3">Change extension websocket server port</p>
|
value={activeSubExtTab}
|
||||||
<div className="flex items-center gap-4">
|
onValueChange={setActiveSubExtTab}
|
||||||
<Form {...websocketPortForm}>
|
>
|
||||||
<form onSubmit={websocketPortForm.handleSubmit(handleWebsocketPortSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
<TabsList className="shrink-0 grid grid-cols-1 gap-1 p-0 bg-background min-w-45">
|
||||||
<FormField
|
<TabsTrigger
|
||||||
control={websocketPortForm.control}
|
key="install"
|
||||||
name="port"
|
value="install"
|
||||||
disabled={isChangingWebSocketPort}
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
render={({ field }) => (
|
><ArrowDownToLine className="size-4" /> Install</TabsTrigger>
|
||||||
<FormItem className="w-full">
|
<TabsTrigger
|
||||||
<FormControl>
|
key="port"
|
||||||
<Input
|
value="port"
|
||||||
className="focus-visible:ring-0"
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
placeholder="Enter port number"
|
><EthernetPort className="size-4" /> Port</TabsTrigger>
|
||||||
{...field}
|
</TabsList>
|
||||||
/>
|
<div className="min-h-full flex flex-col w-full border-l border-border pl-4">
|
||||||
</FormControl>
|
<TabsContent key="install" value="install" className="flex flex-col gap-4 min-h-[150px] max-w-[90%]">
|
||||||
<Label htmlFor="port" className="text-xs text-muted-foreground">(Current: {websocketPort}) (Default: 53511, Range: 50000-60000)</Label>
|
<div className="install-neodlp-extension">
|
||||||
<FormMessage />
|
<h3 className="font-semibold">NeoDLP Extension</h3>
|
||||||
</FormItem>
|
<p className="text-xs text-muted-foreground mb-4">Integrate NeoDLP with your favourite browser</p>
|
||||||
)}
|
<div className="flex items-center gap-4 mb-4">
|
||||||
/>
|
<SlidingButton
|
||||||
<Button
|
slidingContent={
|
||||||
type="submit"
|
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
|
||||||
disabled={!watchedPort || Number(watchedPort) === websocketPort || Object.keys(websocketPortFormErrors).length > 0 || isChangingWebSocketPort || isRestartingWebSocketServer}
|
<ArrowRight className="size-4" />
|
||||||
>
|
<span>Get Now</span>
|
||||||
{isChangingWebSocketPort ? (
|
</div>
|
||||||
<>
|
}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'chrome')}
|
||||||
Changing
|
>
|
||||||
</>
|
<span className="font-semibold flex items-center gap-2">
|
||||||
) : (
|
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
'Change'
|
<path d="M0 256C0 209.4 12.5 165.6 34.3 127.1L144.1 318.3C166 357.5 207.9 384 256 384C270.3 384 283.1 381.7 296.8 377.4L220.5 509.6C95.9 492.3 0 385.3 0 256zM365.1 321.6C377.4 302.4 384 279.1 384 256C384 217.8 367.2 183.5 340.7 160H493.4C505.4 189.6 512 222.1 512 256C512 397.4 397.4 511.1 256 512L365.1 321.6zM477.8 128H256C193.1 128 142.3 172.1 130.5 230.7L54.2 98.5C101 38.5 174 0 256 0C350.8 0 433.5 51.5 477.8 128V128zM168 256C168 207.4 207.4 168 256 168C304.6 168 344 207.4 344 256C344 304.6 304.6 344 256 344C207.4 344 168 304.6 168 256z"/>
|
||||||
)}
|
</svg>
|
||||||
</Button>
|
Get Chrome Extension
|
||||||
</form>
|
</span>
|
||||||
</Form>
|
<span className="text-xs">from Chrome Web Store</span>
|
||||||
</div>
|
</SlidingButton>
|
||||||
|
<SlidingButton
|
||||||
|
slidingContent={
|
||||||
|
<div className="flex items-center justify-center gap-2 text-white dark:text-black">
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
<span>Get Now</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'firefox')}
|
||||||
|
>
|
||||||
|
<span className="font-semibold flex items-center gap-2">
|
||||||
|
<svg className="size-4 fill-white dark:fill-black" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<path d="M130.2 127.5C130.4 127.6 130.3 127.6 130.2 127.5V127.5zM481.6 172.9C471 147.4 449.6 119.9 432.7 111.2C446.4 138.1 454.4 165 457.4 185.2C457.4 185.3 457.4 185.4 457.5 185.6C429.9 116.8 383.1 89.1 344.9 28.7C329.9 5.1 334 3.5 331.8 4.1L331.7 4.2C285 30.1 256.4 82.5 249.1 126.9C232.5 127.8 216.2 131.9 201.2 139C199.8 139.6 198.7 140.7 198.1 142C197.4 143.4 197.2 144.9 197.5 146.3C197.7 147.2 198.1 148 198.6 148.6C199.1 149.3 199.8 149.9 200.5 150.3C201.3 150.7 202.1 151 203 151.1C203.8 151.1 204.7 151 205.5 150.8L206 150.6C221.5 143.3 238.4 139.4 255.5 139.2C318.4 138.7 352.7 183.3 363.2 201.5C350.2 192.4 326.8 183.3 304.3 187.2C392.1 231.1 368.5 381.8 247 376.4C187.5 373.8 149.9 325.5 146.4 285.6C146.4 285.6 157.7 243.7 227 243.7C234.5 243.7 256 222.8 256.4 216.7C256.3 214.7 213.8 197.8 197.3 181.5C188.4 172.8 184.2 168.6 180.5 165.5C178.5 163.8 176.4 162.2 174.2 160.7C168.6 141.2 168.4 120.6 173.5 101.1C148.5 112.5 129 130.5 114.8 146.4H114.7C105 134.2 105.7 93.8 106.3 85.3C106.1 84.8 99 89 98.1 89.7C89.5 95.7 81.6 102.6 74.3 110.1C58 126.7 30.1 160.2 18.8 211.3C14.2 231.7 12 255.7 12 263.6C12 398.3 121.2 507.5 255.9 507.5C376.6 507.5 478.9 420.3 496.4 304.9C507.9 228.2 481.6 173.8 481.6 172.9z"/>
|
||||||
|
</svg>
|
||||||
|
Get Firefox Extension
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">from Mozilla Addons Store</span>
|
||||||
|
</SlidingButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'msedge')}>Edge</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'opera')}>Opera</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'brave')}>Brave</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', 'arc')}>Arc</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', 'zen')}>Zen</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">* These links opens with coresponding browsers only. Make sure the browser is installed before clicking the link</p>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent key="port" value="port" className="flex flex-col gap-4 min-h-[150px] max-w-[70%]">
|
||||||
|
<div className="websocket-port">
|
||||||
|
<h3 className="font-semibold">Websocket Port</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">Change extension websocket server port</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Form {...websocketPortForm}>
|
||||||
|
<form onSubmit={websocketPortForm.handleSubmit(handleWebsocketPortSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
||||||
|
<FormField
|
||||||
|
control={websocketPortForm.control}
|
||||||
|
name="port"
|
||||||
|
disabled={isChangingWebSocketPort}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="focus-visible:ring-0"
|
||||||
|
placeholder="Enter port number"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Label htmlFor="port" className="text-xs text-muted-foreground">(Current: {websocketPort}) (Default: 53511, Range: 50000-60000)</Label>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!watchedPort || Number(watchedPort) === websocketPort || Object.keys(websocketPortFormErrors).length > 0 || isChangingWebSocketPort || isRestartingWebSocketServer}
|
||||||
|
>
|
||||||
|
{isChangingWebSocketPort ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Changing
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Change'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tabs>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="font-semibold">Reset Settings</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-3">Reset all setting to default</p>
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="w-fit"
|
|
||||||
variant="destructive"
|
|
||||||
disabled={isUsingDefaultSettings}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
|
||||||
Reset Default
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone! it will permanently reset all settings to default.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={
|
|
||||||
() => resetSettings()
|
|
||||||
}>Reset</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Puzzle, Settings, SquarePlay } from "lucide-react";
|
import { Download, Settings, SquarePlay } from "lucide-react";
|
||||||
import { RoutesObj } from "@/types/route";
|
import { RoutesObj } from "@/types/route";
|
||||||
|
|
||||||
export const AllRoutes: Array<RoutesObj> = [
|
export const AllRoutes: Array<RoutesObj> = [
|
||||||
@@ -12,11 +12,6 @@ export const AllRoutes: Array<RoutesObj> = [
|
|||||||
url: "/library",
|
url: "/library",
|
||||||
icon: SquarePlay,
|
icon: SquarePlay,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Extension",
|
|
||||||
url: "/extension",
|
|
||||||
icon: Puzzle,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, KvPairsStatesStore, SettingsPageStatesStore } from '@/types/store';
|
import { BasePathsStore, CurrentVideoMetadataStore, DownloadActionStatesStore, DownloaderPageStatesStore, DownloadStatesStore, KvPairsStatesStore, LibraryPageStatesStore, SettingsPageStatesStore } from '@/types/store';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
export const useBasePathsStore = create<BasePathsStore>((set) => ({
|
export const useBasePathsStore = create<BasePathsStore>((set) => ({
|
||||||
@@ -34,22 +34,43 @@ export const useCurrentVideoMetadataStore = create<CurrentVideoMetadataStore>((s
|
|||||||
isMetadataLoading: false,
|
isMetadataLoading: false,
|
||||||
requestedUrl: '',
|
requestedUrl: '',
|
||||||
autoSubmitSearch: false,
|
autoSubmitSearch: false,
|
||||||
|
searchPid: null,
|
||||||
|
showSearchError: true,
|
||||||
setVideoUrl: (url) => set(() => ({ videoUrl: url })),
|
setVideoUrl: (url) => set(() => ({ videoUrl: url })),
|
||||||
setVideoMetadata: (metadata) => set(() => ({ videoMetadata: metadata })),
|
setVideoMetadata: (metadata) => set(() => ({ videoMetadata: metadata })),
|
||||||
setIsMetadataLoading: (isLoading) => set(() => ({ isMetadataLoading: isLoading })),
|
setIsMetadataLoading: (isLoading) => set(() => ({ isMetadataLoading: isLoading })),
|
||||||
setRequestedUrl: (url) => set(() => ({ requestedUrl: url })),
|
setRequestedUrl: (url) => set(() => ({ requestedUrl: url })),
|
||||||
setAutoSubmitSearch: (autoSubmit) => set(() => ({ autoSubmitSearch: autoSubmit })),
|
setAutoSubmitSearch: (autoSubmit) => set(() => ({ autoSubmitSearch: autoSubmit })),
|
||||||
|
setSearchPid: (pid) => set(() => ({ searchPid: pid })),
|
||||||
|
setShowSearchError: (showError) => set(() => ({ showSearchError: showError }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({
|
export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({
|
||||||
|
activeDownloadModeTab: 'selective',
|
||||||
isStartingDownload: false,
|
isStartingDownload: false,
|
||||||
selctedDownloadFormat: 'best',
|
selectedDownloadFormat: 'best',
|
||||||
|
selectedCombinableVideoFormat: '',
|
||||||
|
selectedCombinableAudioFormat: '',
|
||||||
selectedSubtitles: [],
|
selectedSubtitles: [],
|
||||||
selectedPlaylistVideoIndex: '1',
|
selectedPlaylistVideoIndex: '1',
|
||||||
|
isErrored: false,
|
||||||
|
isErrorExpected: false,
|
||||||
|
erroredDownloadId: null,
|
||||||
|
setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })),
|
||||||
setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })),
|
setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })),
|
||||||
setSelctedDownloadFormat: (format) => set(() => ({ selctedDownloadFormat: format })),
|
setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })),
|
||||||
|
setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })),
|
||||||
|
setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })),
|
||||||
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
|
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
|
||||||
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index }))
|
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })),
|
||||||
|
setIsErrored: (isErrored) => set(() => ({ isErrored: isErrored })),
|
||||||
|
setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })),
|
||||||
|
setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useLibraryPageStatesStore = create<LibraryPageStatesStore>((set) => ({
|
||||||
|
activeTab: 'completed',
|
||||||
|
setActiveTab: (tab) => set(() => ({ activeTab: tab }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((set) => ({
|
export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((set) => ({
|
||||||
@@ -93,7 +114,9 @@ export const useDownloadActionStatesStore = create<DownloadActionStatesStore>((s
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set) => ({
|
export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set) => ({
|
||||||
activeTab: 'general',
|
activeTab: 'app',
|
||||||
|
activeSubAppTab: 'general',
|
||||||
|
activeSubExtTab: 'install',
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
isFetchingAppVersion: false,
|
isFetchingAppVersion: false,
|
||||||
ytDlpVersion: null,
|
ytDlpVersion: null,
|
||||||
@@ -105,9 +128,19 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
download_dir: '',
|
download_dir: '',
|
||||||
prefer_video_over_playlist: true,
|
prefer_video_over_playlist: true,
|
||||||
|
strict_downloadablity_check: false,
|
||||||
max_parallel_downloads: 2,
|
max_parallel_downloads: 2,
|
||||||
|
max_retries: 5,
|
||||||
use_proxy: false,
|
use_proxy: false,
|
||||||
proxy_url: '',
|
proxy_url: '',
|
||||||
|
use_rate_limit: false,
|
||||||
|
rate_limit: 1048576, // 1 MB/s
|
||||||
|
video_format: 'auto',
|
||||||
|
audio_format: 'auto',
|
||||||
|
always_reencode_video: false,
|
||||||
|
embed_video_metadata: false,
|
||||||
|
embed_audio_metadata: true,
|
||||||
|
embed_audio_thumbnail: true,
|
||||||
websocket_port: 53511
|
websocket_port: 53511
|
||||||
},
|
},
|
||||||
isUsingDefaultSettings: true,
|
isUsingDefaultSettings: true,
|
||||||
@@ -118,6 +151,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
|
|||||||
isUpdatingApp: false,
|
isUpdatingApp: false,
|
||||||
appUpdateDownloadProgress: 0,
|
appUpdateDownloadProgress: 0,
|
||||||
setActiveTab: (tab) => set(() => ({ activeTab: tab })),
|
setActiveTab: (tab) => set(() => ({ activeTab: tab })),
|
||||||
|
setActiveSubAppTab: (tab) => set(() => ({ activeSubAppTab: tab })),
|
||||||
|
setActiveSubExtTab: (tab) => set(() => ({ activeSubExtTab: tab })),
|
||||||
setAppVersion: (version) => set(() => ({ appVersion: version })),
|
setAppVersion: (version) => set(() => ({ appVersion: version })),
|
||||||
setIsFetchingAppVersion: (isFetching) => set(() => ({ isFetchingAppVersion: isFetching })),
|
setIsFetchingAppVersion: (isFetching) => set(() => ({ isFetchingAppVersion: isFetching })),
|
||||||
setYtDlpVersion: (version) => set(() => ({ ytDlpVersion: version })),
|
setYtDlpVersion: (version) => set(() => ({ ytDlpVersion: version })),
|
||||||
@@ -137,9 +172,19 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
download_dir: '',
|
download_dir: '',
|
||||||
prefer_video_over_playlist: true,
|
prefer_video_over_playlist: true,
|
||||||
|
strict_downloadablity_check: false,
|
||||||
max_parallel_downloads: 2,
|
max_parallel_downloads: 2,
|
||||||
|
max_retries: 5,
|
||||||
use_proxy: false,
|
use_proxy: false,
|
||||||
proxy_url: '',
|
proxy_url: '',
|
||||||
|
use_rate_limit: false,
|
||||||
|
rate_limit: 1048576, // 1 MB/s
|
||||||
|
video_format: 'auto',
|
||||||
|
audio_format: 'auto',
|
||||||
|
always_reencode_video: false,
|
||||||
|
embed_video_metadata: false,
|
||||||
|
embed_audio_metadata: true,
|
||||||
|
embed_audio_thumbnail: true,
|
||||||
websocket_port: 53511
|
websocket_port: 53511
|
||||||
},
|
},
|
||||||
isUsingDefaultSettings: true
|
isUsingDefaultSettings: true
|
||||||
|
|||||||
@@ -9,8 +9,19 @@ export interface Settings {
|
|||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
download_dir: string;
|
download_dir: string;
|
||||||
max_parallel_downloads: number;
|
max_parallel_downloads: number;
|
||||||
|
max_retries: number;
|
||||||
prefer_video_over_playlist: boolean;
|
prefer_video_over_playlist: boolean;
|
||||||
|
strict_downloadablity_check: boolean;
|
||||||
use_proxy: boolean;
|
use_proxy: boolean;
|
||||||
proxy_url: string;
|
proxy_url: string;
|
||||||
|
use_rate_limit: boolean;
|
||||||
|
rate_limit: number;
|
||||||
|
video_format: string;
|
||||||
|
audio_format: string;
|
||||||
|
always_reencode_video: boolean;
|
||||||
|
embed_video_metadata: boolean;
|
||||||
|
embed_audio_metadata: boolean;
|
||||||
|
embed_audio_thumbnail: boolean;
|
||||||
|
// extension settings
|
||||||
websocket_port: number;
|
websocket_port: number;
|
||||||
}
|
}
|
||||||
@@ -23,22 +23,43 @@ export interface CurrentVideoMetadataStore {
|
|||||||
isMetadataLoading: boolean;
|
isMetadataLoading: boolean;
|
||||||
requestedUrl: string;
|
requestedUrl: string;
|
||||||
autoSubmitSearch: boolean;
|
autoSubmitSearch: boolean;
|
||||||
|
searchPid: number | null;
|
||||||
|
showSearchError: boolean;
|
||||||
setVideoUrl: (url: string) => void;
|
setVideoUrl: (url: string) => void;
|
||||||
setVideoMetadata: (metadata: RawVideoInfo | null) => void;
|
setVideoMetadata: (metadata: RawVideoInfo | null) => void;
|
||||||
setIsMetadataLoading: (isLoading: boolean) => void;
|
setIsMetadataLoading: (isLoading: boolean) => void;
|
||||||
setRequestedUrl: (url: string) => void;
|
setRequestedUrl: (url: string) => void;
|
||||||
setAutoSubmitSearch: (autoSubmit: boolean) => void;
|
setAutoSubmitSearch: (autoSubmit: boolean) => void;
|
||||||
|
setSearchPid: (pid: number | null) => void;
|
||||||
|
setShowSearchError: (showError: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloaderPageStatesStore {
|
export interface DownloaderPageStatesStore {
|
||||||
|
activeDownloadModeTab: string;
|
||||||
isStartingDownload: boolean;
|
isStartingDownload: boolean;
|
||||||
selctedDownloadFormat: string;
|
selectedDownloadFormat: string;
|
||||||
|
selectedCombinableVideoFormat: string;
|
||||||
|
selectedCombinableAudioFormat: string;
|
||||||
selectedSubtitles: string[];
|
selectedSubtitles: string[];
|
||||||
selectedPlaylistVideoIndex: string;
|
selectedPlaylistVideoIndex: string;
|
||||||
|
isErrored: boolean;
|
||||||
|
isErrorExpected: boolean;
|
||||||
|
erroredDownloadId: string | null;
|
||||||
|
setActiveDownloadModeTab: (tab: string) => void;
|
||||||
setIsStartingDownload: (isStarting: boolean) => void;
|
setIsStartingDownload: (isStarting: boolean) => void;
|
||||||
setSelctedDownloadFormat: (format: string) => void;
|
setSelectedDownloadFormat: (format: string) => void;
|
||||||
|
setSelectedCombinableVideoFormat: (format: string) => void;
|
||||||
|
setSelectedCombinableAudioFormat: (format: string) => void;
|
||||||
setSelectedSubtitles: (subtitles: string[]) => void;
|
setSelectedSubtitles: (subtitles: string[]) => void;
|
||||||
setSelectedPlaylistVideoIndex: (index: string) => void;
|
setSelectedPlaylistVideoIndex: (index: string) => void;
|
||||||
|
setIsErrored: (isErrored: boolean) => void;
|
||||||
|
setIsErrorExpected: (isErrorExpected: boolean) => void;
|
||||||
|
setErroredDownloadId: (downloadId: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryPageStatesStore {
|
||||||
|
activeTab: string;
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadActionStatesStore {
|
export interface DownloadActionStatesStore {
|
||||||
@@ -58,6 +79,8 @@ export interface DownloadActionStatesStore {
|
|||||||
|
|
||||||
export interface SettingsPageStatesStore {
|
export interface SettingsPageStatesStore {
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
|
activeSubAppTab: string;
|
||||||
|
activeSubExtTab: string;
|
||||||
appVersion: string | null;
|
appVersion: string | null;
|
||||||
isFetchingAppVersion: boolean;
|
isFetchingAppVersion: boolean;
|
||||||
ytDlpVersion: string | null;
|
ytDlpVersion: string | null;
|
||||||
@@ -72,6 +95,8 @@ export interface SettingsPageStatesStore {
|
|||||||
isUpdatingApp: boolean;
|
isUpdatingApp: boolean;
|
||||||
appUpdateDownloadProgress: number;
|
appUpdateDownloadProgress: number;
|
||||||
setActiveTab: (tab: string) => void;
|
setActiveTab: (tab: string) => void;
|
||||||
|
setActiveSubAppTab: (tab: string) => void;
|
||||||
|
setActiveSubExtTab: (tab: string) => void;
|
||||||
setAppVersion: (version: string | null) => void;
|
setAppVersion: (version: string | null) => void;
|
||||||
setIsFetchingAppVersion: (isFetching: boolean) => void;
|
setIsFetchingAppVersion: (isFetching: boolean) => void;
|
||||||
setYtDlpVersion: (version: string | null) => void;
|
setYtDlpVersion: (version: string | null) => void;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 1024, // 1MB
|
||||||
|
},
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent vite from obscuring rust errors
|
// 1. prevent vite from obscuring rust errors
|
||||||
|
|||||||
Reference in New Issue
Block a user