Compare commits
135 Commits
v0.2.0
...
52dad3acb1
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[docker-compose.yml]
|
||||||
|
indent_size = 4
|
||||||
3
.gitattributes
vendored
@@ -1,2 +1 @@
|
|||||||
src-tauri/binaries/* filter=lfs diff=lfs merge=lfs -text
|
* text=auto eol=lf
|
||||||
src-tauri/resources/binaries/* filter=lfs diff=lfs merge=lfs -text
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
on:
|
on: workflow_dispatch
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
|
|
||||||
name: 🚀 Publish to AUR
|
name: 🚀 Publish to AUR
|
||||||
jobs:
|
jobs:
|
||||||
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: "[BUG]"
|
||||||
|
labels: bug
|
||||||
|
assignees: neosubhamoy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**OS info (required):**
|
||||||
|
- OS: [e.g. Windows 11]
|
||||||
|
- OS Architecture: [e.g. x64]
|
||||||
|
- OS Version: [e.g. 25H2 26200.6899]
|
||||||
|
|
||||||
|
**App info (required):**
|
||||||
|
- NeoDLP Version: [e.g. 0.3.1]
|
||||||
|
- YT-DLP Version: [e.g. 2025.10.18.232824]
|
||||||
|
- NeoDLP Installation Mode: [e.g. msi, exe, winget]
|
||||||
|
|
||||||
|
**App logs (required):**
|
||||||
|
Paste the full app session logs here
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: "[FEATURE REQUEST]"
|
||||||
|
labels: feature request
|
||||||
|
assignees: neosubhamoy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
31
.github/ISSUE_TEMPLATE/test_result.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Test result
|
||||||
|
about: Share your test results
|
||||||
|
title: "[TEST RESULT]"
|
||||||
|
labels: test result
|
||||||
|
assignees: neosubhamoy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the result**
|
||||||
|
A clear and concise description of what's the final result of your testing.
|
||||||
|
|
||||||
|
**Did you noticed any problem?**
|
||||||
|
Mention if you noticed any problems or inconsistencies during the testing
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain the results.
|
||||||
|
|
||||||
|
**What's working (required):**
|
||||||
|
- [ ] Package installation
|
||||||
|
- [ ] Downloads
|
||||||
|
- [ ] Settings and configurations
|
||||||
|
- [ ] Browser integration
|
||||||
|
|
||||||
|
**Test environment (required):**
|
||||||
|
- OS: [e.g. Windows 11]
|
||||||
|
- OS Architecture: [e.g. x64]
|
||||||
|
- OS Version: [e.g. 25H2 26200.6899]
|
||||||
|
- NeoDLP Version: [e.g. 0.3.1]
|
||||||
|
- YT-DLP Version: [e.g. 2025.10.18.232824]
|
||||||
|
- NeoDLP Installation Mode: [e.g. msi, exe, winget]
|
||||||
16
.github/images/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 |
BIN
.github/images/completed-downloads.png
vendored
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
.github/images/downloader.png
vendored
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
.github/images/flathub/completed-downloads.png
vendored
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
.github/images/flathub/downloader.png
vendored
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
.github/images/flathub/ongoing-downloads.png
vendored
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
.github/images/flathub/settings.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
138
.github/images/mockup.svg
vendored
Normal file
|
After Width: | Height: | Size: 541 KiB |
BIN
.github/images/ongoing-downloads.png
vendored
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
.github/images/settings.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
20
.github/workflows/release.yml
vendored
@@ -14,31 +14,27 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- platform: 'macos-latest'
|
- platform: 'macos-latest'
|
||||||
args: '--target aarch64-apple-darwin --config ./src-tauri/tauri.macos-aarch64.conf.json'
|
args: '--target aarch64-apple-darwin --config ./src-tauri/tauri.macos-aarch64.conf.json'
|
||||||
arch: 'aarch64-apple-darwin'
|
|
||||||
- platform: 'macos-latest'
|
- platform: 'macos-latest'
|
||||||
args: '--target x86_64-apple-darwin --config ./src-tauri/tauri.macos-x86_64.conf.json'
|
args: '--target x86_64-apple-darwin --config ./src-tauri/tauri.macos-x86_64.conf.json'
|
||||||
arch: 'x86_64-apple-darwin'
|
|
||||||
- platform: 'ubuntu-22.04'
|
- platform: 'ubuntu-22.04'
|
||||||
args: ''
|
args: '--target x86_64-unknown-linux-gnu --config ./src-tauri/tauri.linux-x86_64.conf.json'
|
||||||
arch: ''
|
- platform: 'ubuntu-22.04-arm'
|
||||||
|
args: '--target aarch64-unknown-linux-gnu --config ./src-tauri/tauri.linux-aarch64.conf.json'
|
||||||
- platform: 'windows-latest'
|
- platform: 'windows-latest'
|
||||||
args: ''
|
args: ''
|
||||||
arch: ''
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
steps:
|
steps:
|
||||||
- name: 🚚 Checkout repository
|
- name: 🚚 Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
|
||||||
lfs: true
|
|
||||||
|
|
||||||
- name: 🛠️ Install dependencies
|
- name: 🛠️ Install dependencies
|
||||||
if: matrix.platform == 'ubuntu-22.04'
|
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||||
|
|
||||||
- name: 📦 Setup node
|
- name: 📦 Setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 'lts/*'
|
node-version: 'lts/*'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@@ -56,6 +52,9 @@ jobs:
|
|||||||
- name: 🛠️ Install frontend dependencies
|
- name: 🛠️ Install frontend dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: 📥 Download binaries
|
||||||
|
run: npm run download
|
||||||
|
|
||||||
- name: 📄 Read and Process CHANGELOG (Unix)
|
- name: 📄 Read and Process CHANGELOG (Unix)
|
||||||
if: matrix.platform != 'windows-latest'
|
if: matrix.platform != 'windows-latest'
|
||||||
id: changelog_unix
|
id: changelog_unix
|
||||||
@@ -102,7 +101,6 @@ jobs:
|
|||||||
uses: tauri-apps/tauri-action@v0
|
uses: tauri-apps/tauri-action@v0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TARGET_ARCH: ${{ matrix.arch }}
|
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
with:
|
with:
|
||||||
|
|||||||
17
.gitignore
vendored
@@ -1,3 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.github/workflows/.secrets
|
||||||
|
/target/
|
||||||
|
src-tauri/binaries/*
|
||||||
|
!src-tauri/binaries/.gitkeep
|
||||||
|
src-tauri/resources/downloads/*
|
||||||
|
!src-tauri/resources/downloads/.gitkeep
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -7,12 +18,6 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.github/workflows/.secrets
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
55
CHANGELOG.md
@@ -1,39 +1,46 @@
|
|||||||
### ✨ Changelog
|
### ✨ Changelog
|
||||||
|
|
||||||
- DOWNLOADER: Introduced 'Combine' download mode (Now, You can combine a video and audio stream of your choice)
|
- Added 'Health Check' section in app info
|
||||||
- SETTINGS: Added global video/audio file format selection option (Available Formats: MP4, WEBM, MKV, M4A, OPUS, MP3)
|
- Added Homebrew package for macOS
|
||||||
- SETTINGS: Added video/audio file metadata embeding option
|
- Added initial compatibility with Flatpak for Linux
|
||||||
- SETTINGS: Added thumbnail embeding option in audio files (as cover art)
|
- Other minor fixes and improvements
|
||||||
- SETTINGS: Added re-encode video over remuxing option (when file format convertion is needed)
|
|
||||||
- SETTINGS: Added strict downloadablity check option
|
|
||||||
- SETTINGS: Added download speed rate limit option
|
|
||||||
- SETTINGS: Added download max retries option
|
|
||||||
- SETTINGS: Added temporary download folder cleanup option
|
|
||||||
- UI: Improved 'Settings' ui/layout with categories (tabs)
|
|
||||||
- UI: Merged 'Extension' sidebar tab within 'Settings' (Settings > Extension > Install)
|
|
||||||
- UI: Improved 'Library' ui/layout with tabs
|
|
||||||
- UI: Added 'Stop' all ongoing downloads button in 'Library'
|
|
||||||
- UI: Renamed settings 'General' tab to 'Application' ('General' is now a sub-category of 'Application' tab)
|
|
||||||
- UI: Improved all alert dialog messages (for better undestanding/UX)
|
|
||||||
- FIXED: Unexpected crashing of yt-dlp causing downloads to stuck on a unrevocable state (Now, coresponding download will be 'paused' on detection of unexpected yt-dlp crash)
|
|
||||||
- FIXED: Broken app updater progress bar/percentage (also improved the update notification card)
|
|
||||||
- Lots of 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). 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)
|
> [!CAUTION]
|
||||||
|
> Users are always adviced to complete/cancel all paused downloads before updating to a newer version, otherwise paused downloads may not resume properly and re-start from the begining.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Linux users make sure `yt-dlp` and `deno` 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)
|
||||||
|
|
||||||
> This is an Un-Signed Build (MacOS doesn't trust this Certificate so, it may flag this as from 'unverified developer' and prevent it from opening, in that case, open Settings and allow it from 'Settings > Privacy and Security' section to get started)
|
> This is an Un-Signed Build (MacOS doesn't trust this Certificate so, it may flag this as from 'unverified developer' and prevent it from opening, in that case, open Settings and allow it from 'Settings > Privacy and Security' section to get started)
|
||||||
|
|
||||||
|
### 📦 Shipped Binaries
|
||||||
|
|
||||||
|
| yt-dlp (updateable) | ffmpeg | ffprobe | aria2c | deno | bgutil-pot-rs |
|
||||||
|
| :---- | :---- | :---- | :---- | :---- | :---- |
|
||||||
|
| v2026.03.02.233544 (nightly) | v7.1.1 | v7.1.1 | v1.37.0 | v2.7.1 | v0.7.2-1.3.0 |
|
||||||
|
|
||||||
|
> ‼️ Linux builds (deb, rpm) does not ships with `ffmpeg` and `ffprobe` (though it will be auto installed as a dependency by your package manager, if you are on fedora make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package)
|
||||||
|
|
||||||
|
> ‼️ MacOS builds (dmg, app) does not ships with `aria2c`, If you want to use [aria2](https://formulae.brew.sh/formula/aria2) install it via [homebrew](https://brew.sh) (though it will be auto installed as a dependency if you install neodlp via homebrew)
|
||||||
|
|
||||||
### ⬇️ Download Section
|
### ⬇️ Download Section
|
||||||
|
|
||||||
| Arch\OS | Windows (msi) ⬆️ | Windows (exe) ⬆️ | Linux (deb) | Linux (rpm) | MacOS (dmg) ⬆️ | MacOS (app) ⬆️ |
|
| Architecture | Windows (msi) ⬆️ | Windows (exe) ⬆️ | Linux (deb) | Linux (rpm) | Linux (AppImage) ⬆️ | MacOS (dmg) ⬆️ | MacOS (app) ⬆️ |
|
||||||
| :---- | :---- | :---- | :---- | :---- | :---- | :---- |
|
| :---- | :---- | :---- | :---- | :---- | :---- | :---- | :---- |
|
||||||
| x86_64 | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_en-US.msi) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.x86_64.rpm) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_x64.app.tar.gz) |
|
| x86_64 | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64_en-US.msi) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.x86_64.rpm) | 🚫 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_amd64.AppImage) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64.dmg) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_x64.app.tar.gz) |
|
||||||
| ARM64 | N/A | N/A | N/A | N/A | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_aarch64.app.tar.gz) |
|
| ARM64 | N/A | 🪟 [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_x64-setup.exe) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_arm64.deb) | [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP-<version>-1.aarch64.rpm) | N/A | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_<version>_aarch64.dmg) | ⚠️ [Download](https://github.com/neosubhamoy/neodlp/releases/download/<release_tag>/NeoDLP_aarch64.app.tar.gz) |
|
||||||
|
|
||||||
> ⬆️ icon indicates this packaging format supports in-built app-updater
|
> ⬆️ icon indicates this packaging format supports in-built app-updater
|
||||||
|
|
||||||
> ⚠️ MacOS ARM64 binary downloads are experimental and may not open on Apple Silicon Macs if downloaded from browser (You will get 'Damaged File' error) it's because the binaries are not signed (signing MacOS binaries requires 99$/year Apple Developer Account subscription, which I can't afford RN!) and Apple Silicon Macs don't allow unsigned apps (downloaded from browser) to be installed on the system. If you want to use NeoDLP on your Apple Silicon Macs, you can simply use the command line [Curl-Bash Installer](https://neodlp.neosubhamoy.com/download) (Recommended) -OR- [compile it from source](https://github.com/neosubhamoy/neodlp?tab=readme-ov-file#%EF%B8%8F-contributing--building-from-source) in your Mac
|
> 🪟 Windows x86_64 binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact)
|
||||||
|
|
||||||
|
> 🚫 Linux AppImage builds are experimental and does not support neodlp's browser intergration features due to it's sandboxed nature. Also, don't run the AppImage with portable (.home, .config) folders, it will break things (it is highly recommended to use native [deb, rpm, AUR] builds if possible for the full experiance, otherwise AppImages are good for trying out NeoDLP without installing)
|
||||||
|
|
||||||
|
> ⚠️ MacOS ARM64 binary downloads are experimental and may not open on Apple Silicon Macs if downloaded from browser (You will get 'Damaged File' error) it's because the binaries are not signed (signing MacOS binaries requires 99$/year Apple Developer Account subscription, which I can't afford RN!) and Apple Silicon Macs don't allow unsigned apps (downloaded from browser) to be installed on the system. If you want to use NeoDLP on your Apple Silicon Macs, There are few ways you can bypass these restrictions:
|
||||||
|
> 1. Using our automated [Curl-Bash Installer](https://neodlp.neosubhamoy.com/download) (Recommended)
|
||||||
|
> 2. You can also manually remove the .dmg file/.app folder from macOS quarantine using these commands: `xattr -d com.apple.quarantine NeoDLP_x.x.x_aarch64.dmg` (for .dmg file) -OR- `xattr -r -d com.apple.quarantine /Applications/NeoDLP.app` (for .app folder)
|
||||||
|
> 3. Or you can [compile NeoDLP from source](https://github.com/neosubhamoy/neodlp?tab=readme-ov-file#%EF%B8%8F-building-from-source) in your Mac (Then you don't have to download the pre-compiled binaries at all, though it is a much longer process and is intended for advanced users only)
|
||||||
|
|||||||
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Subhamoy Biswas
|
Copyright (c) 2025 - Present Subhamoy Biswas
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
227
README.md
@@ -1,77 +1,212 @@
|
|||||||
# NeoDLP - (Neo Downloader Plus)
|

|
||||||
|
|
||||||
Cross-platform Video/Audio Downloader Desktop App with Modern UI and Browser Integration
|
# NeoDLP - Neo Downloader Plus
|
||||||
|
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
|
||||||
[](https://github.com/neosubhamoy/neodlp)
|
|
||||||
|
|
||||||
|
[](https://github.com/neosubhamoy/neodlp/releases/latest)
|
||||||
|
[](https://github.com/neosubhamoy/neodlp/releases)
|
||||||
|
[](https://github.com/neosubhamoy/neodlp/stargazers)
|
||||||
|
[](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
> **🥰 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!**
|
||||||
|
|
||||||
### 💻 Supported Platforms
|
[](https://github.com/microsoft/winget-pkgs/tree/master/manifests/n/neosubhamoy/neodlp)
|
||||||
|
[](https://aur.archlinux.org/packages/neodlp)
|
||||||
|
|
||||||
|
|
||||||
|
## ✨ Highlighted Features
|
||||||
|
|
||||||
|
- Download Video/Audio from thousands of popular sites (YT, FB, IG, X and other 2.5k+ [supported sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md))
|
||||||
|
- Fully Configured YT-DLP Environment Out-of-the-Box (with JS Runtime, PO Token Server, Real-Time Logs etc.)
|
||||||
|
- Download Video/Audio in your preffered format (MP4, WEBM, MKV, MP3 etc.)
|
||||||
|
- Supports both Video and Playlist/Batch download
|
||||||
|
- Supports Combining Video, Audio streams of your choice
|
||||||
|
- Supports Multi-Lingual Subtitle/Caption (CC) embeding
|
||||||
|
- Different Video/Audio metadata embeding options (info, chapters, thumbnail etc.)
|
||||||
|
- SponsorBlock support (mark/remove video segments)
|
||||||
|
- Aria2 support (for blazing fast downloads)
|
||||||
|
- Network controls (proxy, rate limit etc.)
|
||||||
|
- Highly customizable and many more...😉
|
||||||
|
|
||||||
|
## 🧩 Browser Integration
|
||||||
|
|
||||||
|
You can integrate NeoDLP with your favourite browser (any 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 (Search with Neo Downloader Plus - Link, Selection, Media Source)
|
||||||
|
|
||||||
|
## 👀 Sneak Peek
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Downloader | Completed Downloads | Ongoing Downloads | Settings |
|
||||||
|
| :---- | :---- | :---- | :---- |
|
||||||
|
|  |  |  |  |
|
||||||
|
|
||||||
|
## 💻 Supported Platforms
|
||||||
|
|
||||||
- Windows (10 / 11)
|
- Windows (10 / 11)
|
||||||
- Linux (Debian / Fedora / Arch Linux base)
|
- Linux (Debian / Fedora / RHEL / SUSE / Arch Linux base)
|
||||||
- MacOS (>10.3)
|
- MacOS (>11)
|
||||||
|
|
||||||
### 🌐 Supported Sites
|
## 🤝 External Dependencies
|
||||||
|
|
||||||
- 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+)**
|
- [YT-DLP](https://github.com/yt-dlp/yt-dlp) [Unlicense] - The core CLI tool used to download video/audio from the web (Hero of the show 😎)
|
||||||
|
- [FFmpeg & FFprobe](https://www.ffmpeg.org) [LGPLv2.1+] - Used for video/audio post-processing
|
||||||
|
- [Aria2](https://aria2.github.io) [GPLv2+] - Used as an external downloader for blazing fast downloads with yt-dlp (Not included with NeoDLP MacOS builds)
|
||||||
|
- [Deno](https://deno.com) [MIT] - Provides sandboxed javascript runtime environment for yt-dlp (Required for YT downloads, as per the new yt-dlp [announcement](https://github.com/yt-dlp/yt-dlp/issues/14404))
|
||||||
|
- [BgUtils POT Provider (Rust)](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) [GPLv3+] - Provides PO (Proof-of-Origin) Token for YT downloads
|
||||||
|
|
||||||
### 🧩 External Dependencies
|
## ℹ️ System Pre-Requirements
|
||||||
|
|
||||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The core CLI Tool used to download Video/Audio from the Web
|
- **Windows:** [Microsoft Visual C++ Redistributable 2015+](https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170) `winget install Microsoft.VCRedist.2015+.x64` (Will be auto-installed if you install NeoDLP via winget)
|
||||||
- [FFmpeg](https://www.ffmpeg.org) - Used for Video/Audio Post-processing
|
- **MacOS:** XCode Command Line Tools `xcode-select --install` (Mostly, comes pre-installed on modern macos, still if you encounter any issue then try installing it manually)
|
||||||
|
- **Linux:** Most linux packages comes with pre-defined system dependencies which will be auto installed by your package manager (if you are on `fedora` make sure to [enable rpmfusion free+nonfree repos](https://docs.fedoraproject.org/en-US/quick-docs/rpmfusion-setup/#_enabling_the_rpm_fusion_repositories_using_command_line_utilities) before installing the rpm package. also, if you prefer to install dependencies manually [follow this](https://v2.tauri.app/start/prerequisites/#linux))
|
||||||
|
|
||||||
### ⬇️ 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 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 |
|
| Architecture | Windows | Linux | MacOS |
|
||||||
| :---- | :---- | :---- | :---- |
|
| :---- | :---- | :---- | :---- |
|
||||||
| x86_64 | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
|
| x86_64 | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
|
||||||
| ARM64 | ❌ N/A | ❌ N/A | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
|
| ARM64 | ✅ Emulation | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) | ✅ [Download](https://github.com/neosubhamoy/neodlp/releases/latest) |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> x86_64 Windows binary also works on ARM64 (Windows on ARM) devices with emulation (Not planning to release native Windows ARM64 build anytime soon as, x86_64 one works fine on ARM64 without noticeable performance impact)
|
||||||
|
|
||||||
| Platform (OS) | Distribution Channel | Installation Command / Instruction |
|
| Platform (OS) | Distribution Channel | Installation Command / Instruction |
|
||||||
| :---- | :---- | :---- |
|
| :---- | :---- | :---- |
|
||||||
| Windows x86_64 | WinGet | `winget install neodlp` |
|
| Windows x86_64 / ARM64 | WinGet | `winget install neosubhamoy.neodlp` |
|
||||||
| MacOS Universal | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/neodlp_macos_installer.sh \| bash` |
|
| MacOS x86_64 / ARM64 | Homebrew | `brew install neosubhamoy/tap/neodlp` |
|
||||||
| Linux x86_64 (Arch Linux) | AUR | `yay -S neodlp` |
|
| MacOS x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/macos_installer.sh \| bash` |
|
||||||
|
| Linux x86_64 / ARM64 | Curl-Bash Installer | `curl -sSL https://neodlp.neosubhamoy.com/linux_installer.sh \| bash` |
|
||||||
|
| Arch Linux x86_64 / ARM64 | AUR | `yay -S neodlp` or `paru -S neodlp` |
|
||||||
|
|
||||||
### ⚡ Technologies Used
|
## 🧪 Package Testing Status
|
||||||
|
|
||||||

|
Though NeoDLP is supported on most platforms but not all packages are tested on all platforms, to save some time (and brain cells) and ship the software as fast as possible! Current test coverage is given below. So, untested packages may have issues, test it yourself and always feel free to report any issue on github.
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
### 🛠️ Contributing / Building from Source
|
> [!TIP]
|
||||||
|
> If you have access to any of the untested systems listed below, you can test the packages there and send me the test results via creating an github issue! (that would be super helpful actualy 😊)
|
||||||
|
|
||||||
Want to be part of this? Feel free to contribute...!! Pull Requests are always welcome...!! (^_^) Follow these simple steps to start building:
|
<details>
|
||||||
|
<summary>Test Coverage</summary>
|
||||||
|
|
||||||
* Make sure to install [Rust](https://www.rust-lang.org/tools/install), [Node.js](https://nodejs.org/en), [Git](https://git-scm.com/downloads) and [Git-LFS](https://git-lfs.com/) before proceeding.
|
| Platform | Status | Platform | Status |
|
||||||
|
| :---- | :---- | :---- | :---- |
|
||||||
|
| Windows 10 (x64) | ✅ Tested | Windows 10 (ARM64) | ⚠️ Untested |
|
||||||
|
| Windows 11 (x64) | ✅ Tested | Windows 11 (ARM64) | ✅ Tested |
|
||||||
|
| MacOS 14 (x64) | ✅ Tested | MacOS 14 (ARM64) | ✅ Tested |
|
||||||
|
| MacOS 15 (x64) | ⚠️ Untested | MacOS 15 (ARM64) | ✅ Tested |
|
||||||
|
| MacOS 26 (x64) | ⚠️ Untested | MacOS 26 (ARM64) | ✅ Tested |
|
||||||
|
| Ubuntu 24.04 LTS (x64) | ✅ Tested | Ubuntu 24.04 LTS (ARM64) | ⚠️ Untested |
|
||||||
|
| Fedora 42 (x64) | ✅ Tested | Fedora 42 (ARM64) | ⚠️ Untested |
|
||||||
|
| Arch Linux (x64) | ✅ Tested | Arch Linux (ARM64) | ✅ Tested |
|
||||||
|
| openSUSE 16 (x64) | ⚠️ Untested | openSUSE 16 (ARM64) | ⚠️ Untested |
|
||||||
|
| RHEL 10 (x64) | ⚠️ Untested | RHEL 10 (ARM64) | ⚠️ Untested |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 💝 Support the Development
|
||||||
|
|
||||||
|
NeoDLP is and will be always FREE to Use and Open-Sourced for Everyone. 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**
|
||||||
|
|
||||||
|
## 🪜 Roadmap
|
||||||
|
|
||||||
|
- [x] Add support for yt-dlp
|
||||||
|
- [x] Add basic settings and customization
|
||||||
|
- [x] Integrate with browsers
|
||||||
|
- [x] Add aria2c support
|
||||||
|
- [x] Add custom command support
|
||||||
|
- [x] Add full-playlist/batch download support
|
||||||
|
- [ ] Improve browser integration **(ongoing)**
|
||||||
|
- [ ] Implement NeoDLP API
|
||||||
|
- [ ] Build web interface
|
||||||
|
- [ ] Implement plugin system
|
||||||
|
- [ ] Add more cool stuffs 😉
|
||||||
|
|
||||||
|
## ⚡ Technologies Used
|
||||||
|
|
||||||
|
[](https://tauri.app)
|
||||||
|
[](https://rust-lang.org)
|
||||||
|
[](https://react.dev)
|
||||||
|
[](https://www.typescriptlang.org)
|
||||||
|
[](https://ui.shadcn.com)
|
||||||
|
|
||||||
|
## 🛠️ Building from Source
|
||||||
|
|
||||||
|
Want to build/compile NeoDLP from the source code? Follow these simple steps to create a production build:
|
||||||
|
|
||||||
|
* Make sure to install [Rust](https://www.rust-lang.org/tools/install), [Node.js](https://nodejs.org/en), and [Git](https://git-scm.com/downloads) before proceeding.
|
||||||
* Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform
|
* Install [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS / platform
|
||||||
1. Fork this repo in your github account.
|
1. Clone this repo in your local machine: `git clone https://github.com/neosubhamoy/neodlp.git`
|
||||||
2. Git clone the forked repo in your local machine.
|
2. Go inside the cloned project directory: `cd neodlp`
|
||||||
3. Install Node.js dependencies: `npm install`
|
3. Install Node.js dependencies: `npm install`
|
||||||
4. Run development / build process
|
4. Download required external binaries (for your platform): `npm run download`
|
||||||
> ⚠️ Make sure to run the build command once before running the dev command for the first time to avoid build time errors
|
5. Run build process (run the command based on your platform and architecture)
|
||||||
```code
|
```shell
|
||||||
# for windows and linux users
|
# command for windows users
|
||||||
npm run tauri dev # for development
|
npm run tauri build # for both x64/ARM64 devices
|
||||||
npm run tauri build # for production build
|
|
||||||
|
|
||||||
# for macOS users (based on cpu architecture)
|
# commands for linux users
|
||||||
npm run tauri dev -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, development
|
npm run tauri:build:linux-x64 # for x64 devices
|
||||||
npm run tauri build -- --config "./src-tauri/tauri.macos-aarch64.conf.json" # for apple silicon macs, production build
|
npm run tauri:build:linux-arm64 # for ARM64 devices
|
||||||
|
|
||||||
npm run tauri dev -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, development
|
# commands for macOS users
|
||||||
npm run tauri build -- --config "./src-tauri/tauri.macos-x86_64.conf.json" # for intel x86 macs, production build
|
npm run tauri:build:macos-arm64 # for apple silicon macs
|
||||||
|
npm run tauri:build:macos-x64 # for intel x86 macs
|
||||||
```
|
```
|
||||||
5. Do the changes, Send a Pull Request with proper Description (NOTE: Pull Requests Without Proper Description will be Rejected)
|
6. Give it the time to compile (~5-10min) (if you get an error, something like this at the end: `Error A public key has been found, but no private key. Make sure to set 'TAURI_SIGNING_PRIVATE_KEY' environment variable.` simply ignore it! Your build is successfull!). You can find the compiled packages under: `src-tauri/target/release/bundle` directory.
|
||||||
|
|
||||||
**⭕ Noticed any Bugs or Want to give us some suggetions? Always feel free to open a GitHub Issue. We would love to hear from you...!!**
|
## 🐞 Bug Report and Discussions
|
||||||
|
|
||||||
### 📝 License
|
Noticed any Bug? or Want to give us some suggetions? Always feel free to let us know! We would love to hear from you...!! You can reach us out via the following methods:
|
||||||
|
|
||||||
NeoDLP is Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any attribution and extra permissions.
|
- GitHub Issues (Recommended): [Report a Bug](https://github.com/neosubhamoy/neodlp/issues/new?template=bug_report.md) -OR- [Request a Feature](https://github.com/neosubhamoy/neodlp/issues/new?template=feature_request.md)
|
||||||
|
- Mailing List: If you prefer the good old mailing list way, You can just simply write us on [support@neodlp.neosubhamoy.com](mailto:support@neodlp.neosubhamoy.com) (Kindly follow the Bug Report/Feature Request Template on that case)
|
||||||
|
- Reddit Community: If you have any other general pourpose query/discussion related to NeoDLP, post it on our subreddit community [r/NeoDLP](https://www.reddit.com/r/NeoDLP)
|
||||||
|
|
||||||
|
## 📦 Sources
|
||||||
|
|
||||||
|
- [Official Website](https://neodlp.neosubhamoy.com)
|
||||||
|
- Official Repositories
|
||||||
|
- [GitHub (Primary)](https://github.com/neosubhamoy/neodlp)
|
||||||
|
- [Gitea (Mirror)](https://gitea.neosubhamoy.com/neosubhamoy/neodlp)
|
||||||
|
- [SourceForge (Releases Only)](https://sourceforge.net/projects/neodlp)
|
||||||
|
- Official Distribution Channels
|
||||||
|
- [WinGet (for Windows)](https://github.com/microsoft/winget-pkgs/tree/master/manifests/n/neosubhamoy/neodlp)
|
||||||
|
- [AUR (for Arch Linux)](https://aur.archlinux.org/packages/neodlp)
|
||||||
|
- Related Projects
|
||||||
|
- [NeoDLP Extension](https://github.com/neosubhamoy/neodlp-extension)
|
||||||
|
- [NeoDLP Website](https://github.com/neosubhamoy/neodlp-website)
|
||||||
|
|
||||||
|
## 💫 Credits
|
||||||
|
|
||||||
|
- NeoDLP is made possible by the joint efforts of [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [FFmpeg](https://www.ffmpeg.org). Lots of NeoDLP features are actually powered by these tools under the hood! So huge thanks to all the developers/contributers for making these great tools! 🙏
|
||||||
|
- NeoDLP's 'Format Selection' options are inspired from the [Seal](https://github.com/JunkFood02/Seal) app by [@JunkFood02](https://github.com/JunkFood02)
|
||||||
|
- Aria2 Linux x86_64 static binaries are built by [@asdo92](https://github.com/asdo92/aria2-static-builds)
|
||||||
|
- NeoDLP's 'POT Server' is based on [@jim60105's Rust Implementation](https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs) of [Brainicism/bgutil-ytdlp-pot-provider](https://github.com/Brainicism/bgutil-ytdlp-pot-provider)
|
||||||
|
|
||||||
|
## ⚖️ License and Usage
|
||||||
|
|
||||||
|
NeoDLP is a Fully Open-Source Software Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any extra permission (Just include the LICENSE file :)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.
|
||||||
|
|
||||||
|
****
|
||||||
|
An Open Sourced Project - Developed with ❤️ by **Subhamoy**
|
||||||
|
|||||||
10
com.neosubhamoy.neodlp.desktop
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
|
||||||
|
Name=NeoDLP
|
||||||
|
Comment=Modern feature-rich video/audio downloader based on yt-dlp.
|
||||||
|
Icon=com.neosubhamoy.neodlp
|
||||||
|
Exec=neodlp
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;
|
||||||
|
Keywords=neodlp;downloader;yt-dlp-gui;
|
||||||
46
com.neosubhamoy.neodlp.metainfo.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<component type="desktop-application">
|
||||||
|
<id>com.neosubhamoy.neodlp</id>
|
||||||
|
<name>NeoDLP</name>
|
||||||
|
<summary>Modern feature-rich video/audio downloader based on yt-dlp</summary>
|
||||||
|
<developer id="com.neosubhamoy">
|
||||||
|
<name>Subhamoy Biswas</name>
|
||||||
|
</developer>
|
||||||
|
<metadata_license>CC0-1.0</metadata_license>
|
||||||
|
<project_license>MIT</project_license>
|
||||||
|
<url type="homepage">https://neodlp.neosubhamoy.com</url>
|
||||||
|
<url type="vcs-browser">https://github.com/neosubhamoy/neodlp</url>
|
||||||
|
<url type="bugtracker">https://github.com/neosubhamoy/neodlp/issues</url>
|
||||||
|
<description>
|
||||||
|
<p>
|
||||||
|
NeoDLP is a cross-platform desktop application designed for downloading videos and audio from various online sources based on yt-dlp.
|
||||||
|
|
||||||
|
It offers modern user interface, lots of features and customization options.
|
||||||
|
</p>
|
||||||
|
</description>
|
||||||
|
<launchable type="desktop-id">com.neosubhamoy.neodlp.desktop</launchable>
|
||||||
|
<screenshots>
|
||||||
|
<screenshot type="default">
|
||||||
|
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/downloader.png</image>
|
||||||
|
<caption>Downloader page of NeoDLP</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/completed-downloads.png</image>
|
||||||
|
<caption>Completed downloads page of NeoDLP</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/ongoing-downloads.png</image>
|
||||||
|
<caption>Ongoing downloads page of NeoDLP</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://raw.githubusercontent.com/neosubhamoy/neodlp/main/.github/images/flathub/settings.png</image>
|
||||||
|
<caption>Settings page of NeoDLP</caption>
|
||||||
|
</screenshot>
|
||||||
|
</screenshots>
|
||||||
|
<content_rating type="oars-1.1" />
|
||||||
|
<releases>
|
||||||
|
<release version="0.4.3" date="2026-03-05">
|
||||||
|
<url type="details">https://github.com/neosubhamoy/neodlp/releases/tag/v0.4.3</url>
|
||||||
|
</release>
|
||||||
|
</releases>
|
||||||
|
</component>
|
||||||
3745
package-lock.json
generated
125
package.json
@@ -1,87 +1,72 @@
|
|||||||
{
|
{
|
||||||
"name": "neodlp",
|
"name": "neodlp",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.4.2",
|
||||||
|
"description": "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri",
|
||||||
|
"tauri:dev:linux-x64": "npm run tauri dev -- --config ./src-tauri/tauri.linux-x86_64.conf.json",
|
||||||
|
"tauri:build:linux-x64": "npm run tauri build -- --config ./src-tauri/tauri.linux-x86_64.conf.json",
|
||||||
|
"tauri:dev:linux-arm64": "npm run tauri dev -- --config ./src-tauri/tauri.linux-aarch64.conf.json",
|
||||||
|
"tauri:build:linux-arm64": "npm run tauri build -- --config ./src-tauri/tauri.linux-aarch64.conf.json",
|
||||||
|
"tauri:dev:macos-x64": "npm run tauri dev -- --config ./src-tauri/tauri.macos-x86_64.conf.json",
|
||||||
|
"tauri:build:macos-x64": "npm run tauri build -- --config ./src-tauri/tauri.macos-x86_64.conf.json",
|
||||||
|
"tauri:dev:macos-arm64": "npm run tauri dev -- --config ./src-tauri/tauri.macos-aarch64.conf.json",
|
||||||
|
"tauri:build:macos-arm64": "npm run tauri build -- --config ./src-tauri/tauri.macos-aarch64.conf.json",
|
||||||
|
"download": "node ./scripts/download-bins.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@tanstack/devtools-vite": "^0.5.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@tanstack/react-devtools": "^0.9.6",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@tanstack/react-pacer": "^0.20.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@tanstack/react-pacer-devtools": "^0.5.2",
|
||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@radix-ui/react-collapsible": "^1.1.11",
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
"@radix-ui/react-context-menu": "^2.2.15",
|
"@tauri-apps/api": "^2.10.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||||
"@radix-ui/react-hover-card": "^1.1.14",
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@tauri-apps/plugin-log": "^2.8.0",
|
||||||
"@radix-ui/react-menubar": "^1.1.15",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@radix-ui/react-popover": "^1.1.14",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
"@tauri-apps/plugin-sql": "^2.3.2",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@tauri-apps/plugin-updater": "^2.10.0",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
|
||||||
"@radix-ui/react-slider": "^1.3.5",
|
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.9",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
|
||||||
"@tanstack/react-query": "^5.83.0",
|
|
||||||
"@tanstack/react-query-devtools": "^5.83.0",
|
|
||||||
"@tauri-apps/api": "^2",
|
|
||||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
|
||||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
|
||||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
|
||||||
"@tauri-apps/plugin-os": "^2.3.0",
|
|
||||||
"@tauri-apps/plugin-process": "^2.3.0",
|
|
||||||
"@tauri-apps/plugin-shell": "^2.3.0",
|
|
||||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
|
||||||
"@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",
|
"lucide-react": "^0.576.0",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.525.0",
|
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.1.0",
|
"radix-ui": "^1.4.3",
|
||||||
"react-day-picker": "^9.8.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.60.0",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^4.7.0",
|
||||||
"react-router-dom": "^7.6.3",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^2.15.4",
|
"sonner": "^2.0.7",
|
||||||
"sonner": "^2.0.6",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"ulid": "^3.0.2",
|
||||||
"vaul": "^1.1.2",
|
"zod": "^4.3.6",
|
||||||
"zod": "^3.25.76",
|
"zustand": "^5.0.11"
|
||||||
"zustand": "^5.0.6"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2.10.0",
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.2.1",
|
||||||
"tw-animate-css": "^1.3.5",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { execSync } from 'child_process';
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
// Define array of binary source directories
|
// Define array of binary source directories
|
||||||
const binSrcDirs = [
|
const binSrcDirs = [
|
||||||
path.join(__dirname, 'src-tauri', 'binaries'),
|
path.join(projectRoot, 'src-tauri', 'binaries'),
|
||||||
path.join(__dirname, 'src-tauri', 'resources', 'binaries'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function makeFilesExecutable() {
|
function makeFilesExecutable() {
|
||||||
@@ -47,5 +47,5 @@ function makeFilesExecutable() {
|
|||||||
console.log(`\nSummary: Made ${totalCount} files executable across ${successDirs} directories`);
|
console.log(`\nSummary: Made ${totalCount} files executable across ${successDirs} directories`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`RUNNING: 🛠️ Build Script makeFilesExecutable.js`);
|
console.log(`RUNNING: 🛠️ Build Script --> chmod.js`);
|
||||||
makeFilesExecutable();
|
makeFilesExecutable();
|
||||||
554
scripts/download-bins.js
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const downloadDir = path.join(projectRoot, 'src-tauri', 'resources', 'downloads');
|
||||||
|
const binDir = path.join(projectRoot, 'src-tauri', 'binaries');
|
||||||
|
|
||||||
|
const platform = os.platform();
|
||||||
|
const targetPlatform = process.argv[2];
|
||||||
|
const targetBin = process.argv[3];
|
||||||
|
|
||||||
|
const versions = {
|
||||||
|
'yt-dlp': 'latest',
|
||||||
|
'ffmpeg-ffprobe': 'latest',
|
||||||
|
'deno': 'latest',
|
||||||
|
'aria2c': '1.37.0',
|
||||||
|
'neodlp-pot': '0.7.2'
|
||||||
|
};
|
||||||
|
|
||||||
|
const binaries = {
|
||||||
|
'yt-dlp': [
|
||||||
|
{
|
||||||
|
name: 'yt-dlp-x86_64-pc-windows-msvc',
|
||||||
|
platform: 'win32',
|
||||||
|
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp.exe`,
|
||||||
|
src: path.join(downloadDir, 'yt-dlp-x86_64-pc-windows-msvc.exe'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'yt-dlp-x86_64-pc-windows-msvc.exe')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'yt-dlp-x86_64-pc-windows-msvc.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'yt-dlp-x86_64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_linux`,
|
||||||
|
src: path.join(downloadDir, 'yt-dlp-x86_64-unknown-linux-gnu'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'yt-dlp-x86_64-unknown-linux-gnu')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'yt-dlp-x86_64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'yt-dlp-aarch64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_linux_aarch64`,
|
||||||
|
src: path.join(downloadDir, 'yt-dlp-aarch64-unknown-linux-gnu'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'yt-dlp-aarch64-unknown-linux-gnu')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'yt-dlp-aarch64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'yt-dlp-universal-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/yt-dlp/yt-dlp-nightly-builds/releases${versions['yt-dlp'] === 'latest' ? '/latest' : ''}/download${versions['yt-dlp'] !== 'latest' ? '/'+versions['yt-dlp'] : ''}/yt-dlp_macos`,
|
||||||
|
src: path.join(downloadDir, 'yt-dlp-universal-apple-darwin'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'yt-dlp-x86_64-apple-darwin'),
|
||||||
|
path.join(binDir, 'yt-dlp-aarch64-apple-darwin')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'yt-dlp-universal-apple-darwin')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'ffmpeg-ffprobe': [
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-ffprobe-x86_64-pc-windows-msvc',
|
||||||
|
platform: 'win32',
|
||||||
|
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-win64-gpl.zip`,
|
||||||
|
src: path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl', 'bin', 'ffmpeg.exe'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl', 'bin', 'ffprobe.exe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'ffmpeg-x86_64-pc-windows-msvc.exe'),
|
||||||
|
path.join(binDir, 'ffprobe-x86_64-pc-windows-msvc.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl.zip'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-win64-gpl')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-ffprobe-x86_64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-linux64-gpl.tar.xz`,
|
||||||
|
src: path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl.tar.xz'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'tar.xz',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl', 'bin', 'ffmpeg'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl', 'bin', 'ffprobe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'ffmpeg-x86_64-unknown-linux-gnu'),
|
||||||
|
path.join(binDir, 'ffprobe-x86_64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl.tar.xz'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linux64-gpl')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-ffprobe-aarch64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/yt-dlp/FFmpeg-Builds/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-master-latest-linuxarm64-gpl.tar.xz`,
|
||||||
|
src: path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'tar.xz',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl', 'bin', 'ffmpeg'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl', 'bin', 'ffprobe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'ffmpeg-aarch64-unknown-linux-gnu'),
|
||||||
|
path.join(binDir, 'ffprobe-aarch64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl.tar.xz'),
|
||||||
|
path.join(downloadDir, 'ffmpeg-master-latest-linuxarm64-gpl')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: 'ffmpeg-universal-apple-darwin',
|
||||||
|
// platform: 'darwin',
|
||||||
|
// url: `https://evermeet.cx/ffmpeg/get/zip`,
|
||||||
|
// src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
|
||||||
|
// dest: null,
|
||||||
|
// archive: {
|
||||||
|
// type: 'zip',
|
||||||
|
// binSrc: [
|
||||||
|
// path.join(downloadDir, 'ffmpeg'),
|
||||||
|
// path.join(downloadDir, 'ffmpeg')
|
||||||
|
// ],
|
||||||
|
// binDest: [
|
||||||
|
// path.join(binDir, 'ffmpeg-x86_64-apple-darwin'),
|
||||||
|
// path.join(binDir, 'ffmpeg-aarch64-apple-darwin')
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// cleanup: [
|
||||||
|
// path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
|
||||||
|
// path.join(downloadDir, 'ffmpeg')
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'ffprobe-universal-apple-darwin',
|
||||||
|
// platform: 'darwin',
|
||||||
|
// url: `https://evermeet.cx/ffmpeg/get/ffprobe/zip`,
|
||||||
|
// src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
|
||||||
|
// dest: null,
|
||||||
|
// archive: {
|
||||||
|
// type: 'zip',
|
||||||
|
// binSrc: [
|
||||||
|
// path.join(downloadDir, 'ffprobe'),
|
||||||
|
// path.join(downloadDir, 'ffprobe')
|
||||||
|
// ],
|
||||||
|
// binDest: [
|
||||||
|
// path.join(binDir, 'ffprobe-x86_64-apple-darwin'),
|
||||||
|
// path.join(binDir, 'ffprobe-aarch64-apple-darwin')
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
// cleanup: [
|
||||||
|
// path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
|
||||||
|
// path.join(downloadDir, 'ffprobe')
|
||||||
|
// ]
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
name: 'ffmpeg-universal-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffmpeg-universal-apple-darwin.zip`,
|
||||||
|
src: path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'ffmpeg'),
|
||||||
|
path.join(downloadDir, 'ffmpeg')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'ffmpeg-x86_64-apple-darwin'),
|
||||||
|
path.join(binDir, 'ffmpeg-aarch64-apple-darwin')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'ffmpeg-universal-apple-darwin.zip'),
|
||||||
|
path.join(downloadDir, 'ffmpeg')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ffprobe-universal-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/neosubhamoy/evermeet-static-ffmpeg/releases${versions['ffmpeg-ffprobe'] === 'latest' ? '/latest' : ''}/download${versions['ffmpeg-ffprobe'] !== 'latest' ? '/'+versions['ffmpeg-ffprobe'] : ''}/ffprobe-universal-apple-darwin.zip`,
|
||||||
|
src: path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'ffprobe'),
|
||||||
|
path.join(downloadDir, 'ffprobe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'ffprobe-x86_64-apple-darwin'),
|
||||||
|
path.join(binDir, 'ffprobe-aarch64-apple-darwin')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'ffprobe-universal-apple-darwin.zip'),
|
||||||
|
path.join(downloadDir, 'ffprobe')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'deno': [
|
||||||
|
{
|
||||||
|
name: 'deno-x86_64-pc-windows-msvc',
|
||||||
|
platform: 'win32',
|
||||||
|
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-pc-windows-msvc.zip`,
|
||||||
|
src: path.join(downloadDir, 'deno-x86_64-pc-windows-msvc.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'deno.exe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'deno-x86_64-pc-windows-msvc.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'deno-x86_64-pc-windows-msvc.zip'),
|
||||||
|
path.join(downloadDir, 'deno.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deno-x86_64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-unknown-linux-gnu.zip`,
|
||||||
|
src: path.join(downloadDir, 'deno-x86_64-unknown-linux-gnu.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'deno-x86_64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'deno-x86_64-unknown-linux-gnu.zip'),
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deno-aarch64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-aarch64-unknown-linux-gnu.zip`,
|
||||||
|
src: path.join(downloadDir, 'deno-aarch64-unknown-linux-gnu.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'deno-aarch64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'deno-aarch64-unknown-linux-gnu.zip'),
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deno-x86_64-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-x86_64-apple-darwin.zip`,
|
||||||
|
src: path.join(downloadDir, 'deno-x86_64-apple-darwin.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'deno-x86_64-apple-darwin')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'deno-x86_64-apple-darwin.zip'),
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'deno-aarch64-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/denoland/deno/releases${versions['deno'] === 'latest' ? '/latest' : ''}/download${versions['deno'] !== 'latest' ? '/'+versions['deno'] : ''}/deno-aarch64-apple-darwin.zip`,
|
||||||
|
src: path.join(downloadDir, 'deno-aarch64-apple-darwin.zip'),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'deno-aarch64-apple-darwin')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'deno-aarch64-apple-darwin.zip'),
|
||||||
|
path.join(downloadDir, 'deno')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'aria2c': [
|
||||||
|
{
|
||||||
|
name: 'aria2c-x86_64-pc-windows-msvc',
|
||||||
|
platform: 'win32',
|
||||||
|
url: `https://github.com/aria2/aria2/releases/download/release-${versions['aria2c']}/aria2-${versions['aria2c']}-win-64bit-build1.zip`,
|
||||||
|
src: path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1.zip`),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1`, 'aria2c.exe')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'aria2c-x86_64-pc-windows-msvc.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1.zip`),
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-win-64bit-build1`)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'aria2c-x86_64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/asdo92/aria2-static-builds/releases/download/v${versions['aria2c']}/aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`,
|
||||||
|
src: path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'tar.bz2',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1`, 'aria2c')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'aria2c-x86_64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1.tar.bz2`),
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-linux-gnu-64bit-build1`)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'aria2c-aarch64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/aria2/aria2/releases/download/release-${versions['aria2c']}/aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`,
|
||||||
|
src: path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`),
|
||||||
|
dest: null,
|
||||||
|
archive: {
|
||||||
|
type: 'zip',
|
||||||
|
binSrc: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`, 'aria2c')
|
||||||
|
],
|
||||||
|
binDest: [
|
||||||
|
path.join(binDir, 'aria2c-aarch64-unknown-linux-gnu')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1.zip`),
|
||||||
|
path.join(downloadDir, `aria2-${versions['aria2c']}-aarch64-linux-android-build1`)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'neodlp-pot': [
|
||||||
|
{
|
||||||
|
name: 'neodlp-pot-x86_64-pc-windows-msvc',
|
||||||
|
platform: 'win32',
|
||||||
|
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-windows-x86_64.exe`,
|
||||||
|
src: path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'neodlp-pot-x86_64-pc-windows-msvc.exe')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'bgutil-pot-windows-x86_64.exe')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'neodlp-pot-x86_64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-x86_64`,
|
||||||
|
src: path.join(downloadDir, 'bgutil-pot-linux-x86_64'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'neodlp-pot-x86_64-unknown-linux-gnu')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'bgutil-pot-linux-x86_64')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'neodlp-pot-aarch64-unknown-linux-gnu',
|
||||||
|
platform: 'linux',
|
||||||
|
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-linux-aarch64`,
|
||||||
|
src: path.join(downloadDir, 'bgutil-pot-linux-aarch64'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'neodlp-pot-aarch64-unknown-linux-gnu')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'bgutil-pot-linux-aarch64')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'neodlp-pot-x86_64-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-x86_64`,
|
||||||
|
src: path.join(downloadDir, 'bgutil-pot-macos-x86_64'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'neodlp-pot-x86_64-apple-darwin')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'bgutil-pot-macos-x86_64')
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'neodlp-pot-aarch64-apple-darwin',
|
||||||
|
platform: 'darwin',
|
||||||
|
url: `https://github.com/jim60105/bgutil-ytdlp-pot-provider-rs/releases${versions['neodlp-pot'] === 'latest' ? '/latest' : ''}/download${versions['neodlp-pot'] !== 'latest' ? '/v'+versions['neodlp-pot'] : ''}/bgutil-pot-macos-aarch64`,
|
||||||
|
src: path.join(downloadDir, 'bgutil-pot-macos-aarch64'),
|
||||||
|
dest: [
|
||||||
|
path.join(binDir, 'neodlp-pot-aarch64-apple-darwin')
|
||||||
|
],
|
||||||
|
archive: null,
|
||||||
|
cleanup: [
|
||||||
|
path.join(downloadDir, 'bgutil-pot-macos-aarch64')
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function downloadAndProcess(bin) {
|
||||||
|
console.log(`=> Processing: ${bin.name}`);
|
||||||
|
console.log(`Downloading: ${bin.url}`);
|
||||||
|
if (platform === 'win32') {
|
||||||
|
execSync(`powershell -Command "Invoke-WebRequest -Uri '${bin.url}' -OutFile '${bin.src}'"`, { stdio: 'inherit' });
|
||||||
|
} else {
|
||||||
|
execSync(`curl -L "${bin.url}" -o "${bin.src}"`, { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bin.archive) {
|
||||||
|
console.log(`Extracting: ${bin.src}`);
|
||||||
|
if (platform === 'win32' && bin.archive.type === 'zip') {
|
||||||
|
execSync(`powershell -Command "Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${bin.src}', '${downloadDir}')"`, { stdio: 'inherit' });
|
||||||
|
} else if (bin.archive.type === 'tar.bz2') {
|
||||||
|
execSync(`tar -xjf "${bin.src}" -C "${downloadDir}"`, { stdio: 'inherit' });
|
||||||
|
} else if (bin.archive.type === 'zip') {
|
||||||
|
execSync(`unzip -o "${bin.src}" -d "${downloadDir}"`, { stdio: 'inherit' });
|
||||||
|
} else {
|
||||||
|
execSync(`tar -xf "${bin.src}" -C "${downloadDir}"`, { stdio: 'inherit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
bin.archive.binSrc.forEach((src, index) => {
|
||||||
|
const dest = bin.archive.binDest[index];
|
||||||
|
console.log(`Moving: "${src}" to "${dest}"`);
|
||||||
|
fs.copyFileSync(src, dest);
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
fs.chmodSync(dest, 0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (bin.dest) {
|
||||||
|
bin.dest.forEach((dest) => {
|
||||||
|
console.log(`Moving: "${bin.src}" to "${dest}"`);
|
||||||
|
fs.copyFileSync(bin.src, dest);
|
||||||
|
if (platform !== 'win32') {
|
||||||
|
fs.chmodSync(dest, 0o755);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bin.cleanup.forEach((item) => {
|
||||||
|
if (fs.existsSync(item)) {
|
||||||
|
console.log(`Cleaning: "${item}"`);
|
||||||
|
const stats = fs.statSync(item);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
fs.rmSync(item, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (targetPlatform && !['win32', 'linux', 'darwin', 'all'].includes(targetPlatform)) {
|
||||||
|
console.error(`ERROR: Invalid platform specified: '${targetPlatform}'. Use one of: win32, linux, darwin, or all`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetBin && !binaries.hasOwnProperty(targetBin) && targetBin !== 'all') {
|
||||||
|
console.error(`ERROR: Invalid binary specified: '${targetBin}'. Use one of: ${Object.keys(binaries).join(', ')}, or all`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePlatform = targetPlatform || platform;
|
||||||
|
const effectiveBin = targetBin || 'all';
|
||||||
|
|
||||||
|
console.log(`RUNNING: 📦 Binary Downloader (platform: ${effectivePlatform} | binary: ${effectiveBin})`);
|
||||||
|
|
||||||
|
Object.keys(binaries).forEach((binKey) => {
|
||||||
|
if (effectiveBin !== 'all' && binKey !== effectiveBin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
binaries[binKey].forEach((bin) => {
|
||||||
|
if (effectivePlatform !== 'all' && bin.platform !== effectivePlatform) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAndProcess(bin);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Downloads Completed');
|
||||||
@@ -5,8 +5,9 @@ import { fileURLToPath } from 'url';
|
|||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
console.log(`RUNNING: 🛠️ Build Script updateYtDlpBinary.js`);
|
console.log(`RUNNING: 🛠️ Build Script --> update-yt-dlp.js`);
|
||||||
|
|
||||||
// Get the platform triple from command line arguments
|
// Get the platform triple from command line arguments
|
||||||
const platformTriple = process.argv[2];
|
const platformTriple = process.argv[2];
|
||||||
@@ -17,7 +18,7 @@ if (!platformTriple) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define the binaries directory
|
// Define the binaries directory
|
||||||
const binariesDir = path.join(__dirname, 'src-tauri', 'binaries');
|
const binariesDir = path.join(projectRoot, 'src-tauri', 'binaries');
|
||||||
|
|
||||||
// Construct the binary filename based on platform triple
|
// Construct the binary filename based on platform triple
|
||||||
let binaryName = `yt-dlp-${platformTriple}`;
|
let binaryName = `yt-dlp-${platformTriple}`;
|
||||||
3134
src-tauri/Cargo.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "neodlp"
|
name = "neodlp"
|
||||||
version = "0.2.0"
|
version = "0.4.2"
|
||||||
description = "NeoDLP"
|
description = "Cross-platform Video/Audio Downloader Desktop App based on YT-DLP with Modern UI and Browser Integration"
|
||||||
authors = ["neosubhamoy <hey@neosubhamoy.com>"]
|
authors = ["neosubhamoy <hey@neosubhamoy.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -22,13 +22,15 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tauri = { version = "2", features = ["tray-icon"] }
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.13", features = ["json"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-tungstenite = "*"
|
tokio-tungstenite = "*"
|
||||||
sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] }
|
sqlx = { version = "0.8", features = [ "sqlite", "runtime-tokio", "tls-native-tls" ] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
directories = "5.0"
|
directories = "6.0"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
log = "0.4"
|
||||||
|
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
@@ -36,6 +38,9 @@ tauri-plugin-os = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
|
tauri-plugin-log = "2"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
0
src-tauri/binaries/.gitkeep
Normal file
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
|
|
||||||
size 34391824
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:fc5bd50ef656f1727d6f1c6c55688b21434e70cb5fb3e439701d1061ae094bf0
|
|
||||||
size 34391824
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:f061f5be11e96c3764442b5339f91102316ac9a7eb8270f017e7912ed0384eba
|
|
||||||
size 18112457
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:0aa0afe3d2b32c047b73083f3c8e56081d71bb33fe047357820d51d153d1d54f
|
|
||||||
size 34608400
|
|
||||||
@@ -10,6 +10,10 @@
|
|||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
@@ -22,14 +26,10 @@
|
|||||||
"fs:allow-app-write-recursive",
|
"fs:allow-app-write-recursive",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
"process:default",
|
"process:default",
|
||||||
{
|
"clipboard-manager:allow-read-text",
|
||||||
"identifier": "opener:allow-open-path",
|
"clipboard-manager:allow-write-text",
|
||||||
"allow": [
|
"notification:default",
|
||||||
{
|
"log:default",
|
||||||
"path": "**"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
"allow": [
|
"allow": [
|
||||||
@@ -37,6 +37,14 @@
|
|||||||
"path": "**"
|
"path": "**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "opener:allow-open-path",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"path": "**"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,65 @@
|
|||||||
"args": true,
|
"args": true,
|
||||||
"sidecar": true
|
"sidecar": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/ffmpeg",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/ffprobe",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/aria2c",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/deno",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/neodlp-pot",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "yt-dlp",
|
||||||
|
"cmd": "yt-dlp",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ffmpeg",
|
||||||
|
"cmd": "ffmpeg",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aria2c",
|
||||||
|
"cmd": "aria2c",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deno",
|
||||||
|
"cmd": "deno",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pkexec",
|
"name": "pkexec",
|
||||||
"cmd": "pkexec",
|
"cmd": "pkexec",
|
||||||
"args": true
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "powershell",
|
||||||
|
"cmd": "powershell",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sh",
|
||||||
|
"cmd": "sh",
|
||||||
|
"args": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -29,6 +84,66 @@
|
|||||||
"name": "binaries/yt-dlp",
|
"name": "binaries/yt-dlp",
|
||||||
"args": true,
|
"args": true,
|
||||||
"sidecar": true
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/ffmpeg",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/ffprobe",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/aria2c",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/deno",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "binaries/neodlp-pot",
|
||||||
|
"args": true,
|
||||||
|
"sidecar": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "yt-dlp",
|
||||||
|
"cmd": "yt-dlp",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ffmpeg",
|
||||||
|
"cmd": "ffmpeg",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "aria2c",
|
||||||
|
"cmd": "aria2c",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deno",
|
||||||
|
"cmd": "deno",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pkexec",
|
||||||
|
"cmd": "pkexec",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "powershell",
|
||||||
|
"cmd": "powershell",
|
||||||
|
"args": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sh",
|
||||||
|
"cmd": "sh",
|
||||||
|
"args": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
!macro NSIS_HOOK_POSTINSTALL
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
; Add Registry Keys for Chrome Native Messaging Host
|
; Add Registry Keys for Chrome Native Messaging Host
|
||||||
WriteRegStr HKCU "Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\neodlp-msghost.json"
|
WriteRegStr HKCU "Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\chrome.json"
|
||||||
; Add Registry Keys for Firefox Native Messaging Host
|
; Add Registry Keys for Firefox Native Messaging Host
|
||||||
WriteRegStr HKCU "Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\neodlp-msghost-moz.json"
|
WriteRegStr HKCU "Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" "" "$INSTDIR\firefox.json"
|
||||||
; Add entry for automatic startup with Windows
|
; Add entry for automatic startup with Windows
|
||||||
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" "$\"$INSTDIR\neodlp.exe$\" --hidden"
|
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" "$\"$INSTDIR\neodlp.exe$\" --hidden"
|
||||||
!macroend
|
!macroend
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
<DirectoryRef Id="TARGETDIR">
|
<DirectoryRef Id="TARGETDIR">
|
||||||
<Component Id="NeoDlpRegEntriesFragment" Guid="*">
|
<Component Id="NeoDlpRegEntriesFragment" Guid="*">
|
||||||
<RegistryKey Root="HKLM" Key="Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
|
<RegistryKey Root="HKLM" Key="Software\Google\Chrome\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
|
||||||
<RegistryValue Type="string" Value="[INSTALLDIR]neodlp-msghost.json" KeyPath="no" />
|
<RegistryValue Type="string" Value="[INSTALLDIR]chrome.json" KeyPath="no" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
<RegistryKey Root="HKLM" Key="Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
|
<RegistryKey Root="HKLM" Key="Software\Mozilla\NativeMessagingHosts\com.neosubhamoy.neodlp" Action="createAndRemoveOnUninstall">
|
||||||
<RegistryValue Type="string" Value="[INSTALLDIR]neodlp-msghost-moz.json" KeyPath="no" />
|
<RegistryValue Type="string" Value="[INSTALLDIR]firefox.json" KeyPath="no" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
<RegistryKey Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Run">
|
<RegistryKey Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\Run">
|
||||||
<RegistryValue Name="NeoDLP" Type="string" Value=""[INSTALLDIR]neodlp.exe" --hidden" KeyPath="no" />
|
<RegistryValue Name="NeoDLP" Type="string" Value=""[INSTALLDIR]neodlp.exe" --hidden" KeyPath="no" />
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ license = "MIT"
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-tungstenite = "*"
|
tokio-tungstenite = "*"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
directories = "5.0"
|
directories = "6.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
|
|
||||||
size 79945800
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:e607b7f079c4eb0dc666ffca152f225020f8022c8c014dd94d91e6072f57228d
|
|
||||||
size 79945800
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:c49b5913c9a107120c86b401af95df7965003f7fc6dbb4436f1f03c8ba391e8b
|
|
||||||
size 127473664
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:3ee15e5145c9eb4775c193ab824c592d4ff3744bb7f283f8db29bd3c3c961589
|
|
||||||
size 79928672
|
|
||||||
0
src-tauri/resources/downloads/.gitkeep
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__version__ = '1.3.0'
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import json
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
ExternalRequestFeature,
|
||||||
|
PoTokenContext,
|
||||||
|
PoTokenProvider,
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
)
|
||||||
|
from yt_dlp.extractor.youtube.pot.utils import WEBPO_CLIENTS
|
||||||
|
from yt_dlp.utils import js_to_json
|
||||||
|
from yt_dlp.utils.traversal import traverse_obj
|
||||||
|
|
||||||
|
|
||||||
|
class BgUtilPTPBase(PoTokenProvider, abc.ABC):
|
||||||
|
PROVIDER_VERSION = __version__
|
||||||
|
BUG_REPORT_LOCATION = (
|
||||||
|
'https://github.com/jim60105/bgutil-ytdlp-pot-provider/issues'
|
||||||
|
)
|
||||||
|
_SUPPORTED_EXTERNAL_REQUEST_FEATURES = (
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_HTTP,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_HTTPS,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_SOCKS4,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_SOCKS4A,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_SOCKS5,
|
||||||
|
ExternalRequestFeature.PROXY_SCHEME_SOCKS5H,
|
||||||
|
ExternalRequestFeature.SOURCE_ADDRESS,
|
||||||
|
ExternalRequestFeature.DISABLE_TLS_VERIFICATION,
|
||||||
|
)
|
||||||
|
_SUPPORTED_CLIENTS = WEBPO_CLIENTS
|
||||||
|
_SUPPORTED_CONTEXTS = (
|
||||||
|
PoTokenContext.GVS,
|
||||||
|
PoTokenContext.PLAYER,
|
||||||
|
PoTokenContext.SUBS,
|
||||||
|
)
|
||||||
|
_GETPOT_TIMEOUT = 20.0
|
||||||
|
_GET_SERVER_VSN_TIMEOUT = 5.0
|
||||||
|
_MIN_NODE_VSN = (18, 0, 0)
|
||||||
|
|
||||||
|
def _info_and_raise(self, msg, raise_from=None):
|
||||||
|
self.logger.info(msg)
|
||||||
|
raise PoTokenProviderRejectedRequest(msg) from raise_from
|
||||||
|
|
||||||
|
def _warn_and_raise(self, msg, once=True, raise_from=None):
|
||||||
|
self.logger.warning(msg, once=once)
|
||||||
|
raise PoTokenProviderRejectedRequest(msg) from raise_from
|
||||||
|
|
||||||
|
def _get_attestation(self, webpage: str | None):
|
||||||
|
if not webpage:
|
||||||
|
return None
|
||||||
|
raw_cd = (
|
||||||
|
traverse_obj(
|
||||||
|
self.ie._search_regex(
|
||||||
|
r'''(?sx)window\s*\.\s*ytAtN\s*\(\s*
|
||||||
|
(?P<js>\{.+?}\s*)
|
||||||
|
\s*\)\s*;''',
|
||||||
|
webpage,
|
||||||
|
'ytAtN challenge',
|
||||||
|
default=None),
|
||||||
|
({js_to_json}, {json.loads}, 'R'))
|
||||||
|
or traverse_obj(
|
||||||
|
self.ie._search_regex(
|
||||||
|
r'''(?sx)window\.ytAtR\s*=\s*(?P<raw_cd>(?P<q>['"])
|
||||||
|
(?:
|
||||||
|
\\.|
|
||||||
|
(?!(?P=q)).
|
||||||
|
)*
|
||||||
|
(?P=q))\s*;''',
|
||||||
|
webpage,
|
||||||
|
'ytAtR challenge',
|
||||||
|
default=None),
|
||||||
|
({js_to_json}, {json.loads})))
|
||||||
|
if att_txt := traverse_obj(raw_cd, ({json.loads}, 'bgChallenge')):
|
||||||
|
return att_txt
|
||||||
|
self.logger.warning('Failed to extract initial attestation from the webpage')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ['__version__']
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import os.path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenProviderError,
|
||||||
|
PoTokenRequest,
|
||||||
|
PoTokenResponse,
|
||||||
|
register_preference,
|
||||||
|
register_provider,
|
||||||
|
)
|
||||||
|
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
|
||||||
|
from yt_dlp.utils import Popen
|
||||||
|
|
||||||
|
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
|
||||||
|
|
||||||
|
|
||||||
|
@register_provider
|
||||||
|
class BgUtilCliPTP(BgUtilPTPBase):
|
||||||
|
PROVIDER_NAME = 'bgutil:cli'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._check_cli = functools.cache(self._check_cli_impl)
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def _cli_path(self):
|
||||||
|
cli_path = self._configuration_arg(
|
||||||
|
'cli_path', casesense=True, default=[None])[0]
|
||||||
|
|
||||||
|
if cli_path:
|
||||||
|
return os.path.expandvars(cli_path)
|
||||||
|
|
||||||
|
# check deprecated arg
|
||||||
|
deprecated_cli_path = self.ie._configuration_arg(
|
||||||
|
ie_key='youtube', key='getpot_bgutil_script', default=[None])[0]
|
||||||
|
|
||||||
|
if deprecated_cli_path:
|
||||||
|
self._warn_and_raise(
|
||||||
|
"'youtube:getpot_bgutil_script' extractor arg is deprecated, "
|
||||||
|
"use 'youtubepot-bgutilcli:cli_path' instead")
|
||||||
|
|
||||||
|
# default if no arg was passed
|
||||||
|
# First, try to find the executable in PATH
|
||||||
|
if self._get_executable_path('bgutil-pot'):
|
||||||
|
self.logger.debug('Found bgutil-pot in PATH')
|
||||||
|
return 'bgutil-pot'
|
||||||
|
|
||||||
|
# Then check common file locations
|
||||||
|
file_paths = [
|
||||||
|
os.path.join(
|
||||||
|
os.getcwd(), 'target', 'debug', 'bgutil-pot'
|
||||||
|
),
|
||||||
|
os.path.join(
|
||||||
|
os.getcwd(), 'target', 'release', 'bgutil-pot'
|
||||||
|
),
|
||||||
|
os.path.expanduser(
|
||||||
|
'~/bgutil-ytdlp-pot-provider/target/debug/bgutil-pot'
|
||||||
|
),
|
||||||
|
os.path.expanduser(
|
||||||
|
'~/bgutil-ytdlp-pot-provider/target/release/'
|
||||||
|
'bgutil-pot'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in file_paths:
|
||||||
|
if self._get_executable_path(path):
|
||||||
|
self.logger.debug(f'Found bgutil-pot at: {path}')
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Fallback to PATH name if no file found
|
||||||
|
default_path = 'bgutil-pot'
|
||||||
|
self.logger.debug(
|
||||||
|
f'No CLI path found, defaulting to {default_path}')
|
||||||
|
return default_path
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return self._check_cli(self._cli_path)
|
||||||
|
|
||||||
|
def _get_executable_path(self, cli_path):
|
||||||
|
"""Get the actual executable path, checking PATH or file existence."""
|
||||||
|
# For relative names (like 'bgutil-pot-generate'), search in PATH
|
||||||
|
if os.path.sep not in cli_path:
|
||||||
|
executable_path = shutil.which(cli_path)
|
||||||
|
if executable_path:
|
||||||
|
return executable_path
|
||||||
|
|
||||||
|
# For absolute/relative paths, check file existence directly
|
||||||
|
if os.path.isfile(cli_path):
|
||||||
|
return cli_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_cli_impl(self, cli_path):
|
||||||
|
executable_path = self._get_executable_path(cli_path)
|
||||||
|
if not executable_path:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Executable path doesn't exist: {cli_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
stdout, stderr, returncode = Popen.run(
|
||||||
|
[executable_path, '--version'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=self._GET_SERVER_VSN_TIMEOUT
|
||||||
|
)
|
||||||
|
if returncode:
|
||||||
|
self.logger.warning(
|
||||||
|
f'Failed to check executable version. '
|
||||||
|
f'Executable returned {returncode} exit status. '
|
||||||
|
f'stdout: {stdout}; stderr: {stderr}',
|
||||||
|
once=True)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.logger.debug(f'bgutil-pot version: {stdout.strip()}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _real_request_pot(
|
||||||
|
self,
|
||||||
|
request: PoTokenRequest,
|
||||||
|
) -> PoTokenResponse:
|
||||||
|
# used for CI check
|
||||||
|
self.logger.trace(
|
||||||
|
f'Generating POT via Rust executable: {self._cli_path}')
|
||||||
|
|
||||||
|
executable_path = self._get_executable_path(self._cli_path)
|
||||||
|
if not executable_path:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'Executable not found: {self._cli_path}')
|
||||||
|
|
||||||
|
command_args = [executable_path]
|
||||||
|
if proxy := request.request_proxy:
|
||||||
|
command_args.extend(['-p', proxy])
|
||||||
|
command_args.extend(['-c', get_webpo_content_binding(request)[0]])
|
||||||
|
if request.bypass_cache:
|
||||||
|
command_args.append('--bypass-cache')
|
||||||
|
if request.request_source_address:
|
||||||
|
command_args.extend(
|
||||||
|
['--source-address', request.request_source_address])
|
||||||
|
if request.request_verify_tls is False:
|
||||||
|
command_args.append('--disable-tls-verification')
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f'Generating a {request.context.value} PO Token for '
|
||||||
|
f'{request.internal_client_name} client via bgutil '
|
||||||
|
f'Rust executable',
|
||||||
|
)
|
||||||
|
self.logger.debug(
|
||||||
|
f'Executing command to get POT via Rust executable: '
|
||||||
|
f'{" ".join(command_args)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout, stderr, returncode = Popen.run(
|
||||||
|
command_args,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=self._GETPOT_TIMEOUT
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'_get_pot_via_cli failed: Timeout expired when trying '
|
||||||
|
f'to run executable (caused by {e!r})'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'_get_pot_via_cli failed: Unable to run executable '
|
||||||
|
f'(caused by {e!r})'
|
||||||
|
) from e
|
||||||
|
|
||||||
|
msg = ''
|
||||||
|
if stdout_extra := stdout.strip().splitlines()[:-1]:
|
||||||
|
msg = f'stdout:\n{stdout_extra}\n'
|
||||||
|
if stderr_stripped := stderr.strip(): # Empty strings are falsy
|
||||||
|
msg += f'stderr:\n{stderr_stripped}\n'
|
||||||
|
msg = msg.strip()
|
||||||
|
if msg:
|
||||||
|
self.logger.trace(msg)
|
||||||
|
if returncode:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'_get_pot_via_cli failed with returncode {returncode}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
json_resp = stdout.splitlines()[-1]
|
||||||
|
self.logger.trace(f'JSON response:\n{json_resp}')
|
||||||
|
# The JSON response is always the last line
|
||||||
|
cli_data_resp = json.loads(json_resp)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'Error parsing JSON response from _get_pot_via_cli '
|
||||||
|
f'(caused by {e!r})'
|
||||||
|
) from e
|
||||||
|
if 'poToken' not in cli_data_resp:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
'The executable did not respond with a po_token')
|
||||||
|
return PoTokenResponse(po_token=cli_data_resp['poToken'])
|
||||||
|
|
||||||
|
|
||||||
|
@register_preference(BgUtilCliPTP)
|
||||||
|
def bgutil_cli_getpot_preference(provider, request):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [BgUtilCliPTP.__name__,
|
||||||
|
bgutil_cli_getpot_preference.__name__]
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from yt_dlp.extractor.youtube.pot.provider import (
|
||||||
|
PoTokenProviderError,
|
||||||
|
PoTokenProviderRejectedRequest,
|
||||||
|
PoTokenRequest,
|
||||||
|
PoTokenResponse,
|
||||||
|
register_preference,
|
||||||
|
register_provider,
|
||||||
|
)
|
||||||
|
from yt_dlp.extractor.youtube.pot.utils import get_webpo_content_binding
|
||||||
|
from yt_dlp.networking.common import Request
|
||||||
|
from yt_dlp.networking.exceptions import HTTPError, TransportError
|
||||||
|
|
||||||
|
from yt_dlp_plugins.extractor.getpot_bgutil import BgUtilPTPBase
|
||||||
|
|
||||||
|
|
||||||
|
@register_provider
|
||||||
|
class BgUtilHTTPPTP(BgUtilPTPBase):
|
||||||
|
PROVIDER_NAME = 'bgutil:http'
|
||||||
|
DEFAULT_BASE_URL = 'http://127.0.0.1:4416'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._last_server_check = 0
|
||||||
|
self._server_available = True
|
||||||
|
|
||||||
|
@functools.cached_property
|
||||||
|
def _base_url(self):
|
||||||
|
base_url = self._configuration_arg('base_url', default=[None])[0]
|
||||||
|
|
||||||
|
if base_url:
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
# check deprecated arg
|
||||||
|
deprecated_base_url = self.ie._configuration_arg(
|
||||||
|
ie_key='youtube', key='getpot_bgutil_baseurl', default=[None])[0]
|
||||||
|
if deprecated_base_url:
|
||||||
|
self._warn_and_raise(
|
||||||
|
"'youtube:getpot_bgutil_baseurl' extractor arg is deprecated, "
|
||||||
|
"use 'youtubepot-bgutilhttp:base_url' instead"
|
||||||
|
)
|
||||||
|
|
||||||
|
# default if no arg was passed
|
||||||
|
self.logger.debug(
|
||||||
|
f'No base_url provided, defaulting to {self.DEFAULT_BASE_URL}')
|
||||||
|
return self.DEFAULT_BASE_URL
|
||||||
|
|
||||||
|
def _check_server_availability(self, ctx: PoTokenRequest):
|
||||||
|
if self._last_server_check + 60 > time.time():
|
||||||
|
return self._server_available
|
||||||
|
|
||||||
|
self._server_available = False
|
||||||
|
try:
|
||||||
|
self.logger.trace(
|
||||||
|
f'Checking server availability at {self._base_url}/ping')
|
||||||
|
response = json.load(self._request_webpage(Request(
|
||||||
|
f'{self._base_url}/ping',
|
||||||
|
extensions={'timeout': self._GET_SERVER_VSN_TIMEOUT},
|
||||||
|
proxies={'all': None}
|
||||||
|
),
|
||||||
|
note=False))
|
||||||
|
except TransportError as e:
|
||||||
|
# the server may be down
|
||||||
|
script_path_provided = self.ie._configuration_arg(
|
||||||
|
ie_key='youtubepot-bgutilscript',
|
||||||
|
key='script_path',
|
||||||
|
default=[None]
|
||||||
|
)[0] is not None
|
||||||
|
|
||||||
|
warning_base = (
|
||||||
|
f'Error reaching GET {self._base_url}/ping '
|
||||||
|
f'(caused by {e.__class__.__name__}). '
|
||||||
|
)
|
||||||
|
if script_path_provided: # server down is expected, log info
|
||||||
|
self._info_and_raise(
|
||||||
|
warning_base +
|
||||||
|
'This is expected if you are using the script method.'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._warn_and_raise(
|
||||||
|
warning_base +
|
||||||
|
f'Please make sure that the server is reachable at '
|
||||||
|
f'{self._base_url}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
except HTTPError as e:
|
||||||
|
# may be an old server, don't raise
|
||||||
|
self.logger.warning(
|
||||||
|
f'HTTP Error reaching GET /ping (caused by {e!r})', once=True)
|
||||||
|
return
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# invalid server
|
||||||
|
self._warn_and_raise(
|
||||||
|
f'Error parsing ping response JSON (caused by {e!r})')
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
self._warn_and_raise(
|
||||||
|
f'Unknown error reaching GET /ping (caused by {e!r})',
|
||||||
|
raise_from=e
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
version = response.get("version", "unknown")
|
||||||
|
self.logger.debug(f'HTTP server version: {version}')
|
||||||
|
self._server_available = True
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
self._last_server_check = time.time()
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return (self._server_available or
|
||||||
|
self._last_server_check + 60 < int(time.time()))
|
||||||
|
|
||||||
|
def _real_request_pot(
|
||||||
|
self,
|
||||||
|
request: PoTokenRequest,
|
||||||
|
) -> PoTokenResponse:
|
||||||
|
if not self._check_server_availability(request):
|
||||||
|
raise PoTokenProviderRejectedRequest(
|
||||||
|
f'{self.PROVIDER_NAME} server is not available')
|
||||||
|
|
||||||
|
# used for CI check
|
||||||
|
self.logger.trace('Generating POT via HTTP server')
|
||||||
|
|
||||||
|
disable_innertube = bool(
|
||||||
|
self._configuration_arg('disable_innertube', default=[None])[0]
|
||||||
|
)
|
||||||
|
challenge = self._get_attestation(
|
||||||
|
None if disable_innertube else request.video_webpage
|
||||||
|
)
|
||||||
|
# The challenge is falsy when the webpage and the challenge are
|
||||||
|
# unavailable. In this case, we need to disable /att/get since
|
||||||
|
# it's broken for web_music
|
||||||
|
if not challenge and request.internal_client_name == 'web_music':
|
||||||
|
if not disable_innertube: # if not already set, warn the user
|
||||||
|
self.logger.warning(
|
||||||
|
'BotGuard challenges could not be obtained from the '
|
||||||
|
'webpage, overriding disable_innertube=True because '
|
||||||
|
'InnerTube challenges are currently broken for the '
|
||||||
|
'web_music client. Pass disable_innertube=1 to suppress '
|
||||||
|
'this warning.'
|
||||||
|
)
|
||||||
|
disable_innertube = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._request_webpage(
|
||||||
|
request=Request(
|
||||||
|
f'{self._base_url}/get_pot', data=json.dumps({
|
||||||
|
'bypass_cache': request.bypass_cache,
|
||||||
|
'challenge': challenge,
|
||||||
|
'content_binding': get_webpo_content_binding(
|
||||||
|
request
|
||||||
|
)[0],
|
||||||
|
'disable_innertube': disable_innertube,
|
||||||
|
'disable_tls_verification': (
|
||||||
|
not request.request_verify_tls
|
||||||
|
),
|
||||||
|
'proxy': request.request_proxy,
|
||||||
|
'innertube_context': request.innertube_context,
|
||||||
|
'source_address': request.request_source_address,
|
||||||
|
}).encode(), headers={'Content-Type': 'application/json'},
|
||||||
|
extensions={'timeout': self._GETPOT_TIMEOUT},
|
||||||
|
proxies={'all': None}
|
||||||
|
),
|
||||||
|
note=f'Generating a {request.context.value} PO Token for '
|
||||||
|
f'{request.internal_client_name} client via bgutil '
|
||||||
|
f'HTTP server',
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'Error reaching POST /get_pot (caused by {e!r})') from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_json = json.load(response)
|
||||||
|
except Exception as e:
|
||||||
|
response_data = response.read().decode()
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'Error parsing response JSON (caused by {e!r}). '
|
||||||
|
f'response = {response_data}'
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if error_msg := response_json.get('error'):
|
||||||
|
raise PoTokenProviderError(error_msg)
|
||||||
|
if 'poToken' not in response_json:
|
||||||
|
raise PoTokenProviderError(
|
||||||
|
f'Server did not respond with a poToken. '
|
||||||
|
f'Received response: {response}'
|
||||||
|
)
|
||||||
|
|
||||||
|
po_token = response_json['poToken']
|
||||||
|
self.logger.trace(f'Generated POT: {po_token}')
|
||||||
|
return PoTokenResponse(po_token=po_token)
|
||||||
|
|
||||||
|
|
||||||
|
@register_preference(BgUtilHTTPPTP)
|
||||||
|
def bgutil_HTTP_getpot_preference(provider, request):
|
||||||
|
return 130
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [BgUtilHTTPPTP.__name__,
|
||||||
|
bgutil_HTTP_getpot_preference.__name__]
|
||||||
@@ -21,6 +21,7 @@ use std::{
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{Menu, MenuItem},
|
menu::{Menu, MenuItem},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
path::BaseDirectory,
|
||||||
Emitter, Manager, State,
|
Emitter, Manager, State,
|
||||||
};
|
};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
@@ -30,6 +31,7 @@ use tokio::{
|
|||||||
time::sleep,
|
time::sleep,
|
||||||
};
|
};
|
||||||
use tokio_tungstenite::accept_async;
|
use tokio_tungstenite::accept_async;
|
||||||
|
use log::{info, error};
|
||||||
|
|
||||||
struct ImageCache(StdMutex<HashMap<String, String>>);
|
struct ImageCache(StdMutex<HashMap<String, String>>);
|
||||||
|
|
||||||
@@ -174,6 +176,26 @@ fn get_config_file_path() -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_current_app_path() -> Result<String, String> {
|
||||||
|
let exe_path = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||||
|
Ok(exe_path
|
||||||
|
.parent()
|
||||||
|
.ok_or("Failed to get parent directory")?
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn is_flatpak() -> bool {
|
||||||
|
std::env::var("FLATPAK").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_appimage_path() -> Option<String> {
|
||||||
|
std::env::var("APPDIR").ok()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn update_config(
|
async fn update_config(
|
||||||
new_config: Config,
|
new_config: Config,
|
||||||
@@ -336,21 +358,48 @@ async fn open_file_with_app(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if let Some(name) = &app_name {
|
if let Some(name) = &app_name {
|
||||||
if name == "explorer" {
|
if name == "explorer" {
|
||||||
println!("Revealing file: {} in explorer", file_path);
|
info!("Revealing file: {} in explorer", file_path);
|
||||||
return app_handle
|
return app_handle
|
||||||
.opener()
|
.opener()
|
||||||
.reveal_item_in_dir(file_path)
|
.reveal_item_in_dir(file_path)
|
||||||
.map_err(|e| e.to_string());
|
.map_err(|e| {
|
||||||
|
error!("Failed to reveal file in explorer: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
println!("Opening file: {} with app: {}", file_path, name);
|
info!("Opening file: {} with app: {}", file_path, name);
|
||||||
} else {
|
} else {
|
||||||
println!("Opening file: {} with default app", file_path);
|
info!("Opening file: {} with default app", file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
app_handle
|
app_handle
|
||||||
.opener()
|
.opener()
|
||||||
.open_path(file_path, app_name)
|
.open_path(file_path, app_name)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| {
|
||||||
|
error!("Failed to open file: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_link_with_app(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
url: String,
|
||||||
|
app_name: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Some(name) = &app_name {
|
||||||
|
info!("Opening link: {} with app: {}", url, name);
|
||||||
|
} else {
|
||||||
|
info!("Opening link: {} with default app", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
app_handle
|
||||||
|
.opener()
|
||||||
|
.open_url(url, app_name)
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to open link: {}", e);
|
||||||
|
e.to_string()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -452,6 +501,7 @@ async fn pause_ongoing_downloads(
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub async fn run() {
|
pub async fn run() {
|
||||||
|
let _ = fix_path_env::fix();
|
||||||
let migrations = migrations::get_migrations();
|
let migrations = migrations::get_migrations();
|
||||||
let config = load_config();
|
let config = load_config();
|
||||||
let port = config.port;
|
let port = config.port;
|
||||||
@@ -466,6 +516,11 @@ pub async fn run() {
|
|||||||
let start_hidden = args.contains(&"--hidden".to_string());
|
let start_hidden = args.contains(&"--hidden".to_string());
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_log::Builder::new()
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.max_file_size(5_242_880) /* in bytes = 5MB */
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
// Focus the main window when attempting to launch another instance
|
// Focus the main window when attempting to launch another instance
|
||||||
@@ -474,8 +529,7 @@ pub async fn run() {
|
|||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(
|
.plugin(tauri_plugin_sql::Builder::default()
|
||||||
tauri_plugin_sql::Builder::default()
|
|
||||||
.add_migrations("sqlite:database.db", migrations)
|
.add_migrations("sqlite:database.db", migrations)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
@@ -485,6 +539,8 @@ pub async fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.manage(ImageCache(StdMutex::new(HashMap::new())))
|
.manage(ImageCache(StdMutex::new(HashMap::new())))
|
||||||
.manage(websocket_state.clone())
|
.manage(websocket_state.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
@@ -557,6 +613,20 @@ pub async fn run() {
|
|||||||
.build(app)
|
.build(app)
|
||||||
.map_err(|e| format!("Failed to create tray: {}", e))?;
|
.map_err(|e| format!("Failed to create tray: {}", e))?;
|
||||||
|
|
||||||
|
// Fix tray icon in sandboxed environments (e.g., Flatpak)
|
||||||
|
// libappindicator uses the full path of the icon in dbus messages,
|
||||||
|
// so the path needs to be accessible from both the host and the sandbox.
|
||||||
|
// The default /tmp path doesn't work across sandbox boundaries.
|
||||||
|
if let Ok(local_data_path) = app
|
||||||
|
.path()
|
||||||
|
.resolve("tray-icon", BaseDirectory::AppLocalData)
|
||||||
|
{
|
||||||
|
let _ = fs::create_dir_all(&local_data_path);
|
||||||
|
let _ = tray.set_temp_dir_path(Some(local_data_path));
|
||||||
|
// Re-set the icon so it gets written to the new temp dir path
|
||||||
|
let _ = tray.set_icon(Some(app.default_window_icon().unwrap().clone()));
|
||||||
|
}
|
||||||
|
|
||||||
app.manage(tray);
|
app.manage(tray);
|
||||||
|
|
||||||
let window = app.get_webview_window("main").unwrap();
|
let window = app.get_webview_window("main").unwrap();
|
||||||
@@ -577,6 +647,7 @@ pub async fn run() {
|
|||||||
kill_all_process,
|
kill_all_process,
|
||||||
fetch_image,
|
fetch_image,
|
||||||
open_file_with_app,
|
open_file_with_app,
|
||||||
|
open_link_with_app,
|
||||||
list_ongoing_downloads,
|
list_ongoing_downloads,
|
||||||
pause_ongoing_downloads,
|
pause_ongoing_downloads,
|
||||||
send_to_extension,
|
send_to_extension,
|
||||||
@@ -586,6 +657,9 @@ pub async fn run() {
|
|||||||
reset_config,
|
reset_config,
|
||||||
get_config_file_path,
|
get_config_file_path,
|
||||||
restart_websocket_server,
|
restart_websocket_server,
|
||||||
|
get_current_app_path,
|
||||||
|
is_flatpak,
|
||||||
|
get_appimage_path
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -3,5 +3,12 @@
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
neodlp_lib::run().await
|
neodlp_lib::run().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,5 +68,174 @@ pub fn get_migrations() -> Vec<Migration> {
|
|||||||
);
|
);
|
||||||
",
|
",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 2,
|
||||||
|
description: "add_columns_to_downloads",
|
||||||
|
sql: "
|
||||||
|
-- Create temporary table with all new columns
|
||||||
|
CREATE TABLE downloads_temp (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
download_id TEXT UNIQUE NOT NULL,
|
||||||
|
download_status TEXT NOT NULL,
|
||||||
|
video_id TEXT NOT NULL,
|
||||||
|
format_id TEXT NOT NULL,
|
||||||
|
subtitle_id TEXT,
|
||||||
|
queue_index INTEGER,
|
||||||
|
playlist_id TEXT,
|
||||||
|
playlist_index INTEGER,
|
||||||
|
resolution TEXT,
|
||||||
|
ext TEXT,
|
||||||
|
abr REAL,
|
||||||
|
vbr REAL,
|
||||||
|
acodec TEXT,
|
||||||
|
vcodec TEXT,
|
||||||
|
dynamic_range TEXT,
|
||||||
|
process_id INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
progress REAL,
|
||||||
|
total INTEGER,
|
||||||
|
downloaded INTEGER,
|
||||||
|
speed REAL,
|
||||||
|
eta INTEGER,
|
||||||
|
filepath TEXT,
|
||||||
|
filetype TEXT,
|
||||||
|
filesize INTEGER,
|
||||||
|
output_format TEXT,
|
||||||
|
embed_metadata INTEGER NOT NULL DEFAULT 0,
|
||||||
|
embed_thumbnail INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sponsorblock_remove TEXT,
|
||||||
|
sponsorblock_mark TEXT,
|
||||||
|
use_aria2 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
custom_command TEXT,
|
||||||
|
queue_config TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (video_id) REFERENCES video_info (video_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlist_info (playlist_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copy all data from original table to temporary table with default values for new columns
|
||||||
|
INSERT INTO downloads_temp SELECT
|
||||||
|
id, download_id, download_status, video_id, format_id, subtitle_id,
|
||||||
|
queue_index, playlist_id, playlist_index, resolution, ext, abr, vbr,
|
||||||
|
acodec, vcodec, dynamic_range, process_id, status, progress, total,
|
||||||
|
downloaded, speed, eta, filepath, filetype, filesize,
|
||||||
|
NULL, -- output_format
|
||||||
|
0, -- embed_metadata
|
||||||
|
0, -- embed_thumbnail
|
||||||
|
NULL, -- sponsorblock_remove
|
||||||
|
NULL, -- sponsorblock_mark
|
||||||
|
0, -- use_aria2
|
||||||
|
NULL, -- custom_command
|
||||||
|
NULL, -- queue_config
|
||||||
|
CURRENT_TIMESTAMP, -- created_at
|
||||||
|
CURRENT_TIMESTAMP -- updated_at
|
||||||
|
FROM downloads;
|
||||||
|
|
||||||
|
-- Drop the original table
|
||||||
|
DROP TABLE downloads;
|
||||||
|
|
||||||
|
-- Rename temporary table to original name
|
||||||
|
ALTER TABLE downloads_temp RENAME TO downloads;
|
||||||
|
|
||||||
|
-- Create trigger for updating updated_at timestamp
|
||||||
|
CREATE TRIGGER IF NOT EXISTS update_downloads_updated_at
|
||||||
|
AFTER UPDATE ON downloads
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE downloads SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
|
",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 3,
|
||||||
|
description: "add_more_columns_and_indices_to_downloads",
|
||||||
|
sql: "
|
||||||
|
-- Create temporary table with all new columns
|
||||||
|
CREATE TABLE downloads_temp (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
download_id TEXT UNIQUE NOT NULL,
|
||||||
|
download_status TEXT NOT NULL,
|
||||||
|
video_id TEXT NOT NULL,
|
||||||
|
format_id TEXT NOT NULL,
|
||||||
|
subtitle_id TEXT,
|
||||||
|
queue_index INTEGER,
|
||||||
|
playlist_id TEXT,
|
||||||
|
playlist_indices TEXT,
|
||||||
|
resolution TEXT,
|
||||||
|
ext TEXT,
|
||||||
|
abr REAL,
|
||||||
|
vbr REAL,
|
||||||
|
acodec TEXT,
|
||||||
|
vcodec TEXT,
|
||||||
|
dynamic_range TEXT,
|
||||||
|
process_id INTEGER,
|
||||||
|
status TEXT,
|
||||||
|
item TEXT,
|
||||||
|
progress REAL,
|
||||||
|
total INTEGER,
|
||||||
|
downloaded INTEGER,
|
||||||
|
speed REAL,
|
||||||
|
eta INTEGER,
|
||||||
|
filepath TEXT,
|
||||||
|
filetype TEXT,
|
||||||
|
filesize INTEGER,
|
||||||
|
output_format TEXT,
|
||||||
|
embed_metadata INTEGER NOT NULL DEFAULT 0,
|
||||||
|
embed_thumbnail INTEGER NOT NULL DEFAULT 0,
|
||||||
|
square_crop_thumbnail INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sponsorblock_remove TEXT,
|
||||||
|
sponsorblock_mark TEXT,
|
||||||
|
use_aria2 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
custom_command TEXT,
|
||||||
|
queue_config TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (video_id) REFERENCES video_info (video_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlist_info (playlist_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Copy all data from original table to temporary table with default values for new columns
|
||||||
|
INSERT INTO downloads_temp SELECT
|
||||||
|
id, download_id, download_status, video_id, format_id, subtitle_id,
|
||||||
|
queue_index, playlist_id,
|
||||||
|
CAST(playlist_index AS TEXT), -- Convert INTEGER playlist_index to TEXT playlist_indices
|
||||||
|
resolution, ext, abr, vbr,
|
||||||
|
acodec, vcodec, dynamic_range, process_id, status,
|
||||||
|
CASE WHEN playlist_id IS NOT NULL THEN '1/1' ELSE NULL END, -- item
|
||||||
|
progress, total, downloaded, speed, eta,
|
||||||
|
filepath, filetype, filesize,
|
||||||
|
output_format, embed_metadata, embed_thumbnail,
|
||||||
|
0, -- square_crop_thumbnail
|
||||||
|
sponsorblock_remove, sponsorblock_mark, use_aria2,
|
||||||
|
custom_command, queue_config, created_at, updated_at
|
||||||
|
FROM downloads;
|
||||||
|
|
||||||
|
-- Remove existing triggers
|
||||||
|
DROP TRIGGER IF EXISTS update_downloads_updated_at;
|
||||||
|
|
||||||
|
-- Drop the original table
|
||||||
|
DROP TABLE downloads;
|
||||||
|
|
||||||
|
-- Rename temporary table to original name
|
||||||
|
ALTER TABLE downloads_temp RENAME TO downloads;
|
||||||
|
|
||||||
|
-- Create trigger for updating updated_at timestamp
|
||||||
|
CREATE TRIGGER IF NOT EXISTS update_downloads_updated_at
|
||||||
|
AFTER UPDATE ON downloads
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
UPDATE downloads SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Add indexes to improve query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloads_video_id ON downloads(video_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloads_playlist_id ON downloads(playlist_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloads_status_updated ON downloads(download_status, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloads_id_desc ON downloads(id DESC);
|
||||||
|
",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.2.0",
|
"version": "0.4.2",
|
||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -36,6 +36,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
"sql": {
|
||||||
|
"preload": ["sqlite:database.db"]
|
||||||
|
},
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDM0I4ODcyODdGOTM4MDIKUldRQ09QbUhjb2c3UENGY1lFUVdTVWhucmJ4QzdGeW9sU3VHVFlGNWY5anZab2s4SU1rMWFsekMK",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDNDM0I4ODcyODdGOTM4MDIKUldRQ09QbUhjb2c3UENGY1lFUVdTVWhucmJ4QzdGeW9sU3VHVFlGNWY5anZab2s4SU1rMWFsekMK",
|
||||||
"endpoints": [
|
"endpoints": [
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
|
"beforeDevCommand": "cargo build --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
||||||
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-unknown-linux-gnu && npm run build",
|
"beforeBuildCommand": "cargo build --release --target=aarch64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "NeoDLP",
|
"title": "NeoDLP",
|
||||||
"width": 1067,
|
"width": 1080,
|
||||||
"height": 605,
|
"height": 680,
|
||||||
|
"decorations": false,
|
||||||
"visible": false
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"targets": ["deb", "rpm"],
|
"targets": ["deb", "rpm"],
|
||||||
"createUpdaterArtifacts": true,
|
"createUpdaterArtifacts": true,
|
||||||
"licenseFile": "../LICENSE",
|
"licenseFile": "../LICENSE",
|
||||||
|
"category": "Utility",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -36,29 +38,34 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/yt-dlp"
|
"binaries/yt-dlp",
|
||||||
|
"binaries/aria2c",
|
||||||
|
"binaries/deno",
|
||||||
|
"binaries/neodlp-pot"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"resources/binaries/ffmpeg-x86_64-unknown-linux-gnu": "binaries/ffmpeg-x86_64"
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
|
"depends": ["ffmpeg"],
|
||||||
"files": {
|
"files": {
|
||||||
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
||||||
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
|
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
|
||||||
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rpm": {
|
"rpm": {
|
||||||
"epoch": 0,
|
"epoch": 0,
|
||||||
"release": "1",
|
"release": "1",
|
||||||
|
"depends": ["ffmpeg"],
|
||||||
"files": {
|
"files": {
|
||||||
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
||||||
"/usr/bin/neodlp-msghost": "./target/release/neodlp-msghost",
|
"/usr/bin/neodlp-msghost": "./target/aarch64-unknown-linux-gnu/release/neodlp-msghost",
|
||||||
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
46
src-tauri/tauri.linux-flatpak.conf.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "NeoDLP",
|
||||||
|
"width": 1080,
|
||||||
|
"height": 680,
|
||||||
|
"decorations": false,
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null,
|
||||||
|
"capabilities": [
|
||||||
|
"default",
|
||||||
|
"shell-scope"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["deb"],
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"licenseFile": "../LICENSE",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/deno"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src-tauri/tauri.linux-x86_64.conf.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "cargo build --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
||||||
|
"beforeBuildCommand": "cargo build --release --target=x86_64-unknown-linux-gnu --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "NeoDLP",
|
||||||
|
"width": 1080,
|
||||||
|
"height": 680,
|
||||||
|
"decorations": false,
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null,
|
||||||
|
"capabilities": [
|
||||||
|
"default",
|
||||||
|
"shell-scope"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["deb", "rpm", "appimage"],
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
|
"licenseFile": "../LICENSE",
|
||||||
|
"category": "Utility",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/yt-dlp",
|
||||||
|
"binaries/aria2c",
|
||||||
|
"binaries/deno",
|
||||||
|
"binaries/neodlp-pot"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"deb": {
|
||||||
|
"depends": ["ffmpeg"],
|
||||||
|
"files": {
|
||||||
|
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
|
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
|
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
||||||
|
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
|
||||||
|
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rpm": {
|
||||||
|
"epoch": 0,
|
||||||
|
"release": "1",
|
||||||
|
"depends": ["ffmpeg"],
|
||||||
|
"files": {
|
||||||
|
"/etc/opt/chrome/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
|
"/etc/chromium/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/chrome.json",
|
||||||
|
"/usr/lib/mozilla/native-messaging-hosts/com.neosubhamoy.neodlp.json": "./resources/msghost-manifest/linux/firefox.json",
|
||||||
|
"/usr/bin/neodlp-msghost": "./target/x86_64-unknown-linux-gnu/release/neodlp-msghost",
|
||||||
|
"/etc/xdg/autostart/neodlp-autostart.desktop": "./resources/autostart/linux/autostart.desktop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appimage": {
|
||||||
|
"files": {
|
||||||
|
"/usr/bin/ffmpeg": "./binaries/ffmpeg-x86_64-unknown-linux-gnu",
|
||||||
|
"/usr/bin/ffprobe": "./binaries/ffprobe-x86_64-unknown-linux-gnu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
|
"beforeDevCommand": "cargo build --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
||||||
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js aarch64-apple-darwin && npm run build",
|
"beforeBuildCommand": "cargo build --release --target=aarch64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
@@ -36,14 +36,18 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/yt-dlp"
|
"binaries/yt-dlp",
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe",
|
||||||
|
"binaries/deno",
|
||||||
|
"binaries/neodlp-pot"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
|
"target/aarch64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
|
||||||
"resources/binaries/ffmpeg-aarch64-apple-darwin": "binaries/ffmpeg-aarch64",
|
|
||||||
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
|
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
|
||||||
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
|
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
|
||||||
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"
|
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
|
||||||
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
},
|
},
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"providerShortName": "neosubhamoy"
|
"providerShortName": "neosubhamoy"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && npm run dev",
|
"beforeDevCommand": "cargo build --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
||||||
"beforeBuildCommand": "[[ -n \"$TARGET_ARCH\" ]] && ARCH=\"$TARGET_ARCH\" || ARCH=\"$(uname -m | sed 's/^arm64$/aarch64/')-apple-darwin\" && cargo build --release --target=$ARCH --manifest-path=./src-tauri/msghost/Cargo.toml && node makeFilesExecutable.js && node updateYtDlpBinary.js x86_64-apple-darwin && npm run build",
|
"beforeBuildCommand": "cargo build --release --target=x86_64-apple-darwin --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
@@ -36,14 +36,18 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/yt-dlp"
|
"binaries/yt-dlp",
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe",
|
||||||
|
"binaries/deno",
|
||||||
|
"binaries/neodlp-pot"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
|
"target/x86_64-apple-darwin/release/neodlp-msghost": "neodlp-msghost",
|
||||||
"resources/binaries/ffmpeg-x86_64-apple-darwin": "binaries/ffmpeg-x86_64",
|
|
||||||
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
|
"resources/msghost-manifest/macos/chrome.json": "neodlp-msghost.json",
|
||||||
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
|
"resources/msghost-manifest/macos/firefox.json": "neodlp-msghost-moz.json",
|
||||||
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist"
|
"resources/autostart/macos/autostart.plist": "neodlp-autostart.plist",
|
||||||
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
},
|
},
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"providerShortName": "neosubhamoy"
|
"providerShortName": "neosubhamoy"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"identifier": "com.neosubhamoy.neodlp",
|
"identifier": "com.neosubhamoy.neodlp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
"beforeDevCommand": "cargo build --manifest-path=./src-tauri/msghost/Cargo.toml && npm run dev",
|
||||||
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && node updateYtDlpBinary.js x86_64-pc-windows-msvc && npm run build",
|
"beforeBuildCommand": "cargo build --release --manifest-path=./src-tauri/msghost/Cargo.toml && npm run build",
|
||||||
"devUrl": "http://localhost:1420",
|
"devUrl": "http://localhost:1420",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
@@ -10,8 +10,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "NeoDLP",
|
"title": "NeoDLP",
|
||||||
"width": 1067,
|
"width": 1080,
|
||||||
"height": 605,
|
"height": 680,
|
||||||
|
"decorations": false,
|
||||||
"visible": false
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -36,13 +37,18 @@
|
|||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"externalBin": [
|
"externalBin": [
|
||||||
"binaries/yt-dlp"
|
"binaries/yt-dlp",
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe",
|
||||||
|
"binaries/aria2c",
|
||||||
|
"binaries/deno",
|
||||||
|
"binaries/neodlp-pot"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
"resources/binaries/ffmpeg-x86_64-pc-windows-msvc.exe": "binaries/ffmpeg-x86_64.exe",
|
|
||||||
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
|
"target/release/neodlp-msghost.exe": "neodlp-msghost.exe",
|
||||||
"resources/msghost-manifest/windows/chrome.json": "neodlp-msghost.json",
|
"resources/msghost-manifest/windows/chrome.json": "chrome.json",
|
||||||
"resources/msghost-manifest/windows/firefox.json": "neodlp-msghost-moz.json"
|
"resources/msghost-manifest/windows/firefox.json": "firefox.json",
|
||||||
|
"resources/plugins/yt-dlp-plugins/": "yt-dlp-plugins/"
|
||||||
},
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
"wix": {
|
"wix": {
|
||||||
|
|||||||
805
src/App.tsx
@@ -1,21 +1,17 @@
|
|||||||
import { ThemeProvider } from "@/providers/themeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
|
||||||
import { AppContext } from "@/providers/appContextProvider";
|
import { AppContext } from "@/providers/appContextProvider";
|
||||||
import { DownloadState } from "@/types/download";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
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, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useEnvironmentStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
import { determineFileType, generateDownloadId, generateSafeFilePath, generateVideoId, isObjEmpty, parseProgressLine, sanitizeFilename } from "@/utils";
|
import { isObjEmpty} from "@/utils";
|
||||||
import { Command } from "@tauri-apps/plugin-shell";
|
import { Command } from "@tauri-apps/plugin-shell";
|
||||||
import { RawVideoInfo } from "@/types/video";
|
import { useUpdateDownloadStatus } from "@/services/mutations";
|
||||||
import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadStatus } from "@/services/mutations";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useFetchAllDownloadStates, useFetchAllkVPairs, useFetchAllSettings } from "@/services/queries";
|
import { useFetchAllDownloadStates, useFetchAllkVPairs, useFetchAllSettings } from "@/services/queries";
|
||||||
import { config } from "@/config";
|
import { config } from "@/config";
|
||||||
import * as fs from "@tauri-apps/plugin-fs";
|
// import * as fs from "@tauri-apps/plugin-fs";
|
||||||
import { useYtDlpUpdater } from "@/helpers/use-ytdlp-updater";
|
import { useYtDlpUpdater } from "@/helpers/use-ytdlp-updater";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
@@ -25,11 +21,16 @@ 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";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useLogger } from "@/helpers/use-logger";
|
||||||
|
import useDownloader from "@/helpers/use-downloader";
|
||||||
|
import usePotServer from "@/helpers/use-pot-server";
|
||||||
|
import { useLinuxRegisterer } from "@/helpers/use-linux-registerer";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
|
||||||
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();
|
||||||
@@ -39,568 +40,61 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates);
|
const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates);
|
||||||
const setPath = useBasePathsStore((state) => state.setPath);
|
const setPath = useBasePathsStore((state) => state.setPath);
|
||||||
|
|
||||||
const ffmpegPath = useBasePathsStore((state) => state.ffmpegPath);
|
const setIsFlatpak = useEnvironmentStore((state) => state.setIsFlatpak);
|
||||||
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
|
const setIsAppimage = useEnvironmentStore((state) => state.setIsAppimage);
|
||||||
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
|
const setAppDirPath = useEnvironmentStore((state) => state.setAppDirPath);
|
||||||
|
|
||||||
// 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);
|
||||||
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
|
const appVersion = useSettingsPageStatesStore(state => state.appVersion);
|
||||||
|
const isRunningPotServer = useSettingsPageStatesStore(state => state.isRunningPotServer);
|
||||||
const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion);
|
const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion);
|
||||||
const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion);
|
const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion);
|
||||||
const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion);
|
const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion);
|
||||||
const setAppVersion = useSettingsPageStatesStore((state) => state.setAppVersion);
|
const setAppVersion = useSettingsPageStatesStore((state) => state.setAppVersion);
|
||||||
const setIsFetchingAppVersion = useSettingsPageStatesStore((state) => state.setIsFetchingAppVersion);
|
const setIsFetchingAppVersion = useSettingsPageStatesStore((state) => state.setIsFetchingAppVersion);
|
||||||
const YTDLP_AUTO_UPDATE = useSettingsPageStatesStore(state => state.settings.ytdlp_auto_update);
|
const {
|
||||||
const YTDLP_UPDATE_CHANNEL = useSettingsPageStatesStore(state => state.settings.ytdlp_update_channel);
|
ytdlp_auto_update: YTDLP_AUTO_UPDATE,
|
||||||
const APP_THEME = useSettingsPageStatesStore(state => state.settings.theme);
|
ytdlp_update_channel: YTDLP_UPDATE_CHANNEL,
|
||||||
const MAX_PARALLEL_DOWNLOADS = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads);
|
download_dir: DOWNLOAD_DIR,
|
||||||
const MAX_RETRIES = useSettingsPageStatesStore(state => state.settings.max_retries);
|
theme: APP_THEME,
|
||||||
const DOWNLOAD_DIR = useSettingsPageStatesStore(state => state.settings.download_dir);
|
color_scheme: APP_COLOR_SCHEME,
|
||||||
const PREFER_VIDEO_OVER_PLAYLIST = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist);
|
use_potoken: USE_POTOKEN,
|
||||||
const STRICT_DOWNLOADABILITY_CHECK = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check);
|
} = useSettingsPageStatesStore(state => state.settings);
|
||||||
const USE_PROXY = useSettingsPageStatesStore(state => state.settings.use_proxy);
|
|
||||||
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 erroredDownloadIds = useDownloaderPageStatesStore((state) => state.erroredDownloadIds);
|
||||||
const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected);
|
const expectedErrorDownloadIds = useDownloaderPageStatesStore((state) => state.expectedErrorDownloadIds);
|
||||||
const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId);
|
const removeErroredDownload = useDownloaderPageStatesStore((state) => state.removeErroredDownload);
|
||||||
const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored);
|
const removeExpectedErrorDownload = useDownloaderPageStatesStore((state) => state.removeExpectedErrorDownload);
|
||||||
const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected);
|
|
||||||
const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId);
|
|
||||||
|
|
||||||
const appWindow = getCurrentWebviewWindow()
|
const appWindow = getCurrentWebviewWindow();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const LOG = useLogger();
|
||||||
|
const currentPlatform = platform();
|
||||||
const { updateYtDlp } = useYtDlpUpdater();
|
const { updateYtDlp } = useYtDlpUpdater();
|
||||||
const { registerToMac } = useMacOsRegisterer();
|
const { registerToMac } = useMacOsRegisterer();
|
||||||
|
const { registerToLinux } = useLinuxRegisterer();
|
||||||
const { checkForAppUpdate } = useAppUpdater();
|
const { checkForAppUpdate } = useAppUpdater();
|
||||||
|
const { startPotServer, stopPotServer } = usePotServer();
|
||||||
const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey);
|
const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey);
|
||||||
const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check);
|
const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check);
|
||||||
const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version);
|
const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version);
|
||||||
|
const linuxRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.linux_registered_version);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const downloadStateSaver = useSaveDownloadState();
|
|
||||||
const downloadStatusUpdater = useUpdateDownloadStatus();
|
const downloadStatusUpdater = useUpdateDownloadStatus();
|
||||||
const downloadFilePathUpdater = useUpdateDownloadFilePath();
|
|
||||||
const videoInfoSaver = useSaveVideoInfo();
|
|
||||||
const downloadStateDeleter = useDeleteDownloadState();
|
|
||||||
const playlistInfoSaver = useSavePlaylistInfo();
|
|
||||||
|
|
||||||
const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting');
|
const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting');
|
||||||
const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!);
|
const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!);
|
||||||
|
|
||||||
const isProcessingQueueRef = useRef(false);
|
|
||||||
const lastProcessedDownloadIdRef = useRef<string | null>(null);
|
|
||||||
const hasRunYtDlpAutoUpdateRef = useRef(false);
|
const hasRunYtDlpAutoUpdateRef = useRef(false);
|
||||||
|
const hasRunAppUpdateCheckRef = useRef(false);
|
||||||
|
const hasRunPotServerStatusCheckRef = useRef(false);
|
||||||
const isRegisteredToMacOsRef = useRef(false);
|
const isRegisteredToMacOsRef = useRef(false);
|
||||||
|
const isRegisteredToLinuxRef = useRef(false);
|
||||||
|
const pendingErrorUpdatesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string): Promise<RawVideoInfo | null> => {
|
const { fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload, processQueuedDownloads } = useDownloader();
|
||||||
try {
|
|
||||||
const args = [url, '--dump-single-json', '--no-warnings'];
|
|
||||||
if (formatId) args.push('-f', formatId);
|
|
||||||
if (playlistIndex) args.push('--playlist-items', playlistIndex);
|
|
||||||
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);
|
|
||||||
const command = Command.sidecar('binaries/yt-dlp', args);
|
|
||||||
|
|
||||||
let jsonOutput = '';
|
|
||||||
|
|
||||||
return new Promise<RawVideoInfo | null>((resolve) => {
|
|
||||||
command.stdout.on('data', line => {
|
|
||||||
jsonOutput += line;
|
|
||||||
});
|
|
||||||
|
|
||||||
command.on('close', async () => {
|
|
||||||
try {
|
|
||||||
const data: RawVideoInfo = JSON.parse(jsonOutput);
|
|
||||||
resolve(data);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
console.error(`Failed to parse JSON: ${e}`);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
command.on('error', error => {
|
|
||||||
console.error(`Error fetching metadata: ${error}`);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
command.spawn().catch(e => {
|
|
||||||
console.error(`Failed to spawn command: ${e}`);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to fetch metadata: ${e}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 });
|
|
||||||
if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) {
|
|
||||||
console.error('FFmpeg or download paths not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false;
|
|
||||||
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null;
|
|
||||||
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined);
|
|
||||||
if (!videoMetadata) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Video Metadata:', 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 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 tempDownloadPathForYtdlp = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.%(ext)s`);
|
|
||||||
const tempDownloadPath = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.${videoMetadata.ext}`);
|
|
||||||
let downloadFilePath = resumeState?.filepath || await join(downloadDirPath, sanitizeFilename(`${videoMetadata.title}_${videoMetadata.resolution || 'unknown'}[${videoMetadata.id}].${videoMetadata.ext}`));
|
|
||||||
let processPid: number | null = null;
|
|
||||||
const args = [
|
|
||||||
url,
|
|
||||||
'--newline',
|
|
||||||
'--progress-template',
|
|
||||||
'status:%(progress.status)s,progress:%(progress._percent_str)s,speed:%(progress.speed)f,downloaded:%(progress.downloaded_bytes)d,total:%(progress.total_bytes)d,eta:%(progress.eta)d',
|
|
||||||
'--output',
|
|
||||||
tempDownloadPathForYtdlp,
|
|
||||||
'--ffmpeg-location',
|
|
||||||
ffmpegPath,
|
|
||||||
'-f',
|
|
||||||
selectedFormat,
|
|
||||||
'--no-mtime',
|
|
||||||
'--no-warnings',
|
|
||||||
'--retries',
|
|
||||||
MAX_RETRIES.toString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (selectedSubtitles) {
|
|
||||||
args.push('--embed-subs', '--sub-lang', selectedSubtitles);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') {
|
|
||||||
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) {
|
|
||||||
args.push('--continue');
|
|
||||||
} else {
|
|
||||||
args.push('--no-continue');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (USE_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);
|
|
||||||
const command = Command.sidecar('binaries/yt-dlp', args);
|
|
||||||
|
|
||||||
command.on('close', async data => {
|
|
||||||
if (data.code !== 0) {
|
|
||||||
console.error(`Download failed with code ${data.code}`);
|
|
||||||
if (!isErrorExpected) {
|
|
||||||
setIsErrored(true);
|
|
||||||
setErroredDownloadId(downloadId);
|
|
||||||
}
|
|
||||||
} 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)) {
|
|
||||||
downloadFilePath = await generateSafeFilePath(downloadFilePath);
|
|
||||||
await fs.rename(tempDownloadPath, downloadFilePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath }, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download filepath updated successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to update download filepath:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
command.on('error', error => {
|
|
||||||
console.error(`Error: ${error}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
command.stdout.on('data', line => {
|
|
||||||
if (line.startsWith('status:')) {
|
|
||||||
const currentProgress = parseProgressLine(line);
|
|
||||||
const state: DownloadState = {
|
|
||||||
download_id: downloadId,
|
|
||||||
download_status: 'downloading',
|
|
||||||
video_id: videoId,
|
|
||||||
format_id: selectedFormat,
|
|
||||||
subtitle_id: selectedSubtitles || null,
|
|
||||||
queue_index: null,
|
|
||||||
playlist_id: playlistId,
|
|
||||||
playlist_index: playlistIndex ? Number(playlistIndex) : null,
|
|
||||||
title: videoMetadata.title,
|
|
||||||
url: url,
|
|
||||||
host: videoMetadata.webpage_url_domain,
|
|
||||||
thumbnail: videoMetadata.thumbnail || null,
|
|
||||||
channel: videoMetadata.channel || null,
|
|
||||||
duration_string: videoMetadata.duration_string || null,
|
|
||||||
release_date: videoMetadata.release_date || null,
|
|
||||||
view_count: videoMetadata.view_count || null,
|
|
||||||
like_count: videoMetadata.like_count || null,
|
|
||||||
playlist_title: videoMetadata.playlist_title,
|
|
||||||
playlist_url: videoMetadata.playlist_webpage_url,
|
|
||||||
playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries,
|
|
||||||
playlist_channel: videoMetadata.playlist_channel || null,
|
|
||||||
resolution: videoMetadata.resolution || null,
|
|
||||||
ext: videoMetadata.ext || null,
|
|
||||||
abr: videoMetadata.abr || null,
|
|
||||||
vbr: videoMetadata.vbr || null,
|
|
||||||
acodec: videoMetadata.acodec || null,
|
|
||||||
vcodec: videoMetadata.vcodec || null,
|
|
||||||
dynamic_range: videoMetadata.dynamic_range || null,
|
|
||||||
process_id: processPid,
|
|
||||||
status: currentProgress.status || null,
|
|
||||||
progress: currentProgress.progress || null,
|
|
||||||
total: currentProgress.total || null,
|
|
||||||
downloaded: currentProgress.downloaded || null,
|
|
||||||
speed: currentProgress.speed || null,
|
|
||||||
eta: currentProgress.eta || null,
|
|
||||||
filepath: downloadFilePath,
|
|
||||||
filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null,
|
|
||||||
filesize: videoMetadata.filesize_approx || null
|
|
||||||
};
|
|
||||||
downloadStateSaver.mutate(state, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download State saved successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to save download state:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.log(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
videoInfoSaver.mutate({
|
|
||||||
video_id: videoId,
|
|
||||||
title: videoMetadata.title,
|
|
||||||
url: url,
|
|
||||||
host: videoMetadata.webpage_url_domain,
|
|
||||||
thumbnail: videoMetadata.thumbnail || null,
|
|
||||||
channel: videoMetadata.channel || videoMetadata.uploader || null,
|
|
||||||
duration_string: videoMetadata.duration_string || null,
|
|
||||||
release_date: videoMetadata.release_date || null,
|
|
||||||
view_count: videoMetadata.view_count || null,
|
|
||||||
like_count: videoMetadata.like_count || null
|
|
||||||
}, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Video Info saved successfully:", data);
|
|
||||||
if (isPlaylist) {
|
|
||||||
playlistInfoSaver.mutate({
|
|
||||||
playlist_id: playlistId ? playlistId : '',
|
|
||||||
playlist_title: videoMetadata.playlist_title,
|
|
||||||
playlist_url: videoMetadata.playlist_webpage_url,
|
|
||||||
playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries,
|
|
||||||
playlist_channel: videoMetadata.playlist_channel || null
|
|
||||||
}, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Playlist Info saved successfully:", data);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to save playlist info:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const state: DownloadState = {
|
|
||||||
download_id: downloadId,
|
|
||||||
download_status: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? 'starting' : 'queued',
|
|
||||||
video_id: videoId,
|
|
||||||
format_id: selectedFormat,
|
|
||||||
subtitle_id: selectedSubtitles || null,
|
|
||||||
queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0),
|
|
||||||
playlist_id: playlistId,
|
|
||||||
playlist_index: playlistIndex ? Number(playlistIndex) : null,
|
|
||||||
title: videoMetadata.title,
|
|
||||||
url: url,
|
|
||||||
host: videoMetadata.webpage_url_domain,
|
|
||||||
thumbnail: videoMetadata.thumbnail || null,
|
|
||||||
channel: videoMetadata.channel || null,
|
|
||||||
duration_string: videoMetadata.duration_string || null,
|
|
||||||
release_date: videoMetadata.release_date || null,
|
|
||||||
view_count: videoMetadata.view_count || null,
|
|
||||||
like_count: videoMetadata.like_count || null,
|
|
||||||
playlist_title: videoMetadata.playlist_title,
|
|
||||||
playlist_url: videoMetadata.playlist_webpage_url,
|
|
||||||
playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries,
|
|
||||||
playlist_channel: videoMetadata.playlist_channel || null,
|
|
||||||
resolution: resumeState?.resolution || null,
|
|
||||||
ext: resumeState?.ext || null,
|
|
||||||
abr: resumeState?.abr || null,
|
|
||||||
vbr: resumeState?.vbr || null,
|
|
||||||
acodec: resumeState?.acodec || null,
|
|
||||||
vcodec: resumeState?.vcodec || null,
|
|
||||||
dynamic_range: resumeState?.dynamic_range || null,
|
|
||||||
process_id: resumeState?.process_id || null,
|
|
||||||
status: resumeState?.status || null,
|
|
||||||
progress: resumeState?.progress || null,
|
|
||||||
total: resumeState?.total || null,
|
|
||||||
downloaded: resumeState?.downloaded || null,
|
|
||||||
speed: resumeState?.speed || null,
|
|
||||||
eta: resumeState?.eta || null,
|
|
||||||
filepath: downloadFilePath,
|
|
||||||
filetype: resumeState?.filetype || null,
|
|
||||||
filesize: resumeState?.filesize || null
|
|
||||||
}
|
|
||||||
downloadStateSaver.mutate(state, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download State saved successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to save download state:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to save video info:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) {
|
|
||||||
const child = await command.spawn();
|
|
||||||
processPid = child.pid;
|
|
||||||
return Promise.resolve();
|
|
||||||
} else {
|
|
||||||
console.log("Download is queued, not starting immediately.");
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to start download: ${e}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pauseDownload = async (downloadState: DownloadState) => {
|
|
||||||
try {
|
|
||||||
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' }, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download status updated successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
// Reset the processing flag to ensure queue can be processed
|
|
||||||
isProcessingQueueRef.current = false;
|
|
||||||
|
|
||||||
// Process the queue after a short delay to ensure state is updated
|
|
||||||
setTimeout(() => {
|
|
||||||
processQueuedDownloads();
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to update download status:", error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to pause download: ${e}`);
|
|
||||||
isProcessingQueueRef.current = false;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resumeDownload = async (downloadState: DownloadState) => {
|
|
||||||
try {
|
|
||||||
await startDownload(
|
|
||||||
downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url,
|
|
||||||
downloadState.format_id,
|
|
||||||
downloadState.subtitle_id,
|
|
||||||
downloadState
|
|
||||||
);
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to resume download: ${e}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancelDownload = async (downloadState: DownloadState) => {
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
await invoke('kill_all_process', { pid: downloadState.process_id });
|
|
||||||
}
|
|
||||||
downloadStateDeleter.mutate(downloadState.download_id, {
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Download State deleted successfully:", data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
// Reset processing flag and trigger queue processing
|
|
||||||
isProcessingQueueRef.current = false;
|
|
||||||
|
|
||||||
// Process the queue after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
processQueuedDownloads();
|
|
||||||
}, 1000);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error("Failed to delete download state:", error);
|
|
||||||
isProcessingQueueRef.current = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return Promise.resolve();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to cancel download: ${e}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const processQueuedDownloads = useCallback(async () => {
|
|
||||||
// Prevent concurrent processing
|
|
||||||
if (isProcessingQueueRef.current) {
|
|
||||||
console.log("Queue processing already in progress, skipping...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we can process more downloads
|
|
||||||
if (!queuedDownloads?.length || ongoingDownloads?.length >= MAX_PARALLEL_DOWNLOADS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isProcessingQueueRef.current = true;
|
|
||||||
console.log("Processing download queue...");
|
|
||||||
|
|
||||||
// Get the first download in queue
|
|
||||||
const downloadToStart = queuedDownloads[0];
|
|
||||||
|
|
||||||
// Skip if we just processed this download to prevent loops
|
|
||||||
if (lastProcessedDownloadIdRef.current === downloadToStart.download_id) {
|
|
||||||
console.log("Skipping recently processed download:", downloadToStart.download_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Double-check current state from global state
|
|
||||||
const currentState = globalDownloadStates.find(
|
|
||||||
state => state.download_id === downloadToStart.download_id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!currentState || currentState.download_status !== 'queued') {
|
|
||||||
console.log("Download no longer in queued state:", downloadToStart.download_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Starting queued download:", downloadToStart.download_id);
|
|
||||||
lastProcessedDownloadIdRef.current = downloadToStart.download_id;
|
|
||||||
|
|
||||||
// Update status to 'starting' first
|
|
||||||
await downloadStatusUpdater.mutateAsync({
|
|
||||||
download_id: downloadToStart.download_id,
|
|
||||||
download_status: 'starting'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch latest state after status update
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
|
||||||
|
|
||||||
// Start the download
|
|
||||||
await startDownload(
|
|
||||||
downloadToStart.url,
|
|
||||||
downloadToStart.format_id,
|
|
||||||
downloadToStart.subtitle_id,
|
|
||||||
downloadToStart
|
|
||||||
);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing download queue:", error);
|
|
||||||
} finally {
|
|
||||||
// Important: reset the processing flag
|
|
||||||
setTimeout(() => {
|
|
||||||
isProcessingQueueRef.current = false;
|
|
||||||
console.log("Queue processor released lock");
|
|
||||||
}, 1000); // Small delay to prevent rapid re-processing
|
|
||||||
}
|
|
||||||
}, [queuedDownloads, ongoingDownloads, globalDownloadStates, queryClient]);
|
|
||||||
|
|
||||||
// Prevent right click context menu in production
|
// Prevent right click context menu in production
|
||||||
if (!import.meta.env.DEV) {
|
if (!import.meta.env.DEV) {
|
||||||
@@ -609,13 +103,6 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for App updates
|
|
||||||
useEffect(() => {
|
|
||||||
checkForAppUpdate().catch((error) => {
|
|
||||||
console.error("Error checking for app update:", error);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Prevent app from closing
|
// Prevent app from closing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCloseRequested = (event: any) => {
|
const handleCloseRequested = (event: any) => {
|
||||||
@@ -626,6 +113,39 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
appWindow.onCloseRequested(handleCloseRequested);
|
appWindow.onCloseRequested(handleCloseRequested);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup before page refresh/unload
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (_event: BeforeUnloadEvent) => {
|
||||||
|
if (isRunningPotServer) {
|
||||||
|
stopPotServer();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [stopPotServer]);
|
||||||
|
|
||||||
|
// Detect sandbox environments
|
||||||
|
useEffect(() => {
|
||||||
|
const detectEnvironment = async () => {
|
||||||
|
try {
|
||||||
|
const isFlatpak = await invoke<boolean>('is_flatpak');
|
||||||
|
const appimagePath = await invoke<string | null>('get_appimage_path');
|
||||||
|
console.log('Environment detection results:', { isFlatpak, appimagePath });
|
||||||
|
|
||||||
|
if (isFlatpak) setIsFlatpak(true);
|
||||||
|
if (appimagePath) {
|
||||||
|
setIsAppimage(true);
|
||||||
|
setAppDirPath(appimagePath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to detect environment:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detectEnvironment();
|
||||||
|
}, [setIsFlatpak, setIsAppimage, setAppDirPath]);
|
||||||
|
|
||||||
// Listen for websocket messages
|
// Listen for websocket messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = listen<WebSocketMessage>('websocket-message', (event) => {
|
const unlisten = listen<WebSocketMessage>('websocket-message', (event) => {
|
||||||
@@ -635,6 +155,7 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
appWindow.setFocus();
|
appWindow.setFocus();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
if (event.payload.url) {
|
if (event.payload.url) {
|
||||||
|
LOG.info('NEODLP', `Received search request from neodlp browser extension for URL: ${event.payload.url}`);
|
||||||
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
|
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
|
||||||
setRequestedUrl(event.payload.url);
|
setRequestedUrl(event.payload.url);
|
||||||
setAutoSubmitSearch(true);
|
setAutoSubmitSearch(true);
|
||||||
@@ -651,17 +172,6 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// const fetchConfigPath = async () => {
|
|
||||||
// const configPath = await invoke('get_config_file_path');
|
|
||||||
// console.log("Config path fetched successfully:", configPath);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// fetchConfigPath().catch((error) => {
|
|
||||||
// console.error("Error fetching config path:", error);
|
|
||||||
// });
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
// Fetch download states from database and sync with state
|
// Fetch download states from database and sync with state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccessFetchingSettings && settings) {
|
if (isSuccessFetchingSettings && settings) {
|
||||||
@@ -698,22 +208,23 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const currentArch = arch();
|
const currentArch = arch();
|
||||||
const currentExeExtension = exeExtension();
|
const currentExeExtension = exeExtension();
|
||||||
|
const isFlatpak = await invoke<boolean>('is_flatpak');
|
||||||
const downloadDirPath = await downloadDir();
|
const downloadDirPath = await downloadDir();
|
||||||
const tempDirPath = await tempDir();
|
const tempDirPath = await tempDir();
|
||||||
const resourceDirPath = await resourceDir();
|
const resourceDirPath = await resourceDir();
|
||||||
|
|
||||||
const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`);
|
const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`);
|
||||||
const tempDownloadDirPath = await join(tempDirPath, config.appPkgName, 'downloads');
|
const tempDownloadDirPath = isFlatpak ? await join(downloadDirPath, config.appName, '.tempdownloads') : await join(tempDirPath, config.appPkgName, 'downloads');
|
||||||
const appDownloadDirPath = await join(downloadDirPath, config.appName);
|
const appDownloadDirPath = await join(downloadDirPath, config.appName);
|
||||||
|
|
||||||
if(!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) });
|
// if (!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) });
|
||||||
|
|
||||||
setPath('ffmpegPath', ffmpegPath);
|
setPath('ffmpegPath', ffmpegPath);
|
||||||
setPath('tempDownloadDirPath', tempDownloadDirPath);
|
setPath('tempDownloadDirPath', tempDownloadDirPath);
|
||||||
if (DOWNLOAD_DIR) {
|
if (DOWNLOAD_DIR) {
|
||||||
setPath('downloadDirPath', DOWNLOAD_DIR);
|
setPath('downloadDirPath', DOWNLOAD_DIR);
|
||||||
} else {
|
} else {
|
||||||
if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) });
|
// if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) });
|
||||||
setPath('downloadDirPath', appDownloadDirPath);
|
setPath('downloadDirPath', appDownloadDirPath);
|
||||||
}
|
}
|
||||||
console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath });
|
console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath });
|
||||||
@@ -746,7 +257,10 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
const fetchYtDlpVersion = async () => {
|
const fetchYtDlpVersion = async () => {
|
||||||
setIsFetchingYtDlpVersion(true);
|
setIsFetchingYtDlpVersion(true);
|
||||||
try {
|
try {
|
||||||
const command = Command.sidecar('binaries/yt-dlp', ['--version']);
|
const isFlatpak = await invoke<boolean>('is_flatpak');
|
||||||
|
const command = isFlatpak
|
||||||
|
? Command.create('yt-dlp', [' --version'])
|
||||||
|
: Command.sidecar('binaries/yt-dlp', ['--version']);
|
||||||
const output = await command.execute();
|
const output = await command.execute();
|
||||||
if (output.code === 0) {
|
if (output.code === 0) {
|
||||||
const version = output.stdout.trim();
|
const version = output.stdout.trim();
|
||||||
@@ -764,8 +278,27 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
fetchYtDlpVersion();
|
fetchYtDlpVersion();
|
||||||
}, [ytDlpVersion, setYtDlpVersion]);
|
}, [ytDlpVersion, setYtDlpVersion]);
|
||||||
|
|
||||||
|
// Check for app update
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run once when both settings and KV pairs are loaded
|
||||||
|
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
|
||||||
|
console.log("Skipping app update check, waiting for configs to load...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip if we've already run the update check once
|
||||||
|
if (hasRunAppUpdateCheckRef.current) {
|
||||||
|
console.log("App update check already performed in this session, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasRunAppUpdateCheckRef.current = true;
|
||||||
|
checkForAppUpdate().catch((error) => {
|
||||||
|
console.error("Error checking for app update:", error);
|
||||||
|
});
|
||||||
|
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
||||||
|
|
||||||
// Check for yt-dlp auto-update
|
// Check for yt-dlp auto-update
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleYtDlpAutoUpdate = async () => {
|
||||||
// Only run once when both settings and KV pairs are loaded
|
// Only run once when both settings and KV pairs are loaded
|
||||||
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
|
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
|
||||||
console.log("Skipping yt-dlp auto-update check, waiting for configs to load...");
|
console.log("Skipping yt-dlp auto-update check, waiting for configs to load...");
|
||||||
@@ -776,6 +309,11 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
console.log("Auto-update check already performed in this session, skipping");
|
console.log("Auto-update check already performed in this session, skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const isFlatpak = await invoke<boolean>('is_flatpak');
|
||||||
|
if (isFlatpak) {
|
||||||
|
console.log("Flatpak detected! Skipping yt-dlp auto-update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
hasRunYtDlpAutoUpdateRef.current = true;
|
hasRunYtDlpAutoUpdateRef.current = true;
|
||||||
console.log("Checking yt-dlp auto-update with loaded config values:", {
|
console.log("Checking yt-dlp auto-update with loaded config values:", {
|
||||||
autoUpdate: YTDLP_AUTO_UPDATE,
|
autoUpdate: YTDLP_AUTO_UPDATE,
|
||||||
@@ -790,6 +328,34 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
console.log("Skipping yt-dlp auto-update, either disabled or recently updated.");
|
console.log("Skipping yt-dlp auto-update, either disabled or recently updated.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
handleYtDlpAutoUpdate()
|
||||||
|
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
||||||
|
|
||||||
|
// Check POT server status and auto-start if enabled
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run once when both settings and KV pairs are loaded
|
||||||
|
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
|
||||||
|
console.log("Skipping POT server status check, waiting for configs to load...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip if we've already run the POT server status check once
|
||||||
|
if (hasRunPotServerStatusCheckRef.current) {
|
||||||
|
console.log("POT server status check already performed in this session, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasRunPotServerStatusCheckRef.current = true;
|
||||||
|
console.log("Checking POT server status with loaded config values:", {
|
||||||
|
usePotoken: USE_POTOKEN,
|
||||||
|
});
|
||||||
|
if (USE_POTOKEN) {
|
||||||
|
console.log("Auto-starting POT server...");
|
||||||
|
startPotServer().catch((error) => {
|
||||||
|
console.error("Error starting POT server:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("Skipping POT server auto-start, not enabled.");
|
||||||
|
}
|
||||||
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
||||||
|
|
||||||
// Check for MacOS auto-registration
|
// Check for MacOS auto-registration
|
||||||
@@ -809,24 +375,62 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
registeredVersion: macOsRegisteredVersion
|
registeredVersion: macOsRegisteredVersion
|
||||||
});
|
});
|
||||||
const currentPlatform = platform();
|
|
||||||
if (currentPlatform === 'macos' && (!macOsRegisteredVersion || macOsRegisteredVersion !== appVersion)) {
|
if (currentPlatform === 'macos' && (!macOsRegisteredVersion || macOsRegisteredVersion !== appVersion)) {
|
||||||
console.log("Running MacOS auto registration...");
|
console.log("Running MacOS auto registration...");
|
||||||
|
LOG.info('NEODLP', 'Running macOS registration');
|
||||||
registerToMac().then((result: { success: boolean, message: string }) => {
|
registerToMac().then((result: { success: boolean, message: string }) => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log("MacOS registration successful:", result.message);
|
console.log("MacOS registration successful:", result.message);
|
||||||
|
LOG.info('NEODLP', 'macOS registration successful');
|
||||||
} else {
|
} else {
|
||||||
console.error("MacOS registration failed:", result.message);
|
console.error("MacOS registration failed:", result.message);
|
||||||
|
LOG.error('NEODLP', `macOS registration failed: ${result.message}`);
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error("Error during macOS registration:", error);
|
console.error("Error during macOS registration:", error);
|
||||||
|
LOG.error('NEODLP', `Error during macOS registration: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
||||||
|
|
||||||
|
// Check for Linux auto-registration
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run once when both settings and KV pairs are loaded
|
||||||
|
if (!isSettingsStatePropagated || !isKvPairsStatePropagated) {
|
||||||
|
console.log("Skipping Linux auto registration, waiting for configs to load...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Skip if we've already run the linux auto-registration once
|
||||||
|
if (isRegisteredToLinuxRef.current) {
|
||||||
|
console.log("Linux auto registration check already performed in this session, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isRegisteredToLinuxRef.current = true;
|
||||||
|
console.log("Checking Linux auto registration with loaded config values:", {
|
||||||
|
appVersion: appVersion,
|
||||||
|
registeredVersion: linuxRegisteredVersion
|
||||||
|
});
|
||||||
|
if (currentPlatform === 'linux' && (!linuxRegisteredVersion || linuxRegisteredVersion !== appVersion)) {
|
||||||
|
console.log("Running Linux auto registration...");
|
||||||
|
LOG.info('NEODLP', 'Running Linux registration');
|
||||||
|
registerToLinux().then((result: { success: boolean, message: string }) => {
|
||||||
|
if (result.success) {
|
||||||
|
console.log("Linux registration successful:", result.message);
|
||||||
|
LOG.info('NEODLP', 'Linux registration successful');
|
||||||
|
} else {
|
||||||
|
console.error("Linux registration failed:", result.message);
|
||||||
|
LOG.error('NEODLP', `Linux registration failed: ${result.message}`);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Error during Linux registration:", error);
|
||||||
|
LOG.error('NEODLP', `Error during Linux registration: ${error}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSuccessFetchingDownloadStates && downloadStates) {
|
if (isSuccessFetchingDownloadStates && downloadStates) {
|
||||||
console.log("Download States fetched successfully:", downloadStates);
|
// console.log("Download States fetched successfully:", downloadStates);
|
||||||
setDownloadStates(downloadStates);
|
setDownloadStates(downloadStates);
|
||||||
}
|
}
|
||||||
}, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]);
|
}, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]);
|
||||||
@@ -844,47 +448,66 @@ export default function App({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// show a toast and pause the download when yt-dlp exits unexpectedly
|
// show a toast and pause the download when yt-dlp exits unexpectedly
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isErrored && !isErrorExpected) {
|
const unexpectedErrors = Array.from(erroredDownloadIds).filter(id => !expectedErrorDownloadIds.has(id));
|
||||||
toast({
|
const processedUnexpectedErrors = unexpectedErrors.filter(id => !pendingErrorUpdatesRef.current.has(id));
|
||||||
title: "Download Failed",
|
if (unexpectedErrors.length === 0) return;
|
||||||
description: "yt-dlp exited unexpectedly. Please try again later",
|
|
||||||
variant: "destructive",
|
processedUnexpectedErrors.forEach((downloadId) => {
|
||||||
|
const downloadState = globalDownloadStates.find(d => d.download_id === downloadId);
|
||||||
|
const isPlaylist = downloadState?.playlist_id !== null && downloadState?.playlist_indices !== null;
|
||||||
|
const isMultiplePlaylistItems = isPlaylist && downloadState?.playlist_indices && downloadState?.playlist_indices.includes(',');
|
||||||
|
|
||||||
|
toast.error("Download Failed", {
|
||||||
|
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState?.playlist_title : downloadState?.title}" failed because yt-dlp exited unexpectedly. Please try again later.`,
|
||||||
});
|
});
|
||||||
if (erroredDownloadId) {
|
});
|
||||||
downloadStatusUpdater.mutate({ download_id: erroredDownloadId, download_status: 'paused' }, {
|
|
||||||
|
const timeoutIds: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
unexpectedErrors.forEach((downloadId) => {
|
||||||
|
pendingErrorUpdatesRef.current.add(downloadId);
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'errored' }, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log("Download status updated successfully:", data);
|
console.log("Download status updated successfully:", data);
|
||||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||||
|
removeErroredDownload(downloadId);
|
||||||
|
pendingErrorUpdatesRef.current.delete(downloadId);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Failed to update download status:", error);
|
console.error("Failed to update download status:", error);
|
||||||
|
removeErroredDownload(downloadId);
|
||||||
|
pendingErrorUpdatesRef.current.delete(downloadId);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
setErroredDownloadId(null);
|
}, 500);
|
||||||
}
|
timeoutIds.push(timeoutId);
|
||||||
setIsErrored(false);
|
});
|
||||||
setIsErrorExpected(false);
|
|
||||||
}
|
return () => {
|
||||||
}, [isErrored, isErrorExpected, erroredDownloadId, setIsErrored, setIsErrorExpected, setErroredDownloadId]);
|
timeoutIds.forEach(id => clearTimeout(id));
|
||||||
|
};
|
||||||
|
}, [erroredDownloadIds, expectedErrorDownloadIds]);
|
||||||
|
|
||||||
// auto reset error states after 3 seconds of expecting an error
|
// auto reset error states after 3 seconds of expecting an error
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isErrorExpected) {
|
if (expectedErrorDownloadIds.size > 0) {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setIsErrored(false);
|
expectedErrorDownloadIds.forEach((downloadId) => {
|
||||||
setIsErrorExpected(false);
|
removeErroredDownload(downloadId);
|
||||||
setErroredDownloadId(null);
|
removeExpectedErrorDownload(downloadId);
|
||||||
|
});
|
||||||
}, 3000);
|
}, 3000);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}, [isErrorExpected, setIsErrorExpected]);
|
}, [expectedErrorDownloadIds]);
|
||||||
|
|
||||||
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"} defaultColorScheme={APP_COLOR_SCHEME || "default"}>
|
||||||
<TooltipProvider delayDuration={1000}>
|
<TooltipProvider delayDuration={1000}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Sonner closeButton />
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
BIN
src/assets/images/neosubhamoy.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { VideoFormat } from "@/types/video"
|
import { VideoFormat } from "@/types/video"
|
||||||
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils"
|
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils"
|
||||||
@@ -57,8 +57,8 @@ const FormatSelectionGroupItem = React.forwardRef<
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full rounded-lg border-2 border-border bg-card px-3 py-2 shadow-sm transition-all",
|
"relative w-full rounded-lg border-2 border-border bg-background px-3 py-2 shadow-sm transition-all",
|
||||||
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
|
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
|
||||||
"hover:bg-muted/70",
|
"hover:bg-muted/70",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
|
|||||||
114
src/components/custom/formatToggleGroup.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||||
|
import { type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toggleVariants } from "@/components/ui/toggle";
|
||||||
|
import { VideoFormat } from "@/types/video";
|
||||||
|
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils";
|
||||||
|
import { Music, Video, File } from "lucide-react";
|
||||||
|
|
||||||
|
const FormatToggleGroupContext = React.createContext<
|
||||||
|
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
|
||||||
|
>({
|
||||||
|
size: "default",
|
||||||
|
variant: "default",
|
||||||
|
toggleType: "multiple",
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormatToggleGroupProps =
|
||||||
|
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||||
|
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
|
||||||
|
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||||
|
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
|
||||||
|
|
||||||
|
export const FormatToggleGroup = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
|
FormatToggleGroupProps
|
||||||
|
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
|
||||||
|
if (type === "single") {
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
type="single"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...(props as any)}
|
||||||
|
>
|
||||||
|
<FormatToggleGroupContext.Provider value={{ variant, size, toggleType: "single" }}>
|
||||||
|
{children}
|
||||||
|
</FormatToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
type="multiple"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...(props as any)}
|
||||||
|
>
|
||||||
|
<FormatToggleGroupContext.Provider value={{ variant, size, toggleType: "multiple" }}>
|
||||||
|
{children}
|
||||||
|
</FormatToggleGroupContext.Provider>
|
||||||
|
</ToggleGroupPrimitive.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormatToggleGroup.displayName = "FormatToggleGroup";
|
||||||
|
|
||||||
|
export const FormatToggleGroupItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
|
VariantProps<typeof toggleVariants> & {
|
||||||
|
format: VideoFormat
|
||||||
|
}
|
||||||
|
>(({ className, children, variant, size, format, value, ...props }, ref) => {
|
||||||
|
const determineFileTypeIcon = (format: VideoFormat) => {
|
||||||
|
const fileFormat = determineFileType(/*format.video_ext, format.audio_ext,*/ format.vcodec, format.acodec)
|
||||||
|
switch (fileFormat) {
|
||||||
|
case 'video+audio':
|
||||||
|
return (
|
||||||
|
<span className="absolute flex items-center right-2 bottom-2">
|
||||||
|
<Video className="w-3 h-3 mr-2" />
|
||||||
|
<Music className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<Video className="w-3 h-3 absolute right-2 bottom-2" />
|
||||||
|
)
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<Music className="w-3 h-3 absolute right-2 bottom-2" />
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<File className="w-3 h-3 absolute right-2 bottom-2" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full p-2 rounded-lg border-2 border-border bg-background px-3 py-2 shadow-sm transition-all",
|
||||||
|
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
|
||||||
|
"data-[state=on]:border-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start text-start gap-1">
|
||||||
|
<h5 className="text-sm">{format.format}</h5>
|
||||||
|
<p className="text-muted-foreground text-xs">{format.filesize_approx ? formatFileSize(format.filesize_approx) : 'unknown'} {format.tbr ? formatBitrate(format.tbr) : 'unknown'}</p>
|
||||||
|
<p className="text-muted-foreground text-xs">{format.ext ? format.ext.toUpperCase() : 'unknown'} {
|
||||||
|
((format.vcodec && format.vcodec !== 'none') || (format.acodec && format.acodec !== 'none')) && (
|
||||||
|
`(${format.vcodec && format.vcodec !== 'none' ? formatCodec(format.vcodec) : ''}${format.vcodec && format.vcodec !== 'none' && format.acodec && format.acodec !== 'none' ? ' ' : ''}${format.acodec && format.acodec !== 'none' ? formatCodec(format.acodec) : ''})`
|
||||||
|
)}</p>
|
||||||
|
{determineFileTypeIcon(format)}
|
||||||
|
</div>
|
||||||
|
</ToggleGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormatToggleGroupItem.displayName = "FormatToggleGroupItem";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
|
||||||
import { type VariantProps } from "class-variance-authority"
|
import { type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|||||||
110
src/components/custom/numberInput.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Minus, Plus } from "lucide-react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface NumberInputProps
|
||||||
|
extends Omit<React.ComponentProps<"input">, "type" | "onChange" | "value"> {
|
||||||
|
value?: number
|
||||||
|
defaultValue?: number
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
onChange?: (value: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue = 0,
|
||||||
|
min = -Infinity,
|
||||||
|
max = Infinity,
|
||||||
|
step = 1,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [internalValue, setInternalValue] = React.useState(defaultValue)
|
||||||
|
const isControlled = controlledValue !== undefined
|
||||||
|
const currentValue = isControlled ? controlledValue : internalValue
|
||||||
|
|
||||||
|
const updateValue = (newValue: number) => {
|
||||||
|
const clamped = Math.min(max, Math.max(min, newValue))
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(clamped)
|
||||||
|
}
|
||||||
|
onChange?.(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIncrement = () => {
|
||||||
|
if (!disabled && !readOnly) {
|
||||||
|
updateValue(currentValue + step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDecrement = () => {
|
||||||
|
if (!disabled && !readOnly) {
|
||||||
|
updateValue(currentValue - step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const parsed = parseFloat(e.target.value)
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
updateValue(parsed)
|
||||||
|
} else if (e.target.value === "" || e.target.value === "-") {
|
||||||
|
if (!isControlled) {
|
||||||
|
setInternalValue(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex items-center", className)}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
ref={ref}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className="pr-16 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield] focus-visible:ring-0"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 flex h-full items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={disabled || readOnly || currentValue <= min}
|
||||||
|
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors border-x"
|
||||||
|
aria-label="Decrement"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Minus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleIncrement}
|
||||||
|
disabled={disabled || readOnly || currentValue >= max}
|
||||||
|
className="flex h-full items-center justify-center px-2 text-muted-foreground hover:text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50 transition-colors rounded-r-md"
|
||||||
|
aria-label="Increment"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NumberInput.displayName = "NumberInput"
|
||||||
|
|
||||||
|
export { NumberInput }
|
||||||
81
src/components/custom/paginationBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Paginated } from "@/types/download";
|
||||||
|
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
|
||||||
|
export default function PaginationBar({
|
||||||
|
paginatedData,
|
||||||
|
setPage,
|
||||||
|
}: {
|
||||||
|
paginatedData: Paginated;
|
||||||
|
setPage: (page: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pagination className="mt-4">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setPage(paginatedData.prev_page ?? paginatedData.first_page)}
|
||||||
|
aria-disabled={!paginatedData.prev_page}
|
||||||
|
className={!paginatedData.prev_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{paginatedData.pages.map((link, index, array) => {
|
||||||
|
const currentPage = paginatedData.current_page;
|
||||||
|
const pageNumber = link.page!;
|
||||||
|
|
||||||
|
// Show first page, last page, current page, and 2 pages around current
|
||||||
|
const showPage =
|
||||||
|
pageNumber === 1 ||
|
||||||
|
pageNumber === paginatedData.last_page ||
|
||||||
|
Math.abs(pageNumber - currentPage) <= 1;
|
||||||
|
|
||||||
|
// Show ellipsis if there's a gap
|
||||||
|
const prevVisiblePage = array
|
||||||
|
.slice(0, index)
|
||||||
|
.reverse()
|
||||||
|
.find((prevLink) => {
|
||||||
|
const prevPageNum = prevLink.page!;
|
||||||
|
return (
|
||||||
|
prevPageNum === 1 ||
|
||||||
|
prevPageNum === paginatedData.last_page ||
|
||||||
|
Math.abs(prevPageNum - currentPage) <= 1
|
||||||
|
);
|
||||||
|
})?.page;
|
||||||
|
|
||||||
|
const showEllipsis = showPage && prevVisiblePage && pageNumber - prevVisiblePage > 1;
|
||||||
|
|
||||||
|
if (!showPage) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={link.page} className="contents">
|
||||||
|
{showEllipsis && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
{showPage && (
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => setPage(link.page)}
|
||||||
|
isActive={link.active}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setPage(paginatedData.next_page ?? paginatedData.last_page)}
|
||||||
|
aria-disabled={!paginatedData.next_page}
|
||||||
|
className={!paginatedData.next_page ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { RawVideoInfo } from "@/types/video"
|
import { RawVideoInfo } from "@/types/video"
|
||||||
import { formatDurationString} from "@/utils"
|
import { formatDurationString} from "@/utils"
|
||||||
@@ -35,8 +35,8 @@ const PlaylistSelectionGroupItem = React.forwardRef<
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full rounded-lg border-2 border-border bg-card p-2 shadow-sm transition-all",
|
"relative w-full rounded-lg border-2 border-border bg-background p-2 shadow-sm transition-all",
|
||||||
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
|
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
|
||||||
"hover:bg-muted/70",
|
"hover:bg-muted/70",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
@@ -44,7 +44,7 @@ const PlaylistSelectionGroupItem = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 w-full relative">
|
<div className="flex gap-2 w-full relative">
|
||||||
<div className="w-[7rem] xl:w-[10rem]">
|
<div className="w-28 xl:w-40">
|
||||||
<AspectRatio
|
<AspectRatio
|
||||||
ratio={16 / 9}
|
ratio={16 / 9}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -63,9 +63,9 @@ const PlaylistSelectionGroupItem = React.forwardRef<
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
|
<div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
|
||||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
|
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
|
<p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||||
<Clock className="w-4 h-4 mr-2"/>
|
<Clock className="w-4 h-4 mr-2"/>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||||
import { type VariantProps } from "class-variance-authority";
|
import { type VariantProps } from "class-variance-authority";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toggleVariants } from "@/components/ui/toggle";
|
import { toggleVariants } from "@/components/ui/toggle";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
import { ProxyImage } from "@/components/custom/proxyImage";
|
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
@@ -11,7 +10,6 @@ import clsx from "clsx";
|
|||||||
import { formatDurationString } from "@/utils";
|
import { formatDurationString } from "@/utils";
|
||||||
import { RawVideoInfo } from "@/types/video";
|
import { RawVideoInfo } from "@/types/video";
|
||||||
|
|
||||||
// Create a context to share toggle group props
|
|
||||||
const PlaylistToggleGroupContext = React.createContext<
|
const PlaylistToggleGroupContext = React.createContext<
|
||||||
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
|
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
|
||||||
>({
|
>({
|
||||||
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
|
|||||||
toggleType: "multiple",
|
toggleType: "multiple",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper type for the PlaylistToggleGroup
|
|
||||||
type PlaylistToggleGroupProps =
|
type PlaylistToggleGroupProps =
|
||||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||||
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
|
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
|
||||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||||
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
|
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
|
||||||
|
|
||||||
// Main PlaylistToggleGroup component with proper type handling
|
|
||||||
export const PlaylistToggleGroup = React.forwardRef<
|
export const PlaylistToggleGroup = React.forwardRef<
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||||
PlaylistToggleGroupProps
|
PlaylistToggleGroupProps
|
||||||
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
|
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
|
||||||
// Pass props based on the type
|
|
||||||
if (type === "single") {
|
if (type === "single") {
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Root
|
<ToggleGroupPrimitive.Root
|
||||||
@@ -63,85 +58,27 @@ export const PlaylistToggleGroup = React.forwardRef<
|
|||||||
});
|
});
|
||||||
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
|
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
|
||||||
|
|
||||||
// Rest of your component remains the same
|
|
||||||
// PlaylistToggleGroupItem component with checkbox and item layout
|
|
||||||
export const PlaylistToggleGroupItem = React.forwardRef<
|
export const PlaylistToggleGroupItem = React.forwardRef<
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||||
VariantProps<typeof toggleVariants> & {
|
VariantProps<typeof toggleVariants> & {
|
||||||
video: RawVideoInfo;
|
video: RawVideoInfo;
|
||||||
}
|
}
|
||||||
>(({ className, children, variant, size, video, value, ...props }, ref) => {
|
>(({ className, children, variant, size, video, value, ...props }, ref) => {
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
|
||||||
const [checked, setChecked] = React.useState(false);
|
|
||||||
|
|
||||||
// Instead of a ref + useEffect approach
|
|
||||||
const [itemElement, setItemElement] = React.useState<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
// Handle checkbox click separately by simulating a click on the parent item
|
|
||||||
const handleCheckboxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Manually trigger the item's click to toggle selection
|
|
||||||
if (itemElement) {
|
|
||||||
// This simulates a click on the toggle item itself
|
|
||||||
itemElement.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use an effect that triggers when itemElement changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (itemElement) {
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.attributeName === 'data-state') {
|
|
||||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
|
||||||
observer.observe(itemElement, { attributes: true });
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
}, [itemElement]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Item
|
<ToggleGroupPrimitive.Item
|
||||||
ref={(el) => {
|
ref={ref}
|
||||||
// Handle both our ref and the forwarded ref
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(el);
|
|
||||||
} else if (ref) {
|
|
||||||
ref.current = el;
|
|
||||||
}
|
|
||||||
setItemElement(el);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full p-2 rounded-md transition-colors border-2 border-border",
|
"flex w-full p-2 rounded-lg transition-colors border-2 border-border",
|
||||||
"hover:bg-muted/50 data-[state=on]:bg-muted/70",
|
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
|
||||||
"data-[state=on]:border-primary",
|
"data-[state=on]:border-primary",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
value={value}
|
value={value}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full relative">
|
<div className="flex gap-2 w-full relative">
|
||||||
<div className="absolute top-2 left-2 z-10">
|
<div className="w-28 xl:w-40">
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
onClick={handleCheckboxClick}
|
|
||||||
className={cn(
|
|
||||||
"transition-opacity",
|
|
||||||
isHovered || checked ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-[7rem] xl:w-[10rem]">
|
|
||||||
<AspectRatio
|
<AspectRatio
|
||||||
ratio={16 / 9}
|
ratio={16 / 9}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -160,9 +97,9 @@ export const PlaylistToggleGroupItem = React.forwardRef<
|
|||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
|
<div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
|
||||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3>
|
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
|
||||||
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
|
<p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||||
<Clock className="w-4 h-4 mr-2"/>
|
<Clock className="w-4 h-4 mr-2"/>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const SlidingButton = ({
|
|||||||
return (
|
return (
|
||||||
<Tag
|
<Tag
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 rounded-md bg-black dark:bg-white dark:text-black text-white text-center relative overflow-hidden cursor-pointer flex justify-center",
|
"px-4 py-2 rounded-md bg-primary text-primary-foreground text-center relative overflow-hidden cursor-pointer flex justify-center",
|
||||||
`group/sliding-button`,
|
`group/sliding-button`,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -41,7 +41,7 @@ export const SlidingButton = ({
|
|||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center absolute inset-0 transition duration-500 text-white z-20',
|
'flex items-center justify-center absolute inset-0 transition duration-500 text-primary-foreground z-20',
|
||||||
`-translate-x-60 group-hover/sliding-button:translate-x-0`
|
`-translate-x-60 group-hover/sliding-button:translate-x-0`
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useLocation } from "react-router-dom";
|
|||||||
import { isActive } from "@/utils";
|
import { isActive } from "@/utils";
|
||||||
import { config } from "@/config";
|
import { config } from "@/config";
|
||||||
import { useSettingsPageStatesStore } from "@/services/store";
|
import { useSettingsPageStatesStore } from "@/services/store";
|
||||||
import { Github, Globe } from "lucide-react";
|
import { Github, Globe, Heart } from "lucide-react";
|
||||||
|
import { IndianFlagLogo } from "@/components/icons/india";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -14,8 +15,8 @@ export default function Footer() {
|
|||||||
{isSettingsPage ? (
|
{isSettingsPage ? (
|
||||||
<div className="flex items-center justify-between p-4 border-t border-border">
|
<div className="flex items-center justify-between p-4 border-t border-border">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm">{config.appName} v{appVersion} - © {new Date().getFullYear()} | <a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank">MIT License</a></span>
|
<span className="text-sm">{config.appName} v{appVersion} © 2025 - {new Date().getFullYear()} | <a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank">MIT License</a></span>
|
||||||
<span className="text-xs text-muted-foreground">Made with ❤️ by <a href={config.appAuthorUrl} target="_blank">{config.appAuthor}</a></span>
|
<span className="text-xs text-muted-foreground">Proudly Made with <Heart className="inline size-3 mb-0.5 fill-primary stroke-primary"/> in India <IndianFlagLogo className="inline size-full w-4 ml-0.5 mb-[0.1rem]" /></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href={config.appHomepage} target="_blank" className="text-sm text-muted-foreground hover:text-foreground" title="Homepage">
|
<a href={config.appHomepage} target="_blank" className="text-sm text-muted-foreground hover:text-foreground" title="Homepage">
|
||||||
|
|||||||
12
src/components/icons/close.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function CloseIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="m7.116 8l-4.558 4.558l.884.884L8 8.884l4.558 4.558l.884-.884L8.884 8l4.558-4.558l-.884-.884L8 7.116L3.442 2.558l-.884.884z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/icons/india.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function IndianFlagLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg width="1024" height="1024" viewBox="-45 -30 90 60" fill="#07038D" xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" className={className}>
|
||||||
|
<path fill="#FFF" d="m-45-30h90v60h-90z"/>
|
||||||
|
<path fill="#FF6820" d="m-45-30h90v20h-90z"/>
|
||||||
|
<path fill="#046A38" d="m-45 10h90v20h-90z"/>
|
||||||
|
<circle r="9.25"/>
|
||||||
|
<circle fill="#FFF" r="8"/>
|
||||||
|
<circle r="1.6"/>
|
||||||
|
<g id="d">
|
||||||
|
<g id="c">
|
||||||
|
<g id="b">
|
||||||
|
<g id="a">
|
||||||
|
<path d="m0-8 .3 4.81409L0-.80235-.3-3.18591z"/>
|
||||||
|
<circle transform="rotate(7.5)" r="0.35" cy="-8"/>
|
||||||
|
</g>
|
||||||
|
<use xlinkHref="#a" transform="scale(-1)"/>
|
||||||
|
</g>
|
||||||
|
<use xlinkHref="#b" transform="rotate(15)"/>
|
||||||
|
</g>
|
||||||
|
<use xlinkHref="#c" transform="rotate(30)"/>
|
||||||
|
</g>
|
||||||
|
<use xlinkHref="#d" transform="rotate(60)"/>
|
||||||
|
<use xlinkHref="#d" transform="rotate(120)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/icons/maximize.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function MaximizeIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
|
||||||
|
<path fill="currentColor" d="M3 3v10h10V3zm9 9H4V4h8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/icons/minimize.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function MinimizeIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
|
||||||
|
<path fill="currentColor" d="M14 8v1H3V8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ export function NeoDlpLogo({ className }: { className?: string }) {
|
|||||||
<rect x="355" y="222" width="313" height="346" rx="25" fill="#FAFAFA"/>
|
<rect x="355" y="222" width="313" height="346" rx="25" fill="#FAFAFA"/>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="paint0_linear_10_2" x1="129.5" y1="148.5" x2="921" y2="863" gradientUnits="userSpaceOnUse">
|
<linearGradient id="paint0_linear_10_2" x1="129.5" y1="148.5" x2="921" y2="863" gradientUnits="userSpaceOnUse">
|
||||||
<stop stopColor="#4444FF"/>
|
<stop stopColor="var(--logo-stop-color-1)"/>
|
||||||
<stop offset="1" stopColor="#FF43D0"/>
|
<stop offset="1" stopColor="var(--logo-stop-color-2)"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
10
src/components/icons/unmaximize.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function UnmaximizeIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" className={className}>
|
||||||
|
<g fill="currentColor">
|
||||||
|
<path d="M3 5v9h9V5zm8 8H4V6h7z" />
|
||||||
|
<path fillRule="evenodd" d="M5 5h1V4h7v7h-1v1h2V3H5z" clipRule="evenodd" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,99 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { getRouteName } from "@/utils";
|
import { getRouteName } from "@/utils";
|
||||||
// import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
// import { Terminal } from "lucide-react";
|
import { BrushCleaning, Check, Copy, Terminal } from "lucide-react";
|
||||||
// import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { useLogger } from "@/helpers/use-logger";
|
||||||
|
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
|
import TitleBar from "@/components/titlebar";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const currentPlatform = platform();
|
||||||
|
const logger = useLogger();
|
||||||
|
const logs = logger.getLogs();
|
||||||
|
const logText = logs.map(log => `${new Date(log.timestamp).toLocaleTimeString()} [${log.level.toUpperCase()}] ${log.context}: ${log.message}`).join('\n');
|
||||||
|
|
||||||
|
const handleCopyLogs = async () => {
|
||||||
|
await writeText(logText);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b z-50">
|
<div className="sticky top-0 z-50">
|
||||||
|
{currentPlatform === "windows" || currentPlatform === "linux" ? (
|
||||||
|
<TitleBar />
|
||||||
|
) : (
|
||||||
|
null
|
||||||
|
)}
|
||||||
|
<nav className="flex justify-between items-center py-3 px-4 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
<h1 className="text-lg text-primary font-semibold ml-4">{getRouteName(location.pathname)}</h1>
|
<h1 className="text-lg font-semibold ml-4">{getRouteName(location.pathname)}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{/* <Tooltip>
|
<Dialog>
|
||||||
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="icon">
|
<Button variant="outline" size="icon">
|
||||||
<Terminal />
|
<Terminal />
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom">
|
||||||
<p>Logs</p>
|
<p>Logs</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip> */}
|
</Tooltip>
|
||||||
|
<DialogContent className="sm:max-w-150">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Log Viewer</DialogTitle>
|
||||||
|
<DialogDescription>Monitor real-time app session logs (latest on top)</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2 p-2 max-h-75 overflow-y-scroll overflow-x-hidden bg-muted">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">NO LOGS TO SHOW!</p>
|
||||||
|
) : (
|
||||||
|
logs.slice().reverse().map((log, index) => (
|
||||||
|
<div key={index} className={`flex flex-col ${log.level === 'error' ? 'text-red-500' : log.level === 'warning' ? 'text-amber-500' : log.level === 'debug' ? 'text-sky-500' : log.level === 'progress' ? 'text-emerald-500' : 'text-foreground'}`}>
|
||||||
|
<p className="text-xs"><strong>{new Date(log.timestamp).toLocaleTimeString()}</strong> [{log.level.toUpperCase()}] <em>{log.context}</em> :</p>
|
||||||
|
<p className="text-xs font-mono break-all">{log.message}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
onClick={() => logger.clearLogs()}
|
||||||
|
>
|
||||||
|
<BrushCleaning className="size-4" />
|
||||||
|
Clear Logs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="transition-all duration-300"
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
onClick={() => handleCopyLogs()}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
Copy Logs
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
503
src/components/pages/downloader/bottomBar.tsx
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAppContext } from "@/providers/appContextProvider";
|
||||||
|
import { useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
|
import { formatBitrate, formatFileSize } from "@/utils";
|
||||||
|
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { RawVideoInfo, VideoFormat } from "@/types/video";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
interface DownloadConfigDialogProps {
|
||||||
|
selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomBarProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
selectedFormat: VideoFormat | undefined;
|
||||||
|
selectedFormatFileType: "video+audio" | "video" | "audio" | "unknown";
|
||||||
|
selectedVideoFormat: VideoFormat | undefined;
|
||||||
|
selectedAudioFormats: VideoFormat[] | undefined;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadConfigDialog({ selectedFormatFileType }: DownloadConfigDialogProps) {
|
||||||
|
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
|
||||||
|
const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab);
|
||||||
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||||
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||||
|
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
|
||||||
|
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
|
||||||
|
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
|
||||||
|
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
|
||||||
|
|
||||||
|
const embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
|
||||||
|
const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
|
||||||
|
const embedVideoThumbnail = useSettingsPageStatesStore(state => state.settings.embed_video_thumbnail);
|
||||||
|
const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
|
||||||
|
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
|
||||||
|
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
|
||||||
|
|
||||||
|
const isCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
disabled={!selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !isCombineableAudioSelected))}
|
||||||
|
>
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Configurations</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DialogContent className="sm:max-w-112.5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configurations</DialogTitle>
|
||||||
|
<DialogDescription>Tweak this download's configurations</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2 max-h-75 overflow-y-scroll overflow-x-hidden no-scrollbar">
|
||||||
|
<Tabs
|
||||||
|
className=""
|
||||||
|
value={activeDownloadConfigurationTab}
|
||||||
|
onValueChange={(tab) => setActiveDownloadConfigurationTab(tab)}
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="options">Options</TabsTrigger>
|
||||||
|
<TabsTrigger value="commands">Commands</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="options">
|
||||||
|
{useCustomCommands ? (
|
||||||
|
<Alert className="mt-2 mb-3">
|
||||||
|
<AlertCircleIcon className="size-4 stroke-primary" />
|
||||||
|
<AlertTitle className="text-sm">Options Unavailable!</AlertTitle>
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
You cannot use these options when custom commands are enabled. To use these options, disable custom commands from Settings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="video-format">
|
||||||
|
<Label className="text-xs mb-3 mt-2">Output Format ({(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? 'Video' : selectedFormatFileType && selectedFormatFileType === 'audio' ? 'Audio' : 'Unknown'})</Label>
|
||||||
|
{(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? (
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4 flex-wrap my-2"
|
||||||
|
value={downloadConfiguration.output_format ?? 'auto'}
|
||||||
|
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="v-auto" />
|
||||||
|
<Label htmlFor="v-auto">Follow Settings</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>
|
||||||
|
) : selectedFormatFileType && selectedFormatFileType === 'audio' ? (
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4 flex-wrap my-2"
|
||||||
|
value={downloadConfiguration.output_format ?? 'auto'}
|
||||||
|
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="a-auto" />
|
||||||
|
<Label htmlFor="a-auto">Follow Settings</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>
|
||||||
|
) : (
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4 flex-wrap my-2"
|
||||||
|
value={downloadConfiguration.output_format ?? 'auto'}
|
||||||
|
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="u-auto" />
|
||||||
|
<Label htmlFor="u-auto">Follow Settings</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mp4" id="u-mp4" />
|
||||||
|
<Label htmlFor="u-mp4">MP4</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="webm" id="u-webm" />
|
||||||
|
<Label htmlFor="u-webm">WEBM</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mkv" id="u-mkv" />
|
||||||
|
<Label htmlFor="u-mkv">MKV</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="m4a" id="u-m4a" />
|
||||||
|
<Label htmlFor="u-m4a">M4A</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="opus" id="u-opus" />
|
||||||
|
<Label htmlFor="u-opus">OPUS</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mp3" id="u-mp3" />
|
||||||
|
<Label htmlFor="u-mp3">MP3</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sponsorblock">
|
||||||
|
<Label className="text-xs my-3">Sponsorblock Mode</Label>
|
||||||
|
<RadioGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="flex items-center gap-4 flex-wrap my-2"
|
||||||
|
value={downloadConfiguration.sponsorblock ?? 'auto'}
|
||||||
|
onValueChange={(value) => setDownloadConfigurationKey('sponsorblock', value)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="auto" id="sb-auto" />
|
||||||
|
<Label htmlFor="sb-auto">Follow Settings</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="remove" id="sb-remove" />
|
||||||
|
<Label htmlFor="sb-remove">Remove</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<RadioGroupItem value="mark" id="sb-mark" />
|
||||||
|
<Label htmlFor="sb-mark">Mark</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
<div className="embeding-options">
|
||||||
|
<Label className="text-xs my-3">Embedding Options</Label>
|
||||||
|
<div className="flex items-center space-x-2 mt-3">
|
||||||
|
<Switch
|
||||||
|
id="embed-metadata"
|
||||||
|
checked={downloadConfiguration.embed_metadata !== null ? downloadConfiguration.embed_metadata : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoMetadata : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioMetadata : false}
|
||||||
|
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_metadata', checked)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="embed-metadata">Embed Metadata</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-3">
|
||||||
|
<Switch
|
||||||
|
id="embed-thumbnail"
|
||||||
|
checked={downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoThumbnail : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false}
|
||||||
|
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_thumbnail', checked)}
|
||||||
|
disabled={useCustomCommands}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="embed-thumbnail">Embed Thumbnail</Label>
|
||||||
|
<div className="flex items-center gap-3 ml-4">
|
||||||
|
<Checkbox
|
||||||
|
id="square-crop-thumbnail"
|
||||||
|
checked={downloadConfiguration.square_crop_thumbnail !== null ? downloadConfiguration.square_crop_thumbnail : false}
|
||||||
|
onCheckedChange={(checked) => setDownloadConfigurationKey('square_crop_thumbnail', checked)}
|
||||||
|
disabled={useCustomCommands || !(downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoThumbnail : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="square-crop-thumbnail">Square Crop</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="commands">
|
||||||
|
{!useCustomCommands ? (
|
||||||
|
<Alert className="mt-2 mb-3">
|
||||||
|
<AlertCircleIcon className="size-4 stroke-primary" />
|
||||||
|
<AlertTitle className="text-sm">Enable Custom Commands!</AlertTitle>
|
||||||
|
<AlertDescription className="text-xs">
|
||||||
|
To run custom commands for downloads, please enable it from the Settings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="custom-commands">
|
||||||
|
<Label className="text-xs mb-3 mt-2">Run Custom Command</Label>
|
||||||
|
{customCommands.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">NO CUSTOM COMMAND TEMPLATE ADDED YET!</p>
|
||||||
|
) : (
|
||||||
|
<RadioGroup
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex flex-col gap-2 my-2"
|
||||||
|
disabled={!useCustomCommands}
|
||||||
|
value={downloadConfiguration.custom_command}
|
||||||
|
onValueChange={(value) => setDownloadConfigurationKey('custom_command', value)}
|
||||||
|
>
|
||||||
|
{customCommands.map((command) => (
|
||||||
|
<div className="flex items-center gap-3" key={command.id}>
|
||||||
|
<RadioGroupItem value={command.id} id={`cmd-${command.id}`} />
|
||||||
|
<Label htmlFor={`cmd-${command.id}`}>{command.label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileType, selectedVideoFormat, selectedAudioFormats, containerRef }: BottomBarProps) {
|
||||||
|
const { startDownload } = useAppContext();
|
||||||
|
|
||||||
|
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
|
||||||
|
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
|
||||||
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||||
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||||
|
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
|
||||||
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
|
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||||
|
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
|
||||||
|
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
|
||||||
|
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
|
||||||
|
|
||||||
|
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
|
||||||
|
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
|
||||||
|
const useSponsorblock = useSettingsPageStatesStore(state => state.settings.use_sponsorblock);
|
||||||
|
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
|
||||||
|
|
||||||
|
const bottomBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const isPlaylist = videoMetadata._type === 'playlist';
|
||||||
|
const isMultiplePlaylistItems = isPlaylist && selectedPlaylistVideos.length > 1;
|
||||||
|
const isCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 0 && selectedAudioFormats && selectedAudioFormats.length > 0;
|
||||||
|
const isMultipleCombineableAudioSelected = selectedCombinableAudioFormats && selectedCombinableAudioFormats.length > 1 && selectedAudioFormats && selectedAudioFormats.length > 1;
|
||||||
|
|
||||||
|
let selectedFormatExtensionMsg = 'Auto - unknown';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${downloadConfiguration.output_format.toUpperCase()}`;
|
||||||
|
} else if (videoFormat !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`;
|
||||||
|
} else if (isCombineableAudioSelected && selectedVideoFormat?.ext) {
|
||||||
|
if (isMultipleCombineableAudioSelected) {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormats.length} Audio`;
|
||||||
|
} else {
|
||||||
|
selectedFormatExtensionMsg = `Combined - ${selectedVideoFormat.ext.toUpperCase()} + ${selectedAudioFormats[0].ext.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedFormatExtensionMsg = `Combined - unknown`;
|
||||||
|
}
|
||||||
|
} else if (selectedFormat?.ext) {
|
||||||
|
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) {
|
||||||
|
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`;
|
||||||
|
} else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) {
|
||||||
|
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : audioFormat.toUpperCase()}`;
|
||||||
|
} else if (selectedFormatFileType === 'unknown' && downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
|
||||||
|
selectedFormatExtensionMsg = `Forced - ${downloadConfiguration.output_format.toUpperCase()}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatResolutionMsg = 'unknown';
|
||||||
|
let totalTbr = 0;
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (isCombineableAudioSelected) {
|
||||||
|
if (isMultipleCombineableAudioSelected) {
|
||||||
|
const totalAudioTbr = selectedAudioFormats.reduce((acc, format) => acc + (format.tbr ?? 0), 0);
|
||||||
|
totalTbr = (selectedVideoFormat?.tbr ?? 0) + totalAudioTbr;
|
||||||
|
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${formatBitrate(totalAudioTbr)}`;
|
||||||
|
} else {
|
||||||
|
totalTbr = (selectedVideoFormat?.tbr ?? 0) + (selectedAudioFormats && selectedAudioFormats[0].tbr ? selectedAudioFormats[0].tbr : 0);
|
||||||
|
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + ${selectedAudioFormats && selectedAudioFormats[0].tbr ? formatBitrate(selectedAudioFormats[0].tbr) : 'unknown'}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalTbr = selectedVideoFormat?.tbr ?? 0;
|
||||||
|
selectedFormatResolutionMsg = `${selectedVideoFormat?.resolution ?? 'unknown'} + unknown`;
|
||||||
|
}
|
||||||
|
} else if (selectedFormat?.resolution) {
|
||||||
|
totalTbr = selectedFormat.tbr ?? 0;
|
||||||
|
selectedFormatResolutionMsg = selectedFormat.resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatDynamicRangeMsg = '';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' && selectedVideoFormat.dynamic_range !== 'auto' ? selectedVideoFormat.dynamic_range : '';
|
||||||
|
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' && selectedFormat.dynamic_range !== 'auto') {
|
||||||
|
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatFileSizeMsg = 'unknown filesize';
|
||||||
|
let totalFilesize = 0;
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (isCombineableAudioSelected) {
|
||||||
|
if (isMultipleCombineableAudioSelected) {
|
||||||
|
totalFilesize = (selectedVideoFormat?.filesize_approx ?? 0) + selectedAudioFormats.reduce((acc, format) => acc + (format.filesize_approx ?? 0), 0);
|
||||||
|
selectedFormatFileSizeMsg = totalFilesize > 0 ? formatFileSize(totalFilesize) : 'unknown filesize';
|
||||||
|
} else {
|
||||||
|
totalFilesize = (selectedVideoFormat?.filesize_approx ?? 0) + (selectedAudioFormats && selectedAudioFormats[0].filesize_approx ? selectedAudioFormats[0].filesize_approx : 0);
|
||||||
|
selectedFormatFileSizeMsg = (selectedVideoFormat?.filesize_approx && selectedAudioFormats && selectedAudioFormats[0].filesize_approx) ? formatFileSize(selectedVideoFormat.filesize_approx + selectedAudioFormats[0].filesize_approx) : 'unknown filesize';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
totalFilesize = selectedVideoFormat?.filesize_approx ?? 0;
|
||||||
|
selectedFormatFileSizeMsg = selectedVideoFormat?.filesize_approx ? formatFileSize(selectedVideoFormat.filesize_approx) : 'unknown filesize';
|
||||||
|
}
|
||||||
|
} else if (selectedFormat?.filesize_approx) {
|
||||||
|
totalFilesize = selectedFormat.filesize_approx;
|
||||||
|
selectedFormatFileSizeMsg = formatFileSize(selectedFormat.filesize_approx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedFormatFinalMsg = '';
|
||||||
|
if (activeDownloadModeTab === 'combine') {
|
||||||
|
if (selectedCombinableVideoFormat && selectedCombinableAudioFormats.length > 0) {
|
||||||
|
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatFinalMsg = `Choose a video and audio streams to combine`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedFormat) {
|
||||||
|
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||||
|
} else {
|
||||||
|
selectedFormatFinalMsg = `Select a stream to download`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateBottomBarWidth = (): void => {
|
||||||
|
if (containerRef.current && bottomBarRef.current) {
|
||||||
|
bottomBarRef.current.style.width = `${containerRef.current.offsetWidth}px`;
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
bottomBarRef.current.style.left = `${containerRect.left}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateBottomBarWidth();
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
updateBottomBarWidth();
|
||||||
|
});
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', updateBottomBarWidth);
|
||||||
|
window.addEventListener('scroll', updateBottomBarWidth);
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', updateBottomBarWidth);
|
||||||
|
window.removeEventListener('scroll', updateBottomBarWidth);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 justify-center items-center p-3 rounded-md border border-border">
|
||||||
|
{activeDownloadModeTab === 'combine' && (
|
||||||
|
<Video className="w-4 h-4 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{activeDownloadModeTab !== 'combine' && selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio') && (
|
||||||
|
<Video className="w-4 h-4 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{activeDownloadModeTab !== 'combine' && selectedFormatFileType && selectedFormatFileType === 'audio' && (
|
||||||
|
<Music className="w-4 h-4 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{activeDownloadModeTab !== 'combine' && ((!selectedFormatFileType) || (selectedFormatFileType && selectedFormatFileType !== 'video' && selectedFormatFileType !== 'audio' && selectedFormatFileType !== 'video+audio')) && (
|
||||||
|
<File className="w-4 h-4 stroke-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? selectedPlaylistVideos.length === 1 ? videoMetadata.entries[Number(selectedPlaylistVideos[0]) - 1].title : `${selectedPlaylistVideos.length} Items` : 'Unknown' }</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DownloadConfigDialog selectedFormatFileType={selectedFormatFileType} />
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setIsStartingDownload(true);
|
||||||
|
try {
|
||||||
|
if (videoMetadata._type === 'playlist') {
|
||||||
|
await startDownload({
|
||||||
|
url: videoMetadata.original_url,
|
||||||
|
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormats.join('+')}` : selectedDownloadFormat,
|
||||||
|
downloadConfig: downloadConfiguration,
|
||||||
|
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
|
||||||
|
playlistItems: selectedPlaylistVideos.sort((a, b) => Number(a) - Number(b)).join(','),
|
||||||
|
overrideOptions: isMultiplePlaylistItems ? {
|
||||||
|
filesize: totalFilesize > 0 ? totalFilesize : undefined,
|
||||||
|
tbr: totalTbr > 0 ? totalTbr : undefined,
|
||||||
|
} : isMultipleCombineableAudioSelected ? {
|
||||||
|
filesize: totalFilesize > 0 ? totalFilesize : undefined,
|
||||||
|
tbr: totalTbr > 0 ? totalTbr : undefined,
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
} else if (videoMetadata._type === 'video') {
|
||||||
|
await startDownload({
|
||||||
|
url: videoMetadata.webpage_url,
|
||||||
|
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormats.join('+')}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
|
||||||
|
downloadConfig: downloadConfiguration,
|
||||||
|
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
|
||||||
|
overrideOptions: isMultipleCombineableAudioSelected ? {
|
||||||
|
filesize: totalFilesize > 0 ? totalFilesize : undefined,
|
||||||
|
tbr: totalTbr > 0 ? totalTbr : undefined,
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// toast({
|
||||||
|
// title: 'Download Initiated',
|
||||||
|
// description: 'Download initiated, it will start shortly.',
|
||||||
|
// });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed to start:', error);
|
||||||
|
toast.error("Failed to Start Download", {
|
||||||
|
description: "There was an error initiating the download."
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsStartingDownload(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !isCombineableAudioSelected)) || (useCustomCommands && !downloadConfiguration.custom_command)}
|
||||||
|
>
|
||||||
|
{isStartingDownload ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Starting Download
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Start Download'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
411
src/components/pages/downloader/playlistDownloader.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import { useDownloaderPageStatesStore } from "@/services/store";
|
||||||
|
import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react";
|
||||||
|
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { RawVideoInfo, VideoFormat } from "@/types/video";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
|
||||||
|
import { getMergedBestFormat } from "@/utils";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { FormatToggleGroup, FormatToggleGroupItem } from "@/components/custom/formatToggleGroup";
|
||||||
|
import { Layout } from "react-resizable-panels";
|
||||||
|
|
||||||
|
interface PlaylistPreviewSelectionProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectivePlaylistDownloadProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
combinedFormats: VideoFormat[] | undefined;
|
||||||
|
qualityPresetFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombinedPlaylistDownloadProps {
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistDownloaderProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
combinedFormats: VideoFormat[] | undefined;
|
||||||
|
qualityPresetFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) {
|
||||||
|
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||||
|
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||||
|
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||||
|
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
|
||||||
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
|
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
const totalVideos = videoMetadata.entries.filter((entry) => entry).length;
|
||||||
|
const allVideoIndices = videoMetadata.entries.filter((entry) => entry).map((entry) => entry.playlist_index.toString());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full pr-4">
|
||||||
|
<div className="flex items-center justify-between mb-4 mt-2">
|
||||||
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
|
<ListVideo className="w-4 h-4" />
|
||||||
|
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="select-all-videos"
|
||||||
|
checked={selectedPlaylistVideos.length === totalVideos && totalVideos > 0}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedPlaylistVideos(allVideoIndices);
|
||||||
|
} else {
|
||||||
|
setSelectedPlaylistVideos(["1"]);
|
||||||
|
}
|
||||||
|
setSelectedDownloadFormat('best');
|
||||||
|
setSelectedSubtitles([]);
|
||||||
|
setSelectedCombinableVideoFormat('');
|
||||||
|
setSelectedCombinableAudioFormats([]);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
disabled={totalVideos <= 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||||
|
<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_creator || videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
|
||||||
|
<PlaylistToggleGroup
|
||||||
|
className="mb-2"
|
||||||
|
type="multiple"
|
||||||
|
value={selectedPlaylistVideos}
|
||||||
|
onValueChange={(value: string[]) => {
|
||||||
|
if (value.length > 0) {
|
||||||
|
setSelectedPlaylistVideos(value);
|
||||||
|
setSelectedDownloadFormat('best');
|
||||||
|
setSelectedSubtitles([]);
|
||||||
|
setSelectedCombinableVideoFormat('');
|
||||||
|
setSelectedCombinableAudioFormats([]);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoMetadata.entries.map((entry) => entry ? (
|
||||||
|
<PlaylistToggleGroupItem
|
||||||
|
key={entry.playlist_index}
|
||||||
|
value={entry.playlist_index.toString()}
|
||||||
|
video={entry}
|
||||||
|
/>
|
||||||
|
) : null)}
|
||||||
|
</PlaylistToggleGroup>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="spacer mb-12"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectivePlaylistDownloadProps) {
|
||||||
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||||
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
|
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||||
|
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||||
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||||
|
{subtitleLanguages && subtitleLanguages.length > 0 && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
|
value={selectedSubtitles}
|
||||||
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
|
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
{subtitleLanguages.map((lang) => {
|
||||||
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||||
|
value={lang.code}
|
||||||
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
<FormatSelectionGroup
|
||||||
|
value={selectedDownloadFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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([]);
|
||||||
|
// }
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Suggested</p>
|
||||||
|
<div className="">
|
||||||
|
<FormatSelectionGroupItem
|
||||||
|
key="best"
|
||||||
|
value="best"
|
||||||
|
format={getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos) as VideoFormat}
|
||||||
|
/>
|
||||||
|
</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-12"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages }: CombinedPlaylistDownloadProps) {
|
||||||
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||||
|
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
|
||||||
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
|
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||||
|
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
|
||||||
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
|
value={selectedSubtitles}
|
||||||
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
{subtitleLanguages.map((lang) => {
|
||||||
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||||
|
value={lang.code}
|
||||||
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
<FormatToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="mb-2"
|
||||||
|
value={selectedCombinableAudioFormats}
|
||||||
|
onValueChange={(value: string[]) => {
|
||||||
|
setSelectedCombinableAudioFormats(value);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && 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) => (
|
||||||
|
<FormatToggleGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatToggleGroup>
|
||||||
|
<FormatSelectionGroup
|
||||||
|
value={selectedCombinableVideoFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedCombinableVideoFormat(value);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircleIcon className="size-4 stroke-primary" />
|
||||||
|
<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-12"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: PlaylistDownloaderProps) {
|
||||||
|
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
|
||||||
|
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
|
||||||
|
const playlistPanelSizes = useDownloaderPageStatesStore((state) => state.playlistPanelSizes);
|
||||||
|
const setPlaylistPanelSizes = useDownloaderPageStatesStore((state) => state.setPlaylistPanelSizes);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="w-full"
|
||||||
|
onLayoutChanged={(layout: Layout) => {
|
||||||
|
const firstPanelSize = layout[Object.keys(layout)[0]];
|
||||||
|
const secondPanelSize = layout[Object.keys(layout)[1]];
|
||||||
|
setPlaylistPanelSizes([firstPanelSize, secondPanelSize]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={`${playlistPanelSizes[0]}%`}
|
||||||
|
>
|
||||||
|
<PlaylistPreviewSelection videoMetadata={videoMetadata} />
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={`${playlistPanelSizes[1]}%`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col w-full pl-4">
|
||||||
|
<Tabs
|
||||||
|
className=""
|
||||||
|
value={activeDownloadModeTab}
|
||||||
|
onValueChange={(tab) => {
|
||||||
|
setActiveDownloadModeTab(tab);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
|
<DownloadCloud className="w-4 h-4" />
|
||||||
|
<span>Download Options</span>
|
||||||
|
</h3>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="selective">Selective</TabsTrigger>
|
||||||
|
<TabsTrigger value="combine">Combine</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="selective">
|
||||||
|
<SelectivePlaylistDownload
|
||||||
|
videoMetadata={videoMetadata}
|
||||||
|
audioOnlyFormats={audioOnlyFormats}
|
||||||
|
videoOnlyFormats={videoOnlyFormats}
|
||||||
|
combinedFormats={combinedFormats}
|
||||||
|
qualityPresetFormats={qualityPresetFormats}
|
||||||
|
subtitleLanguages={subtitleLanguages}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="combine">
|
||||||
|
<CombinedPlaylistDownload
|
||||||
|
audioOnlyFormats={audioOnlyFormats}
|
||||||
|
videoOnlyFormats={videoOnlyFormats}
|
||||||
|
subtitleLanguages={subtitleLanguages}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
388
src/components/pages/downloader/videoDownloader.tsx
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import clsx from "clsx";
|
||||||
|
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||||
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useDownloaderPageStatesStore } from "@/services/store";
|
||||||
|
import { formatBitrate, formatDurationString, formatReleaseDate, formatYtStyleCount, isObjEmpty } from "@/utils";
|
||||||
|
import { Calendar, Clock, DownloadCloud, Eye, Info, ThumbsUp, AlertCircleIcon } from "lucide-react";
|
||||||
|
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
|
import { RawVideoInfo, VideoFormat } from "@/types/video";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { FormatToggleGroup, FormatToggleGroupItem } from "@/components/custom/formatToggleGroup";
|
||||||
|
import { Layout } from "react-resizable-panels";
|
||||||
|
|
||||||
|
interface VideoPreviewProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectiveVideoDownloadProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
combinedFormats: VideoFormat[] | undefined;
|
||||||
|
qualityPresetFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CombinedVideoDownloadProps {
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoDownloaderProps {
|
||||||
|
videoMetadata: RawVideoInfo;
|
||||||
|
audioOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
videoOnlyFormats: VideoFormat[] | undefined;
|
||||||
|
combinedFormats: VideoFormat[] | undefined;
|
||||||
|
qualityPresetFormats: VideoFormat[] | undefined;
|
||||||
|
subtitleLanguages: { code: string; lang: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPreview({ videoMetadata }: VideoPreviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full pr-4">
|
||||||
|
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
<span>Metadata</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] 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")}>
|
||||||
|
<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>
|
||||||
|
<h2 className="mb-1">{videoMetadata.title ? videoMetadata.title : 'UNTITLED'}</h2>
|
||||||
|
<p className="text-muted-foreground text-xs mb-2">{videoMetadata.creator || videoMetadata.channel || videoMetadata.uploader || 'unknown'}</p>
|
||||||
|
<div className="flex items-center mb-2">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {videoMetadata.duration_string ? formatDurationString(videoMetadata.duration_string) : 'unknown'}</span>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center px-3"><Eye className="w-4 h-4 mr-2"/> {videoMetadata.view_count ? formatYtStyleCount(videoMetadata.view_count) : 'unknown'}</span>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center pl-3"><ThumbsUp className="w-4 h-4 mr-2"/> {videoMetadata.like_count ? formatYtStyleCount(videoMetadata.like_count) : 'unknown'}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-2 mb-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span className="">{videoMetadata.upload_date ? formatReleaseDate(videoMetadata.upload_date) : 'unknown'}</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs mb-2">
|
||||||
|
{videoMetadata.resolution && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{videoMetadata.resolution}</span>
|
||||||
|
)}
|
||||||
|
{videoMetadata.tbr && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{formatBitrate(videoMetadata.tbr)}</span>
|
||||||
|
)}
|
||||||
|
{videoMetadata.fps && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{videoMetadata.fps} fps</span>
|
||||||
|
)}
|
||||||
|
{videoMetadata.subtitles && !isObjEmpty(videoMetadata.subtitles) && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">SUB</span>
|
||||||
|
)}
|
||||||
|
{videoMetadata.dynamic_range && videoMetadata.dynamic_range !== 'SDR' && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{videoMetadata.dynamic_range}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="spacer mb-12"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectiveVideoDownloadProps) {
|
||||||
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||||
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
|
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||||
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||||
|
{subtitleLanguages && subtitleLanguages.length > 0 && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
|
value={selectedSubtitles}
|
||||||
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
|
// disabled={selectedFormat?.ext !== 'mp4' && selectedFormat?.ext !== 'mkv' && selectedFormat?.ext !== 'webm'}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
{subtitleLanguages.map((lang) => {
|
||||||
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||||
|
value={lang.code}
|
||||||
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
<FormatSelectionGroup
|
||||||
|
value={selectedDownloadFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
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([]);
|
||||||
|
// }
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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-12"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLanguages }: CombinedVideoDownloadProps) {
|
||||||
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||||
|
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
|
||||||
|
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||||
|
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||||
|
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
|
||||||
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && subtitleLanguages && subtitleLanguages.length > 0 && (
|
||||||
|
<ToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="flex flex-col items-start gap-2 mb-2"
|
||||||
|
value={selectedSubtitles}
|
||||||
|
onValueChange={(value) => setSelectedSubtitles(value)}
|
||||||
|
>
|
||||||
|
<p className="text-xs">Subtitle Languages</p>
|
||||||
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
{subtitleLanguages.map((lang) => {
|
||||||
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem
|
||||||
|
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||||
|
value={lang.code}
|
||||||
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
<FormatToggleGroup
|
||||||
|
type="multiple"
|
||||||
|
variant="outline"
|
||||||
|
className="mb-2"
|
||||||
|
value={selectedCombinableAudioFormats}
|
||||||
|
onValueChange={(value: string[]) => {
|
||||||
|
setSelectedCombinableAudioFormats(value);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{videoOnlyFormats && videoOnlyFormats.length > 0 && 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) => (
|
||||||
|
<FormatToggleGroupItem
|
||||||
|
key={format.format_id}
|
||||||
|
value={format.format_id}
|
||||||
|
format={format}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatToggleGroup>
|
||||||
|
<FormatSelectionGroup
|
||||||
|
value={selectedCombinableVideoFormat}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedCombinableVideoFormat(value);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-xs">Video</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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormatSelectionGroup>
|
||||||
|
{(!videoOnlyFormats || videoOnlyFormats.length === 0 || !audioOnlyFormats || audioOnlyFormats.length === 0) && (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircleIcon className="size-4 stroke-primary" />
|
||||||
|
<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-12"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoDownloader({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: VideoDownloaderProps) {
|
||||||
|
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
|
||||||
|
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
|
||||||
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||||
|
const videoPanelSizes = useDownloaderPageStatesStore((state) => state.videoPanelSizes);
|
||||||
|
const setVideoPanelSizes = useDownloaderPageStatesStore((state) => state.setVideoPanelSizes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
orientation="horizontal"
|
||||||
|
className="w-full"
|
||||||
|
onLayoutChanged={(layout: Layout) => {
|
||||||
|
const firstPanelSize = layout[Object.keys(layout)[0]];
|
||||||
|
const secondPanelSize = layout[Object.keys(layout)[1]];
|
||||||
|
setVideoPanelSizes([firstPanelSize, secondPanelSize]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={`${videoPanelSizes[0]}%`}
|
||||||
|
>
|
||||||
|
<VideoPreview videoMetadata={videoMetadata} />
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel
|
||||||
|
defaultSize={`${videoPanelSizes[1]}%`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col w-full pl-4">
|
||||||
|
<Tabs
|
||||||
|
className=""
|
||||||
|
value={activeDownloadModeTab}
|
||||||
|
onValueChange={(tab) => {
|
||||||
|
setActiveDownloadModeTab(tab);
|
||||||
|
resetDownloadConfiguration();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
|
<DownloadCloud className="w-4 h-4" />
|
||||||
|
<span>Download Options</span>
|
||||||
|
</h3>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="selective">Selective</TabsTrigger>
|
||||||
|
<TabsTrigger value="combine">Combine</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="selective">
|
||||||
|
<SelectiveVideoDownload
|
||||||
|
videoMetadata={videoMetadata}
|
||||||
|
audioOnlyFormats={audioOnlyFormats}
|
||||||
|
videoOnlyFormats={videoOnlyFormats}
|
||||||
|
combinedFormats={combinedFormats}
|
||||||
|
qualityPresetFormats={qualityPresetFormats}
|
||||||
|
subtitleLanguages={subtitleLanguages}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="combine">
|
||||||
|
<CombinedVideoDownload
|
||||||
|
audioOnlyFormats={audioOnlyFormats}
|
||||||
|
videoOnlyFormats={videoOnlyFormats}
|
||||||
|
subtitleLanguages={subtitleLanguages}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
src/components/pages/library/completedDownloads.tsx
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||||
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useCurrentVideoMetadataStore, useDownloadActionStatesStore, useEnvironmentStore, useLibraryPageStatesStore } from "@/services/store";
|
||||||
|
import { formatBitrate, formatCodec, formatDurationString, formatFileSize, paginate } from "@/utils";
|
||||||
|
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
|
import { dirname } from "@tauri-apps/api/path";
|
||||||
|
import { DownloadState } from "@/types/download";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useDeleteDownloadState } from "@/services/mutations";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useLogger } from "@/helpers/use-logger";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||||
|
import PaginationBar from "@/components/custom/paginationBar";
|
||||||
|
|
||||||
|
interface CompletedDownloadProps {
|
||||||
|
state: DownloadState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletedDownloadsProps {
|
||||||
|
downloads: DownloadState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||||
|
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
|
||||||
|
const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked);
|
||||||
|
|
||||||
|
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const downloadStateDeleter = useDeleteDownloadState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const LOG = useLogger();
|
||||||
|
|
||||||
|
const openFile = async (filePath: string | null, app: string | null) => {
|
||||||
|
if (filePath && await fs.exists(filePath)) {
|
||||||
|
try {
|
||||||
|
await invoke('open_file_with_app', { filePath: filePath, appName: app }).then(() => {
|
||||||
|
toast.info(`${app === 'explorer' ? 'Revealing' : 'Opening'} file`, {
|
||||||
|
description: `${app === 'explorer' ? 'Revealing' : 'Opening'} the file ${app === 'explorer' ? 'in' : 'with'} ${app ? app : 'default app'}.`,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error(`Failed to ${app === 'explorer' ? 'reveal' : 'open'} file`, {
|
||||||
|
description: `An error occurred while trying to ${app === 'explorer' ? 'reveal' : 'open'} the file.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.info("File unavailable", {
|
||||||
|
description: `The file you are trying to ${app === 'explorer' ? 'reveal' : 'open'} does not exist.`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => {
|
||||||
|
if (delete_file && downloadState.filepath) {
|
||||||
|
const isMultiplePlaylistItems = downloadState.playlist_id !== null &&
|
||||||
|
downloadState.playlist_indices !== null &&
|
||||||
|
downloadState.playlist_indices.includes(',');
|
||||||
|
|
||||||
|
if (isMultiplePlaylistItems) {
|
||||||
|
const dirPath = await dirname(downloadState.filepath);
|
||||||
|
try {
|
||||||
|
if (await fs.exists(dirPath)) {
|
||||||
|
await fs.remove(dirPath, { recursive: true });
|
||||||
|
} else {
|
||||||
|
console.error(`Directory not found: "${dirPath}"`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (await fs.exists(downloadState.filepath)) {
|
||||||
|
await fs.remove(downloadState.filepath);
|
||||||
|
} else {
|
||||||
|
console.error(`File not found: "${downloadState.filepath}"`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadStateDeleter.mutate(downloadState.download_id, {
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log("Download State deleted successfully:", data);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||||
|
if (delete_file && downloadState.filepath) {
|
||||||
|
toast.success("Deleted from downloads", {
|
||||||
|
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been deleted successfully.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success("Removed from downloads", {
|
||||||
|
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been removed successfully.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Failed to delete download state:", error);
|
||||||
|
if (delete_file && downloadState.filepath) {
|
||||||
|
toast.error("Failed to delete download", {
|
||||||
|
description: `An error occurred while trying to delete the download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to remove download", {
|
||||||
|
description: `An error occurred while trying to remove the download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = async (url: string, isPlaylist: boolean) => {
|
||||||
|
try {
|
||||||
|
LOG.info('NEODLP', `Received search request from library for URL: ${url}`);
|
||||||
|
navigate('/');
|
||||||
|
const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState();
|
||||||
|
setRequestedUrl(url);
|
||||||
|
setAutoSubmitSearch(true);
|
||||||
|
toast.info(`Initiating ${isPlaylist ? 'Playlist' : 'Video'} Search`, {
|
||||||
|
description: `Initiating search for the selected ${isPlaylist ? 'playlist' : 'video'}.`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to initiate search", {
|
||||||
|
description: "An error occurred while trying to initiate the search.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemActionStates = downloadActions[state.download_id] || {
|
||||||
|
isResuming: false,
|
||||||
|
isPausing: false,
|
||||||
|
isCanceling: false,
|
||||||
|
isDeleteFileChecked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
|
||||||
|
const isMultiplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
|
||||||
|
const isMultipleAudioFormatSelected = state.format_id ? state.format_id.split('+').length > 2 : 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">
|
||||||
|
{isMultiplePlaylistItems ? (
|
||||||
|
<div className="w-full relative flex items-center justify-center mt-2">
|
||||||
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
|
</AspectRatio>
|
||||||
|
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
|
</AspectRatio>
|
||||||
|
)}
|
||||||
|
{isMultiplePlaylistItems ? (
|
||||||
|
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||||
|
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<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 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{state.filetype && state.filetype === 'audio' && (
|
||||||
|
<Music className="w-4 h-4 mr-2 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||||
|
<File className="w-4 h-4 mr-2 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {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="">{isMultiplePlaylistItems ? state.playlist_title : state.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{isMultiplePlaylistItems ? state.playlist_channel ?? 'unknown' : state.channel ?? 'unknown'} {state.host ? <><span className="text-primary">•</span> {state.host}</> : 'unknown'}</p>
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||||
|
{isMultiplePlaylistItems ? (
|
||||||
|
<><ListVideo className="w-4 h-4 mr-2"/> {state.playlist_n_entries ?? 'unknown'}</>
|
||||||
|
) : (
|
||||||
|
<><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_indices && !isMultiplePlaylistItems && (
|
||||||
|
<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_indices} of {state.playlist_n_entries})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.vcodec && !isMultiplePlaylistItems && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
|
||||||
|
)}
|
||||||
|
{state.acodec && !isMultiplePlaylistItems && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
|
||||||
|
)}
|
||||||
|
{isMultipleAudioFormatSelected && (
|
||||||
|
<span className="border border-border py-1 px-2 rounded">MULTIAUDIO</span>
|
||||||
|
)}
|
||||||
|
{state.dynamic_range && state.dynamic_range !== 'SDR' && !isMultiplePlaylistItems && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{state.sponsorblock_mark && (
|
||||||
|
<span
|
||||||
|
className="border border-border py-1 px-2 rounded cursor-pointer"
|
||||||
|
title={`SPONSORBLOCK MARKED (${state.sponsorblock_mark})`}
|
||||||
|
>
|
||||||
|
SPBLOCK(M)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.sponsorblock_remove && (
|
||||||
|
<span
|
||||||
|
className="border border-border py-1 px-2 rounded cursor-pointer"
|
||||||
|
title={`SPONSORBLOCK REMOVED (${state.sponsorblock_remove})`}
|
||||||
|
>
|
||||||
|
SPBLOCK(R)
|
||||||
|
</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>
|
||||||
|
{!isFlatpak && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openFile(state.filepath, 'explorer')}>
|
||||||
|
<FolderInput className="w-4 h-4" />
|
||||||
|
Reveal
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleSearch(state.url, state.playlist_id ? true : false)}>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button size="sm" variant="destructive">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove from library?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox id="delete-file" checked={itemActionStates.isDeleteFileChecked} onCheckedChange={() => {setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} />
|
||||||
|
<Label htmlFor="delete-file">Delete the downloaded file</Label>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletedDownloads({ downloads }: CompletedDownloadsProps) {
|
||||||
|
const activeCompletedDownloadsPage = useLibraryPageStatesStore(state => state.activeCompletedDownloadsPage);
|
||||||
|
const setActiveCompletedDownloadsPage = useLibraryPageStatesStore(state => state.setActiveCompletedDownloadsPage);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const paginatedCompletedDownloads = paginate(downloads, activeCompletedDownloadsPage, 5);
|
||||||
|
|
||||||
|
// Ensure current page is valid when downloads change
|
||||||
|
useEffect(() => {
|
||||||
|
if (downloads.length > 0 && activeCompletedDownloadsPage > paginatedCompletedDownloads.last_page) {
|
||||||
|
setActiveCompletedDownloadsPage(paginatedCompletedDownloads.last_page);
|
||||||
|
}
|
||||||
|
}, [downloads.length, activeCompletedDownloadsPage, paginatedCompletedDownloads.last_page, setActiveCompletedDownloadsPage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
{paginatedCompletedDownloads.data.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{paginatedCompletedDownloads.data.map((state) => {
|
||||||
|
return (
|
||||||
|
<CompletedDownload key={state.download_id} state={state} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{paginatedCompletedDownloads.pages.length > 1 && (
|
||||||
|
<PaginationBar
|
||||||
|
paginatedData={paginatedCompletedDownloads}
|
||||||
|
setPage={setActiveCompletedDownloadsPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Empty className="mt-10">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<CircleArrowDown className="stroke-primary" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No Completed Downloads</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
You have not completed any downloads yet! Complete downloading something to see here :)
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Spin Up a New Download <ArrowUpRightIcon />
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
src/components/pages/library/incompleteDownloads.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { IndeterminateProgress } from "@/components/custom/indeterminateProgress";
|
||||||
|
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||||
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useAppContext } from "@/providers/appContextProvider";
|
||||||
|
import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
|
import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
|
||||||
|
import { ArrowUpRightIcon, CircleCheck, File, Info, ListVideo, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
|
||||||
|
import { DownloadState } from "@/types/download";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
interface IncompleteDownloadProps {
|
||||||
|
state: DownloadState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncompleteDownloadsProps {
|
||||||
|
downloads: DownloadState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncompleteDownload({ state }: IncompleteDownloadProps) {
|
||||||
|
const downloadActions = useDownloadActionStatesStore(state => state.downloadActions);
|
||||||
|
const setIsResumingDownload = useDownloadActionStatesStore(state => state.setIsResumingDownload);
|
||||||
|
const setIsPausingDownload = useDownloadActionStatesStore(state => state.setIsPausingDownload);
|
||||||
|
const setIsCancelingDownload = useDownloadActionStatesStore(state => state.setIsCancelingDownload);
|
||||||
|
|
||||||
|
const debugMode = useSettingsPageStatesStore(state => state.settings.debug_mode);
|
||||||
|
|
||||||
|
const { pauseDownload, resumeDownload, cancelDownload } = useAppContext()
|
||||||
|
|
||||||
|
const itemActionStates = downloadActions[state.download_id] || {
|
||||||
|
isResuming: false,
|
||||||
|
isPausing: false,
|
||||||
|
isCanceling: false,
|
||||||
|
isDeleteFileChecked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
|
||||||
|
const isMultiplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
|
||||||
|
|
||||||
|
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">
|
||||||
|
{isMultiplePlaylistItems ? (
|
||||||
|
<div className="w-full relative flex items-center justify-center mt-2">
|
||||||
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
|
</AspectRatio>
|
||||||
|
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||||
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
|
</AspectRatio>
|
||||||
|
)}
|
||||||
|
{isMultiplePlaylistItems ? (
|
||||||
|
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||||
|
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
|
||||||
|
</span>
|
||||||
|
) : 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 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{state.filetype && state.filetype === 'audio' && (
|
||||||
|
<Music className="w-4 h-4 mr-2 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||||
|
<File className="w-4 h-4 mr-2 stroke-primary" />
|
||||||
|
)}
|
||||||
|
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||||
|
{state.download_status === 'starting' ? (
|
||||||
|
<><Loader2 className="h-4 w-4 mr-2 stroke-primary animate-spin" /> Processing...</>
|
||||||
|
) : (
|
||||||
|
<><File className="w-4 h-4 mr-2 stroke-primary" /> Unknown</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h4>{isMultiplePlaylistItems ? state.playlist_title : 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.download_status === 'errored') && state.progress && state.status !== 'finished' && (
|
||||||
|
<div className="w-full flex items-center gap-2">
|
||||||
|
{isMultiplePlaylistItems && state.item ? (
|
||||||
|
<span className="text-sm text-nowrap">({state.item})</span>
|
||||||
|
) : null}
|
||||||
|
<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' ? (
|
||||||
|
<span>Processing</span>
|
||||||
|
) : state.download_status && state.download_status === 'errored' ? (
|
||||||
|
<span className="text-destructive"><Info className="inline size-3 mb-1 mr-0.5" /> Errored</span>
|
||||||
|
) : (
|
||||||
|
<span>{state.download_status.charAt(0).toUpperCase() + state.download_status.slice(1)}</span>
|
||||||
|
)} {
|
||||||
|
(debugMode && state.download_id) || (state.download_status === 'errored' && state.download_id) ? (
|
||||||
|
<><span className="text-primary">•</span> ID: {state.download_id.toUpperCase()}</>
|
||||||
|
) : null} {
|
||||||
|
state.download_status === 'downloading' && state.status !== 'finished' && state.speed && (
|
||||||
|
<><span className="text-primary">•</span> Speed: {formatSpeed(state.speed)}</>
|
||||||
|
)} {state.download_status === 'downloading' && state.eta && (
|
||||||
|
<><span className="text-primary">•</span> 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.success("Resumed Download", {
|
||||||
|
// description: "Download resumed, it will re-start shortly.",
|
||||||
|
// })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to Resume Download", {
|
||||||
|
description: `An error occurred while trying to resume the download for "${state.title}".`,
|
||||||
|
})
|
||||||
|
} 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>
|
||||||
|
) : state.download_status === 'errored' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-fill"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsResumingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await resumeDownload(state);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to Restart Download", {
|
||||||
|
description: `An error occurred while trying to restart the download for "${state.title}".`,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsResumingDownload(state.download_id, false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={itemActionStates.isResuming || itemActionStates.isCanceling}
|
||||||
|
>
|
||||||
|
{itemActionStates.isResuming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Retrying
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCw className="w-4 h-4" />
|
||||||
|
Retry
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-fill"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsPausingDownload(state.download_id, true);
|
||||||
|
try {
|
||||||
|
await pauseDownload(state)
|
||||||
|
// toast.success("Paused Download", {
|
||||||
|
// description: "Download paused successfully.",
|
||||||
|
// })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to Pause Download", {
|
||||||
|
description: `An error occurred while trying to pause the download for "${state.title}".`,
|
||||||
|
})
|
||||||
|
} 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.success("Canceled Download", {
|
||||||
|
description: `The download for "${state.title}" has been canceled.`,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to Cancel Download", {
|
||||||
|
description: `An error occurred while trying to cancel the download for "${state.title}".`,
|
||||||
|
})
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IncompleteDownloads({ downloads }: IncompleteDownloadsProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
{downloads.length > 0 ? (
|
||||||
|
downloads.map((state) => {
|
||||||
|
return (
|
||||||
|
<IncompleteDownload key={state.download_id} state={state} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<Empty className="mt-10">
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyMedia variant="icon">
|
||||||
|
<CircleCheck className="stroke-primary" />
|
||||||
|
</EmptyMedia>
|
||||||
|
<EmptyTitle>No Incomplete Downloads</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
You have all caught up! Sit back and relax or just spin up a new download to see here :)
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
Spin Up a New Download <ArrowUpRightIcon />
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2035
src/components/pages/settings/applicationSettings.tsx
Normal file
300
src/components/pages/settings/extensionSettings.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useEnvironmentStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ArrowDownToLine, ArrowRight, EthernetPort, Loader2, Radio, RotateCw } from "lucide-react";
|
||||||
|
import { useSettings } from "@/helpers/use-settings";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { SlidingButton } from "@/components/custom/slidingButton";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { NumberInput } from "@/components/custom/numberInput";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
|
const websocketPortSchema = z.object({
|
||||||
|
port: z.coerce.number<number>({
|
||||||
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
||||||
|
? "Websocket Port is required"
|
||||||
|
: "Websocket Port must be a valid number"
|
||||||
|
}).int({
|
||||||
|
message: "Websocket Port must be an integer"
|
||||||
|
}).min(50000, {
|
||||||
|
message: "Websocket Port must be at least 50000"
|
||||||
|
}).max(60000, {
|
||||||
|
message: "Websocket Port must be at most 60000"
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
function ExtInstallSettings() {
|
||||||
|
const currentPlatform = platform();
|
||||||
|
const isFlatpak = useEnvironmentStore(state => state.isFlatpak);
|
||||||
|
|
||||||
|
const openLink = async (url: string, app: string | null) => {
|
||||||
|
try {
|
||||||
|
await invoke('open_link_with_app', { url: url, appName: app }).then(() => {
|
||||||
|
toast.info("Opening link", {
|
||||||
|
description: `Opening link with ${app ? app : 'default app'}.`,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Failed to open link", {
|
||||||
|
description: "An error occurred while trying to open the link.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="install-neodlp-extension">
|
||||||
|
<h3 className="font-semibold">NeoDLP Extension</h3>
|
||||||
|
<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
|
||||||
|
slidingContent={
|
||||||
|
<div className="flex items-center justify-center gap-2 text-primary-foreground">
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
<span>Get Now</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : currentPlatform === "linux" ? 'google-chrome' : 'chrome')}
|
||||||
|
>
|
||||||
|
<span className="font-semibold flex items-center gap-2">
|
||||||
|
<svg className="size-4 fill-primary-foreground" 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-primary-foreground">
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
<span>Get Now</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', isFlatpak ? null : 'firefox')}
|
||||||
|
>
|
||||||
|
<span className="font-semibold flex items-center gap-2">
|
||||||
|
<svg className="size-4 fill-primary-foreground" 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', isFlatpak ? null : 'msedge')}>Edge</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'opera')}>Opera</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'brave')}>Brave</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://chromewebstore.google.com/detail/neo-downloader-plus/mehopeailfjmiloiiohgicphlcgpompf', isFlatpak ? null : 'vivaldi')}>Vivaldi</Button>
|
||||||
|
<Button variant="outline" onClick={() => openLink('https://addons.mozilla.org/en-US/firefox/addon/neo-downloader-plus', isFlatpak ? null : '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtPortSettings() {
|
||||||
|
const formResetTrigger = useSettingsPageStatesStore(state => state.formResetTrigger);
|
||||||
|
const acknowledgeFormReset = useSettingsPageStatesStore(state => state.acknowledgeFormReset);
|
||||||
|
|
||||||
|
const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port);
|
||||||
|
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
|
||||||
|
const setIsChangingWebSocketPort = useSettingsPageStatesStore(state => state.setIsChangingWebSocketPort);
|
||||||
|
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
|
||||||
|
|
||||||
|
const { saveSettingsKey } = useSettings();
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const websocketPortForm = useForm<z.infer<typeof websocketPortSchema>>({
|
||||||
|
resolver: zodResolver(websocketPortSchema),
|
||||||
|
defaultValues: {
|
||||||
|
port: websocketPort,
|
||||||
|
},
|
||||||
|
mode: "onChange",
|
||||||
|
});
|
||||||
|
const watchedPort = websocketPortForm.watch("port");
|
||||||
|
const { errors: websocketPortFormErrors } = websocketPortForm.formState;
|
||||||
|
|
||||||
|
async function handleWebsocketPortSubmit(values: z.infer<typeof websocketPortSchema>) {
|
||||||
|
setIsChangingWebSocketPort(true);
|
||||||
|
try {
|
||||||
|
// const port = parseInt(values.port, 10);
|
||||||
|
const updatedConfig: Config = await invoke("update_config", {
|
||||||
|
newConfig: {
|
||||||
|
port: values.port,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
saveSettingsKey('websocket_port', updatedConfig.port);
|
||||||
|
toast.success("Websocket port updated", {
|
||||||
|
description: `Websocket port changed to ${values.port}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error changing websocket port:", error);
|
||||||
|
toast.error("Failed to change websocket port", {
|
||||||
|
description: "An error occurred while trying to change the websocket port. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsChangingWebSocketPort(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formResetTrigger > 0) {
|
||||||
|
websocketPortForm.reset();
|
||||||
|
acknowledgeFormReset();
|
||||||
|
}
|
||||||
|
}, [formResetTrigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<NumberInput
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Enter port number"
|
||||||
|
min={0}
|
||||||
|
{...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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExtensionSettings() {
|
||||||
|
const activeSubExtTab = useSettingsPageStatesStore(state => state.activeSubExtTab);
|
||||||
|
const setActiveSubExtTab = useSettingsPageStatesStore(state => state.setActiveSubExtTab);
|
||||||
|
|
||||||
|
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
|
||||||
|
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
|
||||||
|
const setIsRestartingWebSocketServer = useSettingsPageStatesStore(state => state.setIsRestartingWebSocketServer);
|
||||||
|
|
||||||
|
const tabsList = [
|
||||||
|
{ key: "install", label: "Install", icon: ArrowDownToLine, component: <ExtInstallSettings /> },
|
||||||
|
{ key: "port", label: "Port", icon: EthernetPort, component: <ExtPortSettings /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="p-4 space-y-4 my-4">
|
||||||
|
<div className="w-full flex gap-4 items-center justify-between">
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<div className="imgwrapper w-10 h-10 flex items-center justify-center bg-linear-65 from-[#FF43D0] to-[#4444FF] customscheme:from-chart-1 customscheme:to-chart-5 rounded-md overflow-hidden border border-border">
|
||||||
|
<Radio className="size-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="">Extension Websocket Server</h3>
|
||||||
|
<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 className="flex gap-4 items-center">
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
setIsRestartingWebSocketServer(true);
|
||||||
|
try {
|
||||||
|
await invoke("restart_websocket_server");
|
||||||
|
toast.success("Websocket server restarted", {
|
||||||
|
description: "Websocket server restarted successfully.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error restarting websocket server:", error);
|
||||||
|
toast.error("Failed to restart websocket server", {
|
||||||
|
description: "An error occurred while trying to restart the websocket server. Please try again.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRestartingWebSocketServer(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isRestartingWebSocketServer || isChangingWebSocketPort}
|
||||||
|
>
|
||||||
|
{isRestartingWebSocketServer ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Restarting
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
Restart
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Tabs
|
||||||
|
className="w-full flex flex-row items-start gap-4 mt-7"
|
||||||
|
orientation="vertical"
|
||||||
|
value={activeSubExtTab}
|
||||||
|
onValueChange={setActiveSubExtTab}
|
||||||
|
>
|
||||||
|
<TabsList className="shrink-0 grid grid-cols-1 gap-1 p-0 bg-background min-w-45">
|
||||||
|
{tabsList.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.key}
|
||||||
|
value={tab.key}
|
||||||
|
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
|
||||||
|
>
|
||||||
|
<tab.icon className="size-4" /> {tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<div className="min-h-full flex flex-col w-full border-l border-border pl-4">
|
||||||
|
{tabsList.map((tab) => (
|
||||||
|
<TabsContent key={tab.key} value={tab.key} className={clsx("flex flex-col gap-4 min-h-37.5", tab.key === "install" ? "max-w-[90%]" : "max-w-[70%]")}>
|
||||||
|
{tab.component}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
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, Settings, SquarePlay, } from "lucide-react";
|
import { CircleArrowUp } 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 { AllRoutes } from "@/routes";
|
||||||
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
import { useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -14,6 +15,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import useAppUpdater from "@/helpers/use-app-updater";
|
import useAppUpdater from "@/helpers/use-app-updater";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
import * as fs from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
|
const downloadStates = useDownloadStatesStore(state => state.downloadStates);
|
||||||
@@ -25,35 +28,25 @@ export function AppSidebar() {
|
|||||||
const appUpdate = useSettingsPageStatesStore(state => state.appUpdate);
|
const appUpdate = useSettingsPageStatesStore(state => state.appUpdate);
|
||||||
const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp);
|
const isUpdatingApp = useSettingsPageStatesStore(state => state.isUpdatingApp);
|
||||||
const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress);
|
const appUpdateDownloadProgress = useSettingsPageStatesStore(state => state.appUpdateDownloadProgress);
|
||||||
|
const currentPlatform = platform();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
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 [showUpdateCard, setShowUpdateCard] = useState(false);
|
||||||
|
const [isNativeLinuxApp, setIsNativeLinuxApp] = useState(false);
|
||||||
|
|
||||||
const topItems: Array<RoutesObj> = [
|
const topItems: Array<RoutesObj> = [
|
||||||
{
|
AllRoutes[0], // Downloader
|
||||||
title: "Downloader",
|
AllRoutes[1], // Library
|
||||||
url: "/",
|
|
||||||
icon: Download,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Library",
|
|
||||||
url: "/library",
|
|
||||||
icon: SquarePlay,
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const bottomItems: Array<RoutesObj> = [
|
const bottomItems: Array<RoutesObj> = [
|
||||||
{
|
AllRoutes[2], // Settings
|
||||||
title: "Settings",
|
|
||||||
url: "/settings",
|
|
||||||
icon: Settings,
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeout: NodeJS.Timeout;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
if (open) {
|
if (open) {
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
setShowBadge(true);
|
setShowBadge(true);
|
||||||
@@ -69,6 +62,15 @@ export function AppSidebar() {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (currentPlatform === 'linux') {
|
||||||
|
const neoDlpExists = await fs.exists('/usr/bin/neodlp');
|
||||||
|
setIsNativeLinuxApp(neoDlpExists);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [currentPlatform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@@ -77,7 +79,7 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
<NeoDlpLogo className="size-full rounded-lg border border-border" />
|
<NeoDlpLogo className="size-full rounded-md border border-border [--logo-stop-color-1:#4444FF] [--logo-stop-color-2:#FF43D0] customscheme:[--logo-stop-color-1:var(--color-chart-5)] customscheme:[--logo-stop-color-2:var(--color-chart-1)]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-semibold">Neo Downloader Plus</span>
|
<span className="truncate font-semibold">Neo Downloader Plus</span>
|
||||||
@@ -105,7 +107,7 @@ export function AppSidebar() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to={item.url}>
|
<Link to={item.url}>
|
||||||
<item.icon />
|
<item.icon className="stroke-primary" />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
|
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
|
||||||
<Badge className="absolute right-2 inset-y-auto rounded-full font-bold bg-foreground/80">{ongoingDownloads.length}</Badge>
|
<Badge className="absolute right-2 inset-y-auto rounded-full font-bold bg-foreground/80">{ongoingDownloads.length}</Badge>
|
||||||
@@ -124,10 +126,12 @@ export function AppSidebar() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to={item.url}>
|
<Link to={item.url}>
|
||||||
<item.icon />
|
<item.icon className="stroke-primary" />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
|
{item.title === "Library" && ongoingDownloads.length > 0 && showBadge && (
|
||||||
<Badge className="absolute right-2 inset-y-auto h-5 min-w-5 rounded-full px-1 font-mono tabular-nums">{ongoingDownloads.length}</Badge>
|
<Badge className="absolute right-2 inset-y-auto h-5 min-w-5 rounded-full px-1 font-mono tabular-nums flex items-center justify-center">
|
||||||
|
<span className="mt-0.5">{ongoingDownloads.length}</span>
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -143,7 +147,7 @@ export function AppSidebar() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<CircleArrowUp className="size-4" />
|
<CircleArrowUp className="size-4 stroke-primary" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
@@ -154,13 +158,23 @@ export function AppSidebar() {
|
|||||||
{appUpdate && open && showUpdateCard && (
|
{appUpdate && open && showUpdateCard && (
|
||||||
<Card className="gap-4 py-0">
|
<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 || '0.0.0'})</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
A newer 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>
|
<a className="text-xs font-semibold cursor-pointer mt-1" href={`https://github.com/${config.appRepo}/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank">✨ Read Changelog</a>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2.5 p-4">
|
<CardContent className="grid gap-2.5 p-4">
|
||||||
|
{isNativeLinuxApp ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={`https://github.com/${config.appRepo}/releases/tag/v${appUpdate?.version || '0.0.0'}`} target="_blank">Download Now</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -169,18 +183,20 @@ export function AppSidebar() {
|
|||||||
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
|
disabled={ongoingDownloads.length > 0 || isUpdatingApp}
|
||||||
onClick={() => downloadAndInstallAppUpdate(appUpdate)}
|
onClick={() => downloadAndInstallAppUpdate(appUpdate)}
|
||||||
>
|
>
|
||||||
Download and Install
|
Update Now
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
|
<AlertDialogHeader className="flex flex-col items-center text-center gap-2">
|
||||||
|
<CircleArrowUp className="size-7 stroke-muted-foreground" />
|
||||||
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
|
<AlertDialogTitle>Updating {config.appName}</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-center">Updating {config.appName} to v{appUpdate.version}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
|
<AlertDialogDescription className="text-center text-xs mb-2">Updating {config.appName} to v{appUpdate?.version || '0.0.0'}, Please be patience! Do not quit the app untill the update finishes. The app will auto re-launch to complete the update, Please allow all system prompts from {config.appName} if asked.</AlertDialogDescription>
|
||||||
<Progress value={appUpdateDownloadProgress} className="w-full" />
|
<Progress value={appUpdateDownloadProgress} className="w-full" />
|
||||||
<AlertDialogDescription className="text-center">Downloading update... {appUpdateDownloadProgress}%</AlertDialogDescription>
|
<AlertDialogDescription className="text-center">Downloading update... {appUpdateDownloadProgress}%</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -195,7 +211,7 @@ export function AppSidebar() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to={item.url}>
|
<Link to={item.url}>
|
||||||
<item.icon />
|
<item.icon className="stroke-primary" />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -210,7 +226,7 @@ export function AppSidebar() {
|
|||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link to={item.url}>
|
<Link to={item.url}>
|
||||||
<item.icon />
|
<item.icon className="stroke-primary" />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -220,5 +236,5 @@ export function AppSidebar() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
58
src/components/titlebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
|
import { MaximizeIcon } from "@/components/icons/maximize";
|
||||||
|
import { MinimizeIcon } from "@/components/icons/minimize";
|
||||||
|
import { CloseIcon } from "@/components/icons/close";
|
||||||
|
import { UnmaximizeIcon } from "@/components/icons/unmaximize";
|
||||||
|
|
||||||
|
export default function TitleBar() {
|
||||||
|
const [maximized, setMaximized] = useState<boolean>(false);
|
||||||
|
const appWindow = getCurrentWebviewWindow();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="titlebar flex items-center justify-between border-b bg-background">
|
||||||
|
<div className="flex items-center justify-center grow px-4 py-2.5" data-tauri-drag-region>
|
||||||
|
<h1 className="text-sm text-primary font-semibold">NeoDLP</h1>
|
||||||
|
</div>
|
||||||
|
<div className="controls flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="px-4 py-3 hover:bg-muted"
|
||||||
|
id="titlebar-minimize"
|
||||||
|
title="Minimize"
|
||||||
|
onClick={() => appWindow.minimize()}
|
||||||
|
>
|
||||||
|
<MinimizeIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-3 hover:bg-muted"
|
||||||
|
id="titlebar-maximize"
|
||||||
|
title={maximized ? "Unmaximize" : "Maximize"}
|
||||||
|
onClick={async () => {
|
||||||
|
const isMaximized = await appWindow.isMaximized();
|
||||||
|
if (isMaximized) {
|
||||||
|
await appWindow.unmaximize();
|
||||||
|
setMaximized(false);
|
||||||
|
} else {
|
||||||
|
await appWindow.maximize();
|
||||||
|
setMaximized(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{maximized ? (
|
||||||
|
<UnmaximizeIcon />
|
||||||
|
) : (
|
||||||
|
<MaximizeIcon />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-3 hover:bg-destructive"
|
||||||
|
id="titlebar-close"
|
||||||
|
title="Close"
|
||||||
|
onClick={() => appWindow.hide()}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Accordion({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
|
||||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionItem({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
data-slot="accordion-item"
|
|
||||||
className={cn("border-b last:border-b-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionTrigger({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
data-slot="accordion-trigger"
|
|
||||||
className={cn(
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccordionContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
data-slot="accordion-content"
|
|
||||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
||||||
@@ -1,146 +1,128 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
function AlertDialog({
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
|
||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogPortal({
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
data-slot="alert-dialog-overlay"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
function AlertDialogContent({
|
const AlertDialogContent = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
data-slot="alert-dialog-content"
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
function AlertDialogHeader({
|
const AlertDialogHeader = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"div">) {
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot="alert-dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertDialogFooter({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="alert-dialog-footer"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
function AlertDialogTitle({
|
const AlertDialogFooter = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
return (
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
data-slot="alert-dialog-title"
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-lg font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
function AlertDialogDescription({
|
const AlertDialogDescription = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
data-slot="alert-dialog-description"
|
ref={ref}
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
function AlertDialogAction({
|
const AlertDialogAction = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
className={cn(buttonVariants(), className)}
|
className={cn(buttonVariants(), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
function AlertDialogCancel({
|
const AlertDialogCancel = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const alertVariants = cva(
|
const alertVariants = cva(
|
||||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-card text-card-foreground",
|
default: "bg-background text-foreground",
|
||||||
destructive:
|
destructive:
|
||||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -19,48 +19,41 @@ const alertVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Alert({
|
const Alert = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
variant,
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
...props
|
>(({ className, variant, ...props }, ref) => (
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot="alert"
|
ref={ref}
|
||||||
role="alert"
|
role="alert"
|
||||||
className={cn(alertVariants({ variant }), className)}
|
className={cn(alertVariants({ variant }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
Alert.displayName = "Alert"
|
||||||
|
|
||||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
const AlertTitle = React.forwardRef<
|
||||||
return (
|
HTMLParagraphElement,
|
||||||
<div
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
data-slot="alert-title"
|
>(({ className, ...props }, ref) => (
|
||||||
className={cn(
|
<h5
|
||||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
ref={ref}
|
||||||
className
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertTitle.displayName = "AlertTitle"
|
||||||
|
|
||||||
function AlertDescription({
|
const AlertDescription = React.forwardRef<
|
||||||
className,
|
HTMLParagraphElement,
|
||||||
...props
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
}: React.ComponentProps<"div">) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
data-slot="alert-description"
|
ref={ref}
|
||||||
className={cn(
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AlertDescription.displayName = "AlertDescription"
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
export { Alert, AlertTitle, AlertDescription }
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
|
||||||
|
|
||||||
function AspectRatio({
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
|
||||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AspectRatio }
|
export { AspectRatio }
|
||||||
|
|||||||
@@ -1,53 +1,50 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Avatar({
|
const Avatar = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Root
|
<AvatarPrimitive.Root
|
||||||
data-slot="avatar"
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
function AvatarImage({
|
const AvatarImage = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Image
|
<AvatarPrimitive.Image
|
||||||
data-slot="avatar-image"
|
ref={ref}
|
||||||
className={cn("aspect-square size-full", className)}
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
function AvatarFallback({
|
const AvatarFallback = React.forwardRef<
|
||||||
className,
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
...props
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Fallback
|
<AvatarPrimitive.Fallback
|
||||||
data-slot="avatar-fallback"
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback }
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
outline:
|
outline: "text-foreground",
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -25,21 +23,13 @@ const badgeVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Badge({
|
export interface BadgeProps
|
||||||
className,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
variant,
|
VariantProps<typeof badgeVariants> {}
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span"> &
|
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot : "span"
|
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<Comp
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
data-slot="badge"
|
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
data-slot="breadcrumb-list"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-item"
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbLink({
|
|
||||||
asChild,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="breadcrumb-link"
|
|
||||||
className={cn("hover:text-foreground transition-colors", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-page"
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("text-foreground font-normal", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-separator"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbEllipsis({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-ellipsis"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,32 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot as SlotPrimitive } from "radix-ui"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost:
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-8",
|
||||||
icon: "size-9",
|
icon: "h-9 w-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -35,25 +36,24 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
export interface ButtonProps
|
||||||
className,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
variant,
|
VariantProps<typeof buttonVariants> {
|
||||||
size,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? SlotPrimitive.Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Calendar({
|
|
||||||
className,
|
|
||||||
classNames,
|
|
||||||
showOutsideDays = true,
|
|
||||||
captionLayout = "label",
|
|
||||||
buttonVariant = "ghost",
|
|
||||||
formatters,
|
|
||||||
components,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
|
||||||
}) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DayPicker
|
|
||||||
showOutsideDays={showOutsideDays}
|
|
||||||
className={cn(
|
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
captionLayout={captionLayout}
|
|
||||||
formatters={{
|
|
||||||
formatMonthDropdown: (date) =>
|
|
||||||
date.toLocaleString("default", { month: "short" }),
|
|
||||||
...formatters,
|
|
||||||
}}
|
|
||||||
classNames={{
|
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
|
||||||
months: cn(
|
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
|
||||||
defaultClassNames.months
|
|
||||||
),
|
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
|
||||||
nav: cn(
|
|
||||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
|
||||||
defaultClassNames.nav
|
|
||||||
),
|
|
||||||
button_previous: cn(
|
|
||||||
buttonVariants({ variant: buttonVariant }),
|
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
|
||||||
defaultClassNames.button_previous
|
|
||||||
),
|
|
||||||
button_next: cn(
|
|
||||||
buttonVariants({ variant: buttonVariant }),
|
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
|
||||||
defaultClassNames.button_next
|
|
||||||
),
|
|
||||||
month_caption: cn(
|
|
||||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
|
||||||
defaultClassNames.month_caption
|
|
||||||
),
|
|
||||||
dropdowns: cn(
|
|
||||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
|
||||||
defaultClassNames.dropdowns
|
|
||||||
),
|
|
||||||
dropdown_root: cn(
|
|
||||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
|
||||||
defaultClassNames.dropdown_root
|
|
||||||
),
|
|
||||||
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown),
|
|
||||||
caption_label: cn(
|
|
||||||
"select-none font-medium",
|
|
||||||
captionLayout === "label"
|
|
||||||
? "text-sm"
|
|
||||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
|
||||||
defaultClassNames.caption_label
|
|
||||||
),
|
|
||||||
table: "w-full border-collapse",
|
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
||||||
weekday: cn(
|
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
|
||||||
defaultClassNames.weekday
|
|
||||||
),
|
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
|
||||||
week_number_header: cn(
|
|
||||||
"select-none w-(--cell-size)",
|
|
||||||
defaultClassNames.week_number_header
|
|
||||||
),
|
|
||||||
week_number: cn(
|
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
|
||||||
defaultClassNames.week_number
|
|
||||||
),
|
|
||||||
day: cn(
|
|
||||||
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
|
||||||
defaultClassNames.day
|
|
||||||
),
|
|
||||||
range_start: cn(
|
|
||||||
"rounded-l-md bg-accent",
|
|
||||||
defaultClassNames.range_start
|
|
||||||
),
|
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
|
||||||
today: cn(
|
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
|
||||||
defaultClassNames.today
|
|
||||||
),
|
|
||||||
outside: cn(
|
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
|
||||||
defaultClassNames.outside
|
|
||||||
),
|
|
||||||
disabled: cn(
|
|
||||||
"text-muted-foreground opacity-50",
|
|
||||||
defaultClassNames.disabled
|
|
||||||
),
|
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
|
||||||
...classNames,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
Root: ({ className, rootRef, ...props }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="calendar"
|
|
||||||
ref={rootRef}
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
|
||||||
if (orientation === "left") {
|
|
||||||
return (
|
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orientation === "right") {
|
|
||||||
return (
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn("size-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
DayButton: CalendarDayButton,
|
|
||||||
WeekNumber: ({ children, ...props }) => {
|
|
||||||
return (
|
|
||||||
<td {...props}>
|
|
||||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...components,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarDayButton({
|
|
||||||
className,
|
|
||||||
day,
|
|
||||||
modifiers,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (modifiers.focused) ref.current?.focus()
|
|
||||||
}, [modifiers.focused])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
data-day={day.date.toLocaleDateString()}
|
|
||||||
data-selected-single={
|
|
||||||
modifiers.selected &&
|
|
||||||
!modifiers.range_start &&
|
|
||||||
!modifiers.range_end &&
|
|
||||||
!modifiers.range_middle
|
|
||||||
}
|
|
||||||
data-range-start={modifiers.range_start}
|
|
||||||
data-range-end={modifiers.range_end}
|
|
||||||
data-range-middle={modifiers.range_middle}
|
|
||||||
className={cn(
|
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
||||||
defaultClassNames.day,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
|
||||||
@@ -2,91 +2,75 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
const Card = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
Card.displayName = "Card"
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
const CardHeader = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
data-slot="card-header"
|
ref={ref}
|
||||||
className={cn(
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
const CardTitle = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
data-slot="card-title"
|
ref={ref}
|
||||||
className={cn("leading-none font-semibold", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
const CardDescription = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
data-slot="card-description"
|
ref={ref}
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
const CardContent = React.forwardRef<
|
||||||
return (
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
data-slot="card-action"
|
ref={ref}
|
||||||
className={cn(
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
))
|
||||||
}
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-content"
|
|
||||||
className={cn("px-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-footer"
|
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import useEmblaCarousel, {
|
|
||||||
type UseEmblaCarouselType,
|
|
||||||
} from "embla-carousel-react"
|
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1]
|
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
|
||||||
type CarouselOptions = UseCarouselParameters[0]
|
|
||||||
type CarouselPlugin = UseCarouselParameters[1]
|
|
||||||
|
|
||||||
type CarouselProps = {
|
|
||||||
opts?: CarouselOptions
|
|
||||||
plugins?: CarouselPlugin
|
|
||||||
orientation?: "horizontal" | "vertical"
|
|
||||||
setApi?: (api: CarouselApi) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type CarouselContextProps = {
|
|
||||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
|
||||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
|
||||||
scrollPrev: () => void
|
|
||||||
scrollNext: () => void
|
|
||||||
canScrollPrev: boolean
|
|
||||||
canScrollNext: boolean
|
|
||||||
} & CarouselProps
|
|
||||||
|
|
||||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
|
||||||
|
|
||||||
function useCarousel() {
|
|
||||||
const context = React.useContext(CarouselContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useCarousel must be used within a <Carousel />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function Carousel({
|
|
||||||
orientation = "horizontal",
|
|
||||||
opts,
|
|
||||||
setApi,
|
|
||||||
plugins,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
|
||||||
const [carouselRef, api] = useEmblaCarousel(
|
|
||||||
{
|
|
||||||
...opts,
|
|
||||||
axis: orientation === "horizontal" ? "x" : "y",
|
|
||||||
},
|
|
||||||
plugins
|
|
||||||
)
|
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
|
||||||
|
|
||||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
||||||
if (!api) return
|
|
||||||
setCanScrollPrev(api.canScrollPrev())
|
|
||||||
setCanScrollNext(api.canScrollNext())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollPrev = React.useCallback(() => {
|
|
||||||
api?.scrollPrev()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const scrollNext = React.useCallback(() => {
|
|
||||||
api?.scrollNext()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollPrev()
|
|
||||||
} else if (event.key === "ArrowRight") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollNext()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[scrollPrev, scrollNext]
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api || !setApi) return
|
|
||||||
setApi(api)
|
|
||||||
}, [api, setApi])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api) return
|
|
||||||
onSelect(api)
|
|
||||||
api.on("reInit", onSelect)
|
|
||||||
api.on("select", onSelect)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
api?.off("select", onSelect)
|
|
||||||
}
|
|
||||||
}, [api, onSelect])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CarouselContext.Provider
|
|
||||||
value={{
|
|
||||||
carouselRef,
|
|
||||||
api: api,
|
|
||||||
opts,
|
|
||||||
orientation:
|
|
||||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
|
||||||
scrollPrev,
|
|
||||||
scrollNext,
|
|
||||||
canScrollPrev,
|
|
||||||
canScrollNext,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onKeyDownCapture={handleKeyDown}
|
|
||||||
className={cn("relative", className)}
|
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
|
||||||
data-slot="carousel"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CarouselContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
const { carouselRef, orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={carouselRef}
|
|
||||||
className="overflow-hidden"
|
|
||||||
data-slot="carousel-content"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex",
|
|
||||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
const { orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
data-slot="carousel-item"
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 shrink-0 grow-0 basis-full",
|
|
||||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CarouselPrevious({
|
|
||||||
className,
|
|
||||||
variant = "outline",
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Button>) {
|
|
||||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-slot="carousel-previous"
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute size-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "top-1/2 -left-12 -translate-y-1/2"
|
|
||||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollPrev}
|
|
||||||
onClick={scrollPrev}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
<span className="sr-only">Previous slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CarouselNext({
|
|
||||||
className,
|
|
||||||
variant = "outline",
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Button>) {
|
|
||||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
data-slot="carousel-next"
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute size-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "top-1/2 -right-12 -translate-y-1/2"
|
|
||||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollNext}
|
|
||||||
onClick={scrollNext}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowRight />
|
|
||||||
<span className="sr-only">Next slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
type CarouselApi,
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
CarouselPrevious,
|
|
||||||
CarouselNext,
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as RechartsPrimitive from "recharts"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
|
||||||
|
|
||||||
export type ChartConfig = {
|
|
||||||
[k in string]: {
|
|
||||||
label?: React.ReactNode
|
|
||||||
icon?: React.ComponentType
|
|
||||||
} & (
|
|
||||||
| { color?: string; theme?: never }
|
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChartContextProps = {
|
|
||||||
config: ChartConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
|
||||||
|
|
||||||
function useChart() {
|
|
||||||
const context = React.useContext(ChartContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useChart must be used within a <ChartContainer />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartContainer({
|
|
||||||
id,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
config,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
config: ChartConfig
|
|
||||||
children: React.ComponentProps<
|
|
||||||
typeof RechartsPrimitive.ResponsiveContainer
|
|
||||||
>["children"]
|
|
||||||
}) {
|
|
||||||
const uniqueId = React.useId()
|
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContext.Provider value={{ config }}>
|
|
||||||
<div
|
|
||||||
data-slot="chart"
|
|
||||||
data-chart={chartId}
|
|
||||||
className={cn(
|
|
||||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChartStyle id={chartId} config={config} />
|
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
|
||||||
{children}
|
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</ChartContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
||||||
const colorConfig = Object.entries(config).filter(
|
|
||||||
([, config]) => config.theme || config.color
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<style
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: Object.entries(THEMES)
|
|
||||||
.map(
|
|
||||||
([theme, prefix]) => `
|
|
||||||
${prefix} [data-chart=${id}] {
|
|
||||||
${colorConfig
|
|
||||||
.map(([key, itemConfig]) => {
|
|
||||||
const color =
|
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
|
||||||
itemConfig.color
|
|
||||||
return color ? ` --color-${key}: ${color};` : null
|
|
||||||
})
|
|
||||||
.join("\n")}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
|
||||||
|
|
||||||
function ChartTooltipContent({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
className,
|
|
||||||
indicator = "dot",
|
|
||||||
hideLabel = false,
|
|
||||||
hideIndicator = false,
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
labelClassName,
|
|
||||||
formatter,
|
|
||||||
color,
|
|
||||||
nameKey,
|
|
||||||
labelKey,
|
|
||||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
hideLabel?: boolean
|
|
||||||
hideIndicator?: boolean
|
|
||||||
indicator?: "line" | "dot" | "dashed"
|
|
||||||
nameKey?: string
|
|
||||||
labelKey?: string
|
|
||||||
}) {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
|
||||||
if (hideLabel || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const [item] = payload
|
|
||||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const value =
|
|
||||||
!labelKey && typeof label === "string"
|
|
||||||
? config[label as keyof typeof config]?.label || label
|
|
||||||
: itemConfig?.label
|
|
||||||
|
|
||||||
if (labelFormatter) {
|
|
||||||
return (
|
|
||||||
<div className={cn("font-medium", labelClassName)}>
|
|
||||||
{labelFormatter(value, payload)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
|
||||||
}, [
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
payload,
|
|
||||||
hideLabel,
|
|
||||||
labelClassName,
|
|
||||||
config,
|
|
||||||
labelKey,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!nestLabel ? tooltipLabel : null}
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{payload.map((item, index) => {
|
|
||||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const indicatorColor = color || item.payload.fill || item.color
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.dataKey}
|
|
||||||
className={cn(
|
|
||||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
|
||||||
indicator === "dot" && "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatter && item?.value !== undefined && item.name ? (
|
|
||||||
formatter(item.value, item.name, item, index, item.payload)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{itemConfig?.icon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
!hideIndicator && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
|
||||||
{
|
|
||||||
"h-2.5 w-2.5": indicator === "dot",
|
|
||||||
"w-1": indicator === "line",
|
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
|
||||||
indicator === "dashed",
|
|
||||||
"my-0.5": nestLabel && indicator === "dashed",
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--color-bg": indicatorColor,
|
|
||||||
"--color-border": indicatorColor,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 justify-between leading-none",
|
|
||||||
nestLabel ? "items-end" : "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{nestLabel ? tooltipLabel : null}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{itemConfig?.label || item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{item.value && (
|
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
|
||||||
{item.value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend
|
|
||||||
|
|
||||||
function ChartLegendContent({
|
|
||||||
className,
|
|
||||||
hideIcon = false,
|
|
||||||
payload,
|
|
||||||
verticalAlign = "bottom",
|
|
||||||
nameKey,
|
|
||||||
}: React.ComponentProps<"div"> &
|
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
|
||||||
hideIcon?: boolean
|
|
||||||
nameKey?: string
|
|
||||||
}) {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
if (!payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center gap-4",
|
|
||||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{payload.map((item) => {
|
|
||||||
const key = `${nameKey || item.dataKey || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.value}
|
|
||||||
className={cn(
|
|
||||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{itemConfig?.icon && !hideIcon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: item.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{itemConfig?.label}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
|
||||||
function getPayloadConfigFromPayload(
|
|
||||||
config: ChartConfig,
|
|
||||||
payload: unknown,
|
|
||||||
key: string
|
|
||||||
) {
|
|
||||||
if (typeof payload !== "object" || payload === null) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadPayload =
|
|
||||||
"payload" in payload &&
|
|
||||||
typeof payload.payload === "object" &&
|
|
||||||
payload.payload !== null
|
|
||||||
? payload.payload
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
let configLabelKey: string = key
|
|
||||||
|
|
||||||
if (
|
|
||||||
key in payload &&
|
|
||||||
typeof payload[key as keyof typeof payload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string
|
|
||||||
} else if (
|
|
||||||
payloadPayload &&
|
|
||||||
key in payloadPayload &&
|
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payloadPayload[
|
|
||||||
key as keyof typeof payloadPayload
|
|
||||||
] as string
|
|
||||||
}
|
|
||||||
|
|
||||||
return configLabelKey in config
|
|
||||||
? config[configLabelKey]
|
|
||||||
: config[key as keyof typeof config]
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
ChartStyle,
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||||
import { CheckIcon } from "lucide-react"
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -21,7 +19,7 @@ function Checkbox({
|
|||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="flex items-center justify-center text-current transition-none"
|
className="grid place-content-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
@@ -30,3 +28,4 @@ function Checkbox({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { Checkbox }
|
export { Checkbox }
|
||||||
|
|
||||||
|
|||||||