116 Commits

Author SHA1 Message Date
MasterAcnolo
8b31436e25 feat: publish package to fedora copr, add script to automaticaly build and upload the file.
Enhance readme and fix auto updater issue
2026-06-29 10:44:17 +02:00
MasterAcnolo
7d5203001b add: BINARIES.md readme file. About how to install the binaries for dev mode 2026-06-28 21:49:53 +02:00
MasterAcnolo
60709c4fad fix: don't show dialog box if no previous version is available. 2026-06-28 21:48:24 +02:00
MasterAcnolo
2fb6509edc add: dark.zip and white.zip theme to the repo 2026-06-28 21:34:26 +02:00
MasterAcnolo
eebce83976 fix: small code quality and mismatch. 2026-06-28 21:34:00 +02:00
MasterAcnolo
a56a070bc8 feat: support Linux. Refactor resources architecture. Create build script
Currently, themes are disabled and firefox notification too.
2026-06-28 21:32:49 +02:00
MasterAcnolo
e1ef7032bb add: default theme applied in app. If no theme is there 2026-06-18 21:55:50 +02:00
MasterAcnolo
e7333b7b6b fix: missing enableHardwareAcceleration settings in readme 2026-06-10 21:43:38 +02:00
Axel Nicolas
e2b51d32d1 Merge pull request #43 from MasterAcnolo/1.6.0
1.5.1
2026-06-02 22:47:40 +02:00
MasterAcnolo
81515c6ab8 fix: revert version to 1.5.1 in package.json and README.md 2026-06-02 22:35:29 +02:00
MasterAcnolo
c88189b44f feat: add Devtron in dev mode
remove unused settings item
add setting to disable hardware acceleration (enabled by default)
optimize main.js startup loading
2026-06-01 22:39:12 +02:00
MasterAcnolo
b745ebd29f fix: display "dev" in app version if the app is not packaged 2026-05-31 21:45:56 +02:00
MasterAcnolo
13ee4c170b fix: fix width for form 2026-05-31 21:12:35 +02:00
MasterAcnolo
0218664c5a refactor: add full documentation and fix theme loading robustness
- Add JSDoc comments across frontend and backend modules
- Improve code readability and maintainability with consistent documentation style
- Document Electron IPC handlers, UI utilities, and theme system
- Add missing function/class documentation in theme loader and app services
- Minor structural improvements and cleanup across Electron main process modules
- Minor fixes on variable names
2026-05-31 20:38:42 +02:00
MasterAcnolo
6afc5e06d6 fix: app startup. Can't start another instance now. 2026-05-31 15:48:20 +02:00
MasterAcnolo
519dcad6e6 fix: rename localMode => devMode for clarity 2026-05-29 07:42:49 +02:00
MasterAcnolo
ac94d68224 fix: rename localMode => devMode for clarity 2026-05-29 07:42:38 +02:00
MasterAcnolo
0f876fcb70 fix: update dependencies 2026-05-29 07:41:54 +02:00
MasterAcnolo
a6c11ba7c4 update: version for 1.6.0 2026-05-15 19:57:33 +02:00
MasterAcnolo
44132dd7b5 fix: update dependencies 2026-05-11 21:14:00 +02:00
MasterAcnolo
15ab6892b7 Fix: remove duplication roadmap (now full in website) 2026-05-01 12:07:39 +02:00
MasterAcnolo
725b6f0ada Fix: Configuration File Location 2026-04-15 13:55:13 +02:00
MasterAcnolo
ad0e4f858b Fix: missing options in readme 2026-04-15 13:50:39 +02:00
MasterAcnolo
68108e3a66 Update Workshop link to GitHub repository 2026-04-14 11:28:53 +02:00
MasterAcnolo
eff2affd42 1.5.0 !!!!!!
IT'S TIME
2026-04-14 11:20:48 +02:00
MasterAcnolo
87cf05643f Feat: Update README with detailed project structure and architecture overview 2026-04-14 11:04:55 +02:00
MasterAcnolo
ce14b8abd7 Fix: Remove emoji from error messages in download and info controllers 2026-04-14 09:31:23 +02:00
MasterAcnolo
44ed1bbf0e Feat: Add link to the most recent roadmap in README 2026-04-14 09:25:59 +02:00
MasterAcnolo
e0d017d9fd Fix: Remove z-index from container styles and add z-index to settings button 2026-04-14 09:22:31 +02:00
MasterAcnolo
c3ea1ac0bd Feat: Add section for submitting community themes in README 2026-04-14 09:22:24 +02:00
MasterAcnolo
238d4ea717 Fix: Save Themes inside "AppData" 2026-04-13 21:49:19 +02:00
MasterAcnolo
d879a5eb1d Feat: Add dynamic icon path for main window based on local mode 2026-04-13 20:57:56 +02:00
MasterAcnolo
40bea35286 Feat: Implement notification feature for download completion 2026-04-13 20:39:23 +02:00
MasterAcnolo
83ac76381a Fix: Update links to Freedom-Loader-Site in documentation and IPC handlers 2026-04-13 20:20:47 +02:00
MasterAcnolo
6ff0c71be7 Feat: Add related repository links for website and workshop in README 2026-04-13 19:48:09 +02:00
MasterAcnolo
fd0b339bcc Fix: Update link to Freedom Loader Workshop in README 2026-04-13 19:15:33 +02:00
MasterAcnolo
bc5faf38b6 Fix: Correct typo in Workshop badge link in README 2026-04-13 19:14:35 +02:00
MasterAcnolo
e744e55475 Feat: Add Theme Workshop section to README and update features list 2026-04-13 19:13:04 +02:00
MasterAcnolo
a581c9309d Fix: Update external links for website and wiki in IPC handlers 2026-04-13 19:13:00 +02:00
MasterAcnolo
61040769ee Feat: Add "Theme Workshop" button and associated IPC handler for workshop access 2026-04-12 21:40:13 +02:00
MasterAcnolo
06f6a30797 Feat: Enhance theme settings with new button styles for open theme and JSON options 2026-04-12 19:32:05 +02:00
MasterAcnolo
a2653eef57 Feat: Implement playlist download count tracking during downloads with UI updates 2026-04-12 18:24:19 +02:00
MasterAcnolo
89573d11f7 Fix: Adjust z-index for reference 2026-04-12 18:17:43 +02:00
MasterAcnolo
f0c28a7f5e Fix: Moving Custom Top BAr Settings into advanced 2026-04-12 18:15:19 +02:00
MasterAcnolo
f3c37c2498 Feat: Add createPlaylistFolders feature to organize downloads into dedicated folders with toggle settings 2026-04-12 18:13:38 +02:00
MasterAcnolo
8fe4e7c38a Fix: Update save path display to include title attribute for better accessibility 2026-04-12 17:40:01 +02:00
MasterAcnolo
912f4042e5 Fix: Enhance error handling for playlist fetch failures by retrying with single video 2026-04-12 17:34:17 +02:00
MasterAcnolo
5dc61b4a15 Fix: Adjust stop button hover scale effect for better user experience 2026-04-12 17:28:13 +02:00
MasterAcnolo
5fb49b0c9d Fix: Update button text 2026-04-12 17:04:20 +02:00
MasterAcnolo
75fdf91fcd Refactor: auto-update functionality with user prompts and download progress 2026-04-12 16:57:39 +02:00
MasterAcnolo
011e0a703a Feat: Enhance download experience with progress indicators and toast notifications 2026-04-12 12:04:05 +02:00
MasterAcnolo
5aebc78d44 Fix: IPC button not working 2026-04-12 11:22:31 +02:00
MasterAcnolo
60137a15dc Feat: Implement download cancellation feature with stop button and progress handling 2026-04-12 11:22:18 +02:00
MasterAcnolo
d05b9e2511 Feat: Add theme reloading functionality with refresh button in settings panel 2026-04-12 09:32:15 +02:00
MasterAcnolo
ebb48f4f8b Fix: Remove themes from the theme order array 2026-04-11 22:38:25 +02:00
MasterAcnolo
8be47b9821 Feat: Implement splash screen with progress indicators during startup 2026-04-11 22:36:42 +02:00
MasterAcnolo
ca8b107f61 Fix: Display custom these with a basic order 2026-04-11 22:16:05 +02:00
MasterAcnolo
f25d07cb83 Fix: Remove empty title tag from index.html and unused app styles from styles.css 2026-04-11 22:15:49 +02:00
MasterAcnolo
49e1e1b507 Feat: Add theme folder access functionality in UI 2026-04-11 18:25:17 +02:00
MasterAcnolo
23d11d8933 Feat: Custom Theme features and clean the old one 2026-04-11 18:10:51 +02:00
MasterAcnolo
9f88fe19c3 Fix: Theme Saving 2026-04-11 16:37:35 +02:00
MasterAcnolo
9b4b0306c7 fix: move config to AppData to prevent settings loss on update, add config.dev.json for dev environment 2026-04-11 16:33:58 +02:00
MasterAcnolo
96401a6e1f Refactor: split main.js into app/ modules 2026-04-11 16:14:21 +02:00
MasterAcnolo
0c7103c5c9 Update: Readme version and file structure 2026-04-11 16:13:36 +02:00
MasterAcnolo
798084e3bf Fix: Remove unusued element 2026-04-11 16:00:51 +02:00
MasterAcnolo
5c4e6669fe Fix: Update theme JSON structure for audio and button settings 2026-04-11 15:18:11 +02:00
MasterAcnolo
00c542925c Refactor: Update CSS variables
Fix Progress bar
2026-04-11 15:17:05 +02:00
MasterAcnolo
24460cf0b5 Refactor: Restructure HTML layout and enhance CSS styles for improved UI 2026-04-11 14:20:05 +02:00
MasterAcnolo
a3c1b1d753 Fix: Settings Secondary Color in LIght THeme 2026-04-11 14:01:39 +02:00
MasterAcnolo
5ade3fe144 Merge pull request #39 from MasterAcnolo/1.5.0
1.4.7
2026-04-09 13:46:20 +02:00
MasterAcnolo
577069b471 Fix: Remove unusued pictures 2026-04-09 13:35:04 +02:00
MasterAcnolo
a4da47647a Update: App Version in ReadME 2026-04-09 13:31:58 +02:00
MasterAcnolo
c5b29a0647 Update: Dependencies 2026-04-08 15:04:56 +02:00
MasterAcnolo
90e1a5fde7 Merge pull request #36 from MasterAcnolo/1.4.6
1.4.6
2026-03-18 10:00:17 +01:00
MasterAcnolo
dcfea2073c Fix: Wrong files Path 2026-03-18 09:20:25 +01:00
MasterAcnolo
0be1ef1710 Update: YT-DLP 2026-03-18 09:13:56 +01:00
MasterAcnolo
da393b19d9 Refactor: Suffixe in helpers/ 2026-03-18 09:13:47 +01:00
MasterAcnolo
1c27cc19cf Fix: Remove unusued modules and disable devTools when packaged 2026-03-16 21:22:41 +01:00
MasterAcnolo
ff7366c46c Fix: Update YT-DLP 2026-03-16 21:22:24 +01:00
MasterAcnolo
459d4c6df4 Fix: Increment app version 2026-03-16 21:15:35 +01:00
MasterAcnolo
d2ca3e99d2 Fix: Dependencies 2026-03-16 21:15:17 +01:00
MasterAcnolo
8ad3ea55cc Docs: Update ReadME 2026-03-16 20:57:38 +01:00
MasterAcnolo
71dbd104f8 Merge pull request #35 from MasterAcnolo/working
1.4.5
2026-02-21 13:00:36 +01:00
MasterAcnolo
57a5defb08 Fix: Update Dependencies 2026-02-21 12:43:36 +01:00
MasterAcnolo
25c78ff8b7 Fix: Increment App Version 2026-02-21 12:40:27 +01:00
MasterAcnolo
792a59d8d7 Feat: You can now fetch image from all website. 2026-02-21 12:38:57 +01:00
MasterAcnolo
b13c98dda2 Refactor: Translate all comms in English 2026-02-21 12:27:13 +01:00
MasterAcnolo
1f103c20b9 Fix: Remove Discord RPC Update. It's useless and take performance for nothing. 2026-02-19 22:23:30 +01:00
MasterAcnolo
70d62959ca Fix: Reduce Line Complexity featuresPath 2026-02-19 22:08:56 +01:00
MasterAcnolo
83ca963502 Merge with false positive
Merge with False positive
2026-02-18 15:10:01 +01:00
MasterAcnolo
9fb9295093 Fix: Try to re run workflow 2026-02-18 14:51:57 +01:00
MasterAcnolo
74fcdbb738 Fix: Undo Path Validation for coming back on a classic one 2026-02-18 14:43:40 +01:00
MasterAcnolo
32c9a66a35 Fix: Centralize default download path and enhance path validation logic 2026-02-17 23:29:31 +01:00
MasterAcnolo
45e0840fea Fix: Correct windowMs value in rate limit configuration to 15 minutes 2026-02-17 23:26:01 +01:00
MasterAcnolo
19b3f8b947 Fix: Refactor conditional statements for starting Discord RPC and Auto Update 2026-02-17 21:49:52 +01:00
MasterAcnolo
cc5bde5b48 Fix: Correct comment for rate limit window duration in rateLimit.js 2026-02-17 21:49:43 +01:00
MasterAcnolo
9a48ac05e3 Fix: Uncomplete comments and remove repeated code 2026-02-17 21:42:12 +01:00
MasterAcnolo
5750444655 Fix: Update full changelog link format in release template 2026-02-17 21:35:57 +01:00
MasterAcnolo
fdb22f2342 Fix: Improve browser detection logging and error handling 2026-02-17 21:29:51 +01:00
MasterAcnolo
760550e521 Fix: Centralize resource paths and icons 2026-02-17 21:28:46 +01:00
MasterAcnolo
334e83ae1f Fix: Small coms in AppVersion.js 2026-02-17 21:12:01 +01:00
MasterAcnolo
5d776594fc Fix: App Not Stopping correctly + Better Logs for end :) 2026-02-17 21:09:09 +01:00
MasterAcnolo
adb36e3fa2 Fix: We are not on apple system. Don't care about Darwin 2026-02-17 20:59:22 +01:00
MasterAcnolo
87945fba80 Fix: Missing semi colon, Better Folder Creation, And remove the fucking bad code who was repeat too much (wtf i did) 2026-02-17 18:05:07 +01:00
MasterAcnolo
2234f9ed81 Fix: Update version numbers to 1.4.4 in package.json and ReadME 2026-02-17 15:22:38 +01:00
MasterAcnolo
7cd3b4be06 Fix: Update playlist badge text to English 2026-02-17 15:19:23 +01:00
MasterAcnolo
a42b020b96 Fix: Update URL check to correctly identify playlist links 2026-02-17 15:19:17 +01:00
MasterAcnolo
c1e70dae80 Feat: Add Better playlist layout 2026-02-17 10:27:13 +01:00
MasterAcnolo
6877c51c0e Fix: Add Small Comms for clarity 2026-02-17 10:26:54 +01:00
MasterAcnolo
5a41090175 Fix: Remove Unusued Code 2026-02-17 10:26:39 +01:00
MasterAcnolo
5983f21759 Fix: Big mistake when estimating video format (? -> &) 2026-02-17 09:50:32 +01:00
MasterAcnolo
ee9e02410f Merge pull request #31 from MasterAcnolo/fix-input
1.4.3
2026-02-16 22:34:12 +01:00
MasterAcnolo
909480083e Fix: Update Dependencies 2026-02-16 22:00:31 +01:00
MasterAcnolo
ca0492ed04 Fix: Update ReadME App Version 2026-02-16 21:37:24 +01:00
MasterAcnolo
6c73dd75be Feat: Add Paste BTN URL Input 2026-02-16 21:34:39 +01:00
MasterAcnolo
556dccec08 Fix: Increment App version 2026-02-16 21:29:24 +01:00
95 changed files with 5798 additions and 1919 deletions

View File

@@ -9,8 +9,8 @@ assignees: 'MasterAcnolo'
## Pre-submission Checklist ## Pre-submission Checklist
- [ ] I have checked the [FAQ](https://masteracnolo.github.io/FreedomLoader/pages/faq.html) - [ ] I have checked the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/pages/faq)
- [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/FreedomLoader/pages/wiki.html) - [ ] I have reviewed the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/pages/wiki)
- [ ] I have searched existing issues - [ ] I have searched existing issues
## Question ## Question

View File

@@ -7,4 +7,4 @@
## Found a bug or issue? ## Found a bug or issue?
- Please report it in the GitHub Issues section - Please report it in the GitHub Issues section
**Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/1.4.0...1.4.1 **Full Changelog**: https://github.com/MasterAcnolo/Freedom-Loader/compare/1.W.X...1.Y.Z

24
.gitignore vendored
View File

@@ -1,11 +1,23 @@
# Dependencies
/node_modules /node_modules
/downloads
# Build Output Folder
/dist /dist
/out /out
/ressources/ffmpeg.exe # Config JSON File. Create it and fill it if you want to have dev only settings
/ressources/ffprobe.exe config/config.dev.json
/ressources/deno.exe
logs/*.json # IDE
logs/*.log /.idea
/.vscode
# Linux executable
ffmpeg
ffprobe
deno
# Windows Executable
ffmpeg.exe
ffprobe.exe
deno.exe

96
BINARIES.md Normal file
View File

@@ -0,0 +1,96 @@
# External binaries
This project depends on three external tools that are **not bundled in the repository**.
You must download them manually and place each binary in the correct folder before building or running the app locally.
---
## Required tools
| Tool | Role |
|---------|---------------------------------------------|
| yt-dlp | Video and audio download engine |
| ffmpeg | Media encoding, transcoding, muxing |
| ffprobe | Media file analysis and metadata extraction |
| deno | JavaScript / TypeScript runtime |
---
## Folder structure
Place the binaries exactly as shown below.
Do not rename them — the application resolves them by these exact names.
```
resources/
└── binaries/
├── linux/
│ ├── yt-dlp
│ ├── ffmpeg
│ ├── ffprobe
│ └── deno
└── win-32/
├── yt-dlp.exe
├── ffmpeg.exe
├── ffprobe.exe
└── deno.exe
```
---
## Download links
### yt-dlp
| Platform | URL |
|----------|----------------------------------------------------------------------|
| Windows | https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe |
| Linux | https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp |
Rename the downloaded file to `yt-dlp` (or `yt-dlp.exe` on Windows).
---
### ffmpeg and ffprobe
**Windows and Linux** — both binaries are included in the same archive.
| Platform | URL |
|-------------|-----------------------------------------------------------------------------------------------------------|
| Windows x64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip |
| Linux x64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz |
| Linux arm64 | https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz |
Extract the archive and copy `ffmpeg` and `ffprobe` from the `bin/` subfolder.
---
### deno
| Platform | URL |
|-------------|----------------------------------------------------------------------------------------------|
| Windows x64 | https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip |
| Linux x64 | https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip |
| Linux arm64 | https://github.com/denoland/deno/releases/latest/download/deno-aarch64-unknown-linux-gnu.zip |
Each archive contains a single binary. Rename it to `deno` (or `deno.exe` on Windows).
---
## Permissions (Linux )
After placing the binaries, make them executable:
```bash
chmod +x resources/binaries/linux/yt-dlp
chmod +x resources/binaries/linux/ffmpeg
chmod +x resources/binaries/linux/ffprobe
chmod +x resources/binaries/linux/deno
```
---
## Notes
- These binaries are excluded from version control via `.gitignore`.
- Always use the latest stable release of each tool.

View File

@@ -105,7 +105,7 @@ Freedom-Loader/
│ ├── controller/ # Business logic │ ├── controller/ # Business logic
│ └── helpers/ # Utility functions │ └── helpers/ # Utility functions
├── public/ # Frontend (HTML, CSS, JS) ├── public/ # Frontend (HTML, CSS, JS)
└── ressources/ # Binaries (yt-dlp, ffmpeg, etc.) └── resources/ # Binaries (yt-dlp, ffmpeg, etc.)
``` ```
--- ---
@@ -148,8 +148,8 @@ See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details.
## Questions? ## Questions?
- Check the [FAQ](https://masteracnolo.github.io/FreedomLoader/pages/faq.html) - Check the [FAQ](https://masteracnolo.github.io/Freedom-Loader-Site/faq)
- Review the [Wiki](https://masteracnolo.github.io/FreedomLoader/pages/wiki.html) - Review the [Wiki](https://masteracnolo.github.io/Freedom-Loader-Site/wiki)
- Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose) - Open a [Question issue](https://github.com/MasterAcnolo/Freedom-Loader/issues/new/choose)
--- ---

280
README.md
View File

@@ -1,15 +1,16 @@
<div align="center"> <div align="center">
<a href="https://masteracnolo.github.io/FreedomLoader/"><img <a href="https://masteracnolo.github.io/Freedom-Loader-Site/"><img
src="./build/banner.png" src="./build/banner.png"
alt="Banner" alt="Banner"
style="width: 50%;"/></a> style="width: 50%;"/></a>
### **A clean, open-source multimedia downloader for Windows** ### **A clean, open-source multimedia downloader for Windows and Linux**
[![Release](https://img.shields.io/badge/Release-1.4.2-blue?style=for-the-badge)](https://github.com/MasterAcnolo/Freedom-Loader/releases) [![Release](https://img.shields.io/badge/Release-1.6.0-blue?style=for-the-badge)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-red.svg?style=for-the-badge)](https://www.gnu.org/licenses/gpl-3.0)
[![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/FreedomLoader/) [![Website](https://img.shields.io/badge/Website-Visit-404040?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Site/)
[![Workshop](https://img.shields.io/badge/Worshop-Visit-0E05A1?style=for-the-badge)](https://masteracnolo.github.io/Freedom-Loader-Workshop/)
<a href="https://www.firefox.com/fr/?utm_campaign=SET_DEFAULT_BROWSER"><img src="https://img.shields.io/badge/Require Firefox-E66000?style=for-the-badge&logo=Firefox-Browser&logoColor=white"></a> <a href="https://www.firefox.com/fr/?utm_campaign=SET_DEFAULT_BROWSER"><img src="https://img.shields.io/badge/Require Firefox-E66000?style=for-the-badge&logo=Firefox-Browser&logoColor=white"></a>
</div> </div>
@@ -22,6 +23,11 @@ Freedom Loader is a desktop application built with Electron that provides a stra
The primary goal is to make media downloading accessible to users who want offline access to their favorite content, particularly in situations where internet connectivity is unreliable or unavailable. The primary goal is to make media downloading accessible to users who want offline access to their favorite content, particularly in situations where internet connectivity is unreliable or unavailable.
### See related repo:
- **[Website](https://masteracnolo.github.io/Freedom-Loader-Site/)** - Official project website and documentation
- **[Workshop](https://github.com/MasterAcnolo/Freedom-Loader-Workshop)** - Theme creation tool and theme browser
## Table of Contents ## Table of Contents
- [Features](#features) - [Features](#features)
@@ -30,10 +36,12 @@ The primary goal is to make media downloading accessible to users who want offli
- [Preview](#preview) - [Preview](#preview)
- [Configuration](#configuration) - [Configuration](#configuration)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Theme Workshop](#Theme-Workshop)
- [Technology Stack](#technology-stack) - [Technology Stack](#technology-stack)
- [Development](#development) - [Development](#development)
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Submitting a community theme](#Submitting-a-community-theme)
- [Support](#support) - [Support](#support)
- [License](#license) - [License](#license)
@@ -53,7 +61,9 @@ The primary goal is to make media downloading accessible to users who want offli
### User Interface ### User Interface
- **Custom window controls** - Frameless window with custom top bar (optional) - **Custom window controls** - Frameless window with custom top bar (optional)
- **Theme system** - Multiple pre-built themes with dynamic switching - **Custom themes** — ZIP-based theme system with live preview
- **Theme Workshop** — web-based theme creator and browser
- **Toast notifications** — in-app feedback system
- **Real-time progress tracking** - Live download progress bar with network speed indicator - **Real-time progress tracking** - Live download progress bar with network speed indicator
- **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming - **Server-Sent Events (SSE)** - Non-blocking progress updates via event streaming
- **Settings panel** - In-app configuration interface with live updates - **Settings panel** - In-app configuration interface with live updates
@@ -70,38 +80,90 @@ The primary goal is to make media downloading accessible to users who want offli
- **Rate limiting** - Express-based request throttling to prevent server overload - **Rate limiting** - Express-based request throttling to prevent server overload
- **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections - **Graceful shutdown** - Proper cleanup of servers, logs, and RPC connections
- **System notifications** - Windows notifications on download completion (clickable to open folder) - **System notifications** - Windows notifications on download completion (clickable to open folder)
- **Splash screen** — animated loading screen on startup
- **Stop download** — ability to cancel in-progress downloads
- **Playlist folders** — dedicated folder creation per playlist
## Installation ## Installation
[![Fedora Copr](https://img.shields.io/badge/Fedora-Copr-294172?style=for-the-badge&logo=fedora&logoColor=white)](https://copr.fedorainfracloud.org/coprs/masteracnolo/freedom-loader/)
[![Debian/Ubuntu](https://img.shields.io/badge/.deb-Debian%2FUbuntu-A81D33?style=for-the-badge&logo=debian&logoColor=white)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
[//]: # ([![Snap Store]&#40;https://img.shields.io/badge/Snap-Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white&#41;]&#40;https://snapcraft.io/freedom-loader&#41;)
[![AppImage](https://img.shields.io/badge/AppImage-Universal-1E88E5?style=for-the-badge&logo=linux&logoColor=white)](https://github.com/MasterAcnolo/Freedom-Loader/releases)
### Requirements ### Requirements
- **Operating System**: Windows 10 or later - **Windows**: Windows 10 or later
- **Browser**: Mozilla Firefox (required for cookie extraction) - **Linux**: any modern x86_64 distribution
- [Download Firefox](https://www.firefox.com) - **Browser**: Mozilla Firefox ([download](https://www.firefox.com)) required for cookie extraction, on both platforms
### Standard Installation ### Windows
1. Download the latest release from the [Releases page](https://github.com/MasterAcnolo/Freedom-Loader/releases) 1. Download the latest `.exe` from the [Releases page](https://github.com/MasterAcnolo/Freedom-Loader/releases)
2. Run the downloaded `.exe` installer 2. Run the installer (click "More info" → "Run anyway" if Windows Defender warns you)
3. If Windows Defender shows a warning, click "More info" then "Run anyway" 3. Follow the installation wizard
4. Follow the installation wizard
### Linux
#### Fedora (via Copr — recommended)
Installing via Copr lets `dnf` handle future updates automatically:
```bash
sudo dnf copr enable masteracnolo/freedom-loader
sudo dnf install freedom-loader
```
#### Debian / Ubuntu (.deb)
```bash
# Download the latest .deb from the Releases page, then:
sudo apt install ./freedom-loader_1.x.x_amd64.deb
```
[//]: # (#### Snap &#40;any distro with snapd&#41;)
[//]: # ()
[//]: # (```bash)
[//]: # (sudo snap install freedom-loader)
[//]: # (```)
#### RPM (Fedora/RHEL/openSUSE, without Copr)
```bash
sudo dnf install ./freedom-loader-1.x.y.x86_64.rpm
```
#### AppImage (any distro, no installation needed)
```bash
# Download the latest .AppImage from the Releases page, then:
chmod +x Freedom-Loader-1.x.y.AppImage
./Freedom-Loader-1.x.y.AppImage
```
Once installed via any method, launch Freedom Loader from your application menu, or from a terminal with:
```bash
freedom-loader
```
### File Locations ### File Locations
Downloaded files are stored in: | | Windows | Linux |
``` |---------------|---------------------------------------------------------|--------------------------------------|
C:\Users\[USERNAME]\Downloads\Freedom Loader | **Downloads** | `C:\Users\[USERNAME]\Downloads\Freedom Loader` | `~/Downloads/Freedom Loader` |
``` | **Logs** | `C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\` | `~/.local/share/FreedomLoader/logs/` |
> [!NOTE]
> *You can change it whenever you want. Just click the "Edit" button in the UI.*
Application logs can be found at: > [!NOTE]
``` > The download folder can be changed anytime via the "Edit" button in the UI, on both platforms.
C:\Users\[USERNAME]\AppData\Local\FreedomLoader\logs\
```
## Usage ## Usage
> [!TIP] On Linux, you can start Freedom Loader using `freedom-loader` command
### Basic Download ### Basic Download
1. Launch Freedom Loader 1. Launch Freedom Loader
@@ -125,7 +187,7 @@ The application supports various configuration options:
If you encounter issues: If you encounter issues:
1. Restart the application 1. Restart the application
2. Check the logs in `AppData\Local\FreedomLoader\logs\` 2. Check the logs in `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs`
3. Open the developer console: `CTRL + SHIFT + I` → "Toggle Developer Tools" 3. Open the developer console: `CTRL + SHIFT + I` → "Toggle Developer Tools"
4. Create an issue on GitHub with logs and reproduction steps 4. Create an issue on GitHub with logs and reproduction steps
@@ -151,8 +213,15 @@ If you encounter issues:
Freedom Loader can be configured either through the settings panel in the UI or by directly editing the `config.json` file located in the application data directory. Freedom Loader can be configured either through the settings panel in the UI or by directly editing the `config.json` file located in the application data directory.
### Configuration File Location ### Configuration File Location
#### Windows
``` ```
C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json %APPDATA%\Freedom Loader\config.json
```
#### Linux
```
~/.config/Freedom Loader/config.json
``` ```
### Available Options ### Available Options
@@ -166,18 +235,21 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"createPlaylistFolders": true,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,
"outputTitleCheck": true, "outputTitleCheck": true,
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true "notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark"
} }
``` ```
### Configuration Options ### Configuration Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |------------------------------|---------|----------|------------------------------------------------------------|
| `autoUpdate` | boolean | `true` | Enable automatic application updates | | `autoUpdate` | boolean | `true` | Enable automatic application updates |
| `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration | | `discordRPC` | boolean | `true` | Enable Discord Rich Presence integration |
| `customTopBar` | boolean | `true` | Use custom application top bar | | `customTopBar` | boolean | `true` | Use custom application top bar |
@@ -186,11 +258,14 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
| `addMetadata` | boolean | `true` | Add metadata tags to downloaded files | | `addMetadata` | boolean | `true` | Add metadata tags to downloaded files |
| `verboseLogs` | boolean | `false` | Enable detailed logging for debugging | | `verboseLogs` | boolean | `false` | Enable detailed logging for debugging |
| `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists | | `autoDownloadPlaylist` | boolean | `true` | Automatically download entire playlists |
| `createPlaylistFolders` | boolean | `true` | Automatically create a folder for a playlist |
| `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) | | `customCodec` | string | `"h264"` | Video codec for encoding (supported: h264, h265, vp9, av1) |
| `logSystem` | boolean | `true` | Enable application logging | | `logSystem` | boolean | `true` | Enable application logging |
| `outputTitleCheck` | boolean | `true` | Verify output file titles | | `outputTitleCheck` | boolean | `true` | Verify output file titles |
| `downloadSystem` | boolean | `true` | Enable download system | | `downloadSystem` | boolean | `true` | Enable download system |
| `notifySystem` | boolean | `true` | Enable system notifications | | `notifySystem` | boolean | `true` | Enable system notifications |
| `enableHardwareAcceleration` | boolean | `true` | Enable Hardware Acceleration |
| `theme` | string | `dark` | Current Theme Name |
### Supported Codecs ### Supported Codecs
@@ -209,19 +284,88 @@ C:\Users\[USERNAME]\AppData\Roaming\FreedomLoader\config.json
``` ```
Freedom-Loader/ Freedom-Loader/
├── app/ # Electron main process modules
│ ├── autoUpdater.js
│ ├── dependencyCheck.js
│ ├── discordRPC.js
│ ├── ipcHandlers.js
│ ├── pathValidator.js
│ ├── splashManager.js
│ ├── themeManager.js
│ ├── windowManager.js
│ └── ytDlpUpdater.js
├── build/ # Build resources and assets ├── build/ # Build resources and assets
├── config/ # Configuration files ├── config/ # Configuration files
├── public/ # Frontend assets (HTML, CSS, JavaScript) │ ├── config.default.json
├── ressources/ # Internal resources (icons, binaries) │ └── config.dev.json
├── server/ # Express server code ├── public/ # Frontend assets and UI
│ ├── routes/ # API route handlers │ ├── index.html
│ ├── services/ # Business logic │ ├── splash.html
── utils/ # Server utilities ── assets/
├── .github/ # GitHub configuration and workflows │ │ ├── icon/ # Application icons
├── main.js # Electron main process └── logo/ # Logo assets
├── preload.js # Electron preload script │ ├── script/ # Frontend JavaScript modules
├── config.js # Global application configuration ├── appVersion.js
└── package.json # Dependencies and npm scripts ├── clipboardPaste.js
│ │ ├── custompath.js
│ │ ├── customthemes.js
│ │ ├── downloadstatus.js
│ │ ├── fetchinfo.js
│ │ ├── progressBar.js
│ │ ├── settingsPanel.js
│ │ ├── toast.js
│ │ └── topbar.js
│ └── styles/ # CSS stylesheets
│ ├── styles.css
│ ├── variables.css
│ ├── components/ # Component-specific styles
│ │ ├── checkbox.css
│ │ ├── editpathbutton.css
│ │ ├── loader.css
│ │ ├── progressBar.css
│ │ └── toast.css
│ └── layout/ # Layout styles
│ ├── container.css
│ ├── form.css
│ ├── header.css
│ ├── settingsPanel.css
│ ├── topbar.css
│ └── videoinfo.css
├── resources/ # Internal resources (binaries)
├── server/ # Express backend server
│ ├── logger.js
│ ├── server.js
│ ├── controller/ # Request handlers
│ │ ├── download.controller.js
│ │ └── info.controller.js
│ ├── helpers/ # Utility functions
│ │ ├── buildArgs.helpers.js
│ │ ├── getBrowser.helpers.js
│ │ ├── notify.helpers.js
│ │ ├── parseInfo.helpers.js
│ │ ├── path.helpers.js
│ │ ├── rateLimit.helpers.js
│ │ └── validation.helpers.js
│ ├── routes/ # API route definitions
│ │ ├── download.route.js
│ │ └── info.route.js
│ └── services/ # Business logic layer
│ ├── download.services.js
│ └── info.services.js
├── theme/ # Theme system
│ ├── template.theme.json # Theme template
│ ├── Dark/ # Default dark theme
│ │ └── dark.theme.json
│ └── Light/ # Default light theme
│ └── light.theme.json
├── main.js # Electron main process entry point
├── preload.js # Electron preload script (IPC bridge)
├── config.js # Global configuration loader
├── package.json # Project metadata and dependencies
├── CODE_OF_CONDUCT.md # Community guidelines
├── CONTRIBUTING.md # Contribution guidelines
├── LICENSE # GPLv3 license
└── README.md # This file
``` ```
### Architecture Overview ### Architecture Overview
@@ -230,10 +374,25 @@ Freedom Loader uses a client-server architecture within a single Electron applic
- **Frontend**: HTML/CSS/JavaScript served via Electron's renderer process - **Frontend**: HTML/CSS/JavaScript served via Electron's renderer process
- **Backend**: Express.js server running locally for download management - **Backend**: Express.js server running locally for download management
- **App modules**: Electron-specific logic (window, IPC, updates, RPC) isolated in `app/`
- **IPC Bridge**: Secure communication via Electron's preload script - **IPC Bridge**: Secure communication via Electron's preload script
- **Logging**: Winston-based structured logging with file rotation - **Logging**: Winston-based structured logging with file rotation
- **Updates**: Automatic checking and installation via electron-updater - **Updates**: Automatic checking and installation via electron-updater
## Theme Workshop
Freedom Loader includes a web-based theme creator available at [Freedom Loader Workshop](https://masteracnolo.github.io/Freedom-Loader-Workshop/).
- Create custom themes with a live preview of the actual UI
- Browse and download community themes
- Export themes as `.zip` files ready to drop into the `theme/` folder
- Supports background images, custom colors for every UI element
### Installing a custom theme
1. Download a `.zip` theme file
2. Drop it in the `theme/` folder of your Freedom Loader installation
3. Restart the app — your theme appears in the settings panel
## Technology Stack ## Technology Stack
### Core Technologies ### Core Technologies
@@ -283,7 +442,7 @@ npm run build
This project relies on several external binaries. This project relies on several external binaries.
Some of them are **not included** in the public repository and must be added manually. Some of them are **not included** in the public repository and must be added manually.
You must download the required binaries and place them in the `ressources` folder, using the exact filenames listed below. You must download the required binaries and place them in the `resources` folder, using the exact filenames listed below.
#### Required binaries #### Required binaries
@@ -304,7 +463,7 @@ You must download the required binaries and place them in the `ressources` folde
Final folder structure: Final folder structure:
``` ```
ressources/ resources/
├── deno.exe ├── deno.exe
├── ffmpeg.exe ├── ffmpeg.exe
├── ffprobe.exe ├── ffprobe.exe
@@ -320,43 +479,21 @@ ressources/
- Write clear commit messages - Write clear commit messages
- Test thoroughly before submitting changes - Test thoroughly before submitting changes
- Update documentation when adding features - Update documentation when adding features
- Maintain compatibility with Windows 10+ - Maintain compatibility with Windows 10+ and Linux
## Roadmap ## Roadmap
### Completed See the most recent Roadmap [Here](https://masteracnolo.github.io/Freedom-Loader-Site/Roadmap).
- [x] ~~Website and documentation~~
- [x] ~~Automatic YT-DLP updates~~
- [x] ~~Automatic application updates~~
- [x] ~~Enhanced download status display~~
- [x] ~~Video chapter extraction~~
- [x] ~~Custom output path selection~~
- [x] ~~Custom codec selection~~
- [x] ~~Settings Panel with toggle switch for features.~~
### In Progress
- [ ] Additional format support (WebM, OGG, etc.)
- [ ] Refactoring and cleaning the code base.
- [ ] Chrome/Edge cookie support
- [ ] Improved multi-site support
### Planned
- [ ] Subtitle download and embedding
- [ ] Multiple language support
- [ ] Partial video downloads
- [ ] Custom file naming patterns
- [ ] Parallel downloads
- [ ] Automatic sponsor detection (SponsorBlock integration)
- [ ] Download history
- [ ] Linux version
## Contributing ## Contributing
Contributions are welcome and appreciated. This project benefits from community involvement. Contributions are welcome and appreciated. This project benefits from community involvement.
## Submitting a community theme
Want your theme featured in the Workshop? Please **do not open an issue on this repository** for theme submissions.
Head over to the [Freedom Loader Workshop repository](https://github.com/MasterAcnolo/Freedom-Loader-Workshop) and open an issue there with your theme files, preview screenshots, and description.
### Bug Reports ### Bug Reports
Use the GitHub Issues system and include: Use the GitHub Issues system and include:
@@ -364,7 +501,7 @@ Use the GitHub Issues system and include:
- Clear description of the issue - Clear description of the issue
- Steps to reproduce - Steps to reproduce
- Expected vs actual behavior - Expected vs actual behavior
- Relevant logs from `AppData\Local\FreedomLoader\logs\` - Relevant logs from `AppData\Local\FreedomLoader\logs\` or `~/.local/share/FreedomLoader/logs/`
- Screenshots if applicable - Screenshots if applicable
### Feature Requests ### Feature Requests
@@ -408,6 +545,7 @@ You are free to use, modify, and redistribute this software under the terms of t
- [Electron](https://www.electronjs.org/) for the desktop application framework - [Electron](https://www.electronjs.org/) for the desktop application framework
- The open-source community for continuous support and contributions - The open-source community for continuous support and contributions
- [@SpicyFire21](https://github.com/SpicyFire21) to be the spiciest one - [@SpicyFire21](https://github.com/SpicyFire21) to be the spiciest one
- Zakaria for the website icon
- Jacques Chirac to love Apples - Jacques Chirac to love Apples
- All users who test, report issues, and help improve the application - All users who test, report issues, and help improve the application
@@ -417,6 +555,6 @@ You are free to use, modify, and redistribute this software under the terms of t
**Freedom Loader** - Put freedom in your downloads **Freedom Loader** - Put freedom in your downloads
[Website](https://masteracnolo.github.io/FreedomLoader/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://masteracnolo.github.io/FreedomLoader/pages/wiki.html) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues) [Website](https://masteracnolo.github.io/Freedom-Loader-Site/) • [Download](https://github.com/MasterAcnolo/Freedom-Loader/releases) • [Documentation](https://masteracnolo.github.io/Freedom-Loader-Site/wiki) • [Report Bug](https://github.com/MasterAcnolo/Freedom-Loader/issues)
</div> </div>

View File

@@ -5,7 +5,7 @@
We actively maintain and provide security updates for the following versions of Freedom Loader: We actively maintain and provide security updates for the following versions of Freedom Loader:
| Version | Supported | | Version | Supported |
| ------- | ------------------ | |---------|--------------------|
| 1.4.x | :white_check_mark: | | 1.4.x | :white_check_mark: |
| 1.3.x | :white_check_mark: | | 1.3.x | :white_check_mark: |
| < 1.3.0 | :x: | | < 1.3.0 | :x: |

157
app/autoUpdater.js Normal file
View File

@@ -0,0 +1,157 @@
/**
* Electron auto-update system using electron-updater.
*
* Handles:
* - update detection
* - user confirmation dialogs
* - download progress streaming to renderer
* - application restart and installation
*/
const { autoUpdater } = require("electron-updater");
const { dialog } = require("electron");
const { logger } = require("../server/logger");
/**
* Configures autoUpdater behavior:
* - disables automatic download
* - disables automatic installation on quit
* (manual user-controlled update flow)
*/
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
/**
* Initializes application auto-update lifecycle.
*
* Registers update event listeners and binds them to:
* - UI dialogs (Electron dialog)
* - renderer communication (webContents)
* - download/install flow control
*
* @param {BrowserWindow} mainWindow - main Electron window instance
*/
function initAutoUpdater(mainWindow) {
/**
* Triggered when a new version is detected.
* Prompts user to install or defer update.
*/
autoUpdater.on("update-available", async (info) => {
logger.info(`Update available: ${info.version}`);
const { response } = await dialog.showMessageBox(mainWindow, {
type: "info",
title: "Update Available",
message: `Version ${info.version} is available.`,
detail: "Would you like to download and install it now?",
buttons: ["Install Update", "Maybe Later"],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
await autoUpdater.downloadUpdate();
} else {
mainWindow?.webContents.executeJavaScript(
`window.showUpdateBadge && window.showUpdateBadge("${info.version}")`
);
}
});
/**
* Streams update download progress to renderer process.
* Used to update UI progress bar and status indicators.
*/
autoUpdater.on("download-progress", (progress) => {
logger.info(`Download progress: ${Math.round(progress.percent)}%`);
mainWindow?.webContents.send("update-progress", {
percent: Math.round(progress.percent),
speed: progress.bytesPerSecond,
});
});
/**
* Triggered when update has been fully downloaded.
* Prompts user to restart application and install update.
*/
autoUpdater.on("update-downloaded", async (info) => {
logger.info(`Update downloaded: ${info.version}`);
const { response } = await dialog.showMessageBox(mainWindow, {
type: "info",
title: "Update Ready",
message: `Version ${info.version} has been downloaded.`,
detail: "The application will restart to apply the update.",
buttons: ["Install Now", "Later"],
defaultId: 0,
cancelId: 1,
});
if (response === 0) autoUpdater.quitAndInstall();
});
/**
* Handles update system errors.
* Logs failure and displays an error dialog to user.
*/
autoUpdater.on("error", (err) => {
const msg = err?.message || "";
/**
* If no update is available, I put this because there is no Linux version before 1.6.0
* @type {boolean}
*/
const isNoUpdateAvailable = /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg);
if (isNoUpdateAvailable) {
logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg);
return;
}
logger.error("Auto update error:", msg);
});
checkForUpdates();
}
/**
* Manually triggers update check on startup.
* Separated from init for reusability and testability.
*/
async function checkForUpdates() {
if (process.env.SNAP) {
logger.info("Running as Snap, update managed by snapd, skipping electron-updater");
return;
}
if (!require("electron").app.isPackaged) return ;
try {
await autoUpdater.checkForUpdates();
} catch (err) {
logger.error("Update check failed:", err.message);
}
}
/**
* Manually triggers update download.
* Used when user accepts update prompt.
*/
async function downloadUpdate() {
try {
await autoUpdater.downloadUpdate();
} catch (err) {
logger.error("Download failed:", err.message);
}
}
/**
* Immediately quits application and installs downloaded update.
*/
function installUpdate() {
autoUpdater.quitAndInstall();
}
module.exports = { initAutoUpdater, downloadUpdate, installUpdate };

67
app/dependencyCheck.js Normal file
View File

@@ -0,0 +1,67 @@
const fs = require("fs");
const { app, dialog } = require("electron");
const { logger } = require("../server/logger");
/**
* Verifies that all required native binaries exist before the app starts.
*
* This includes:
* - yt-dlp (download engine)
* - ffmpeg (media processing)
* - ffprobe (media inspection)
* - deno (JS runtime used by yt-dlp hooks)
*
* If any dependency is missing:
* - logs the error
* - shows a blocking Electron error dialog
* - exits the application
*
* @returns {boolean} true if all dependencies are present, false otherwise
*/
function checkNativeDependencies() {
/**
* Native dependency paths are resolved at runtime from the server helpers.
* This ensures correct resolution in both dev and packaged builds.
*/
const { binaryPaths } = require("../server/helpers/path.helpers");
const isWindows = process.platform === "win32";
const addExe = isWindows ? ".exe" : "";
/**
* List of required external executables used by the application runtime.
* Each entry defines a binary name and its resolved absolute path.
*/
const deps = [
{ name: `yt-dlp${addExe}`, path: binaryPaths.ytDlp },
{ name: `ffmpeg${addExe}`, path: binaryPaths.ffmpeg },
{ name: `ffprobe${addExe}`, path: binaryPaths.ffprobe },
{ name: `deno${addExe}`, path: binaryPaths.deno },
];
const missing = deps.filter(d => !fs.existsSync(d.path));
if (missing.length === 0) return true;
/**
* Human-readable list of missing binaries for logging and user dialog display.
*/
const list = missing.map(d => d.name).join(", ");
logger.error(`Missing dependencies: ${list}`);
/**
* Defers error dialog until Electron app is fully initialized.
* Required to safely display modal dialogs before quit.
*/
app.whenReady().then(() => {
dialog.showErrorBox(
"Missing dependencies",
`The following files are missing in the 'resources' folder:\n${list}\n\nThe application will now exit. Try to reinstall.`
);
app.quit();
});
return false;
}
module.exports = { checkNativeDependencies };

87
app/discordRPC.js Normal file
View File

@@ -0,0 +1,87 @@
const config = require('../config');
const RPC = require("discord-rpc");
const { logger } = require("../server/logger");
/**
* Discord Application Client ID used to authenticate the RPC connection.
* Comes from the application configuration.
*/
const clientId = `${config.DiscordRPCID}`;
/**
* Discord RPC client instance using IPC transport.
* Maintains a persistent connection with Discord desktop client.
*/
const rpc = new RPC.Client({ transport: "ipc" });
/**
* Optional interval reference used for future RPC refresh logic.
* Currently reserved for potential periodic activity updates.
*/
let intervalId;
/**
* Initializes Discord Rich Presence (RPC) connection.
*
* Responsibilities:
* - Connect to Discord via IPC transport
* - Set application presence (title, state, timestamps, assets)
* - Handle connection errors gracefully via logger
*
* Triggered once during application startup.
*/
function startRPC() {
/**
* Rich Presence payload describing current application state
* shown in Discord user profile.
*/
rpc.on("ready", () => {
const presence = {
largeImageKey: "icon",
smallImageKey: "acnolo_pfp",
smallImageText: "By MasterAcnolo",
startTimestamp: new Date(),
details: `Open Source Download Tools - ${config.version}`,
state: "masteracnolo.github.io/FreedomLoader",
};
rpc.clearActivity()
rpc.setActivity(presence);
});
rpc.login({ clientId }).catch(err => {
logger.error("Unable to connect to the RPC:", err);
});
}
/**
* Gracefully stops Discord Rich Presence connection.
*
* Responsibilities:
* - Clear any running intervals
* - Remove current activity from Discord
* - Destroy RPC transport connection
* - Handle cleanup errors safely
*/
async function stopRPC(){
try {
if (intervalId) clearInterval(intervalId);
/**
* Ensures RPC connection exists before attempting cleanup.
*/
if (rpc && rpc.transport) {
await rpc.clearActivity()
await rpc.destroy();
} else{
logger.error("Not Able to close RPC")
}
} catch (err) {
logger.error("Error while closing the RPC:", err);
}
}
module.exports = { startRPC, stopRPC};

233
app/ipcHandlers.js Normal file
View File

@@ -0,0 +1,233 @@
/**
* Electron IPC main process registry.
*
* Centralizes all IPC channels exposed to renderer process:
* - Application info (version, features)
* - Window controls (topbar actions)
* - File system interactions (theme, config, download paths)
* - UI state synchronization (progress bar)
*
* Acts as the bridge between renderer and privileged Node/Electron APIs.
*/
const { ipcMain, dialog, shell } = require("electron");
const fs = require("fs");
const { logger, logDir } = require("../server/logger");
const { configFeatures, featuresPath } = require("../config");
const { getThemes, reloadThemes } = require("./themeManager");
const config = require("../config");
const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator");
const { userThemesPath } = require("../server/helpers/path.helpers");
/**
* Security whitelist for feature flags that can be modified at runtime.
*
* Prevents unauthorized or unexpected configuration keys from being persisted.
* Acts as a basic validation layer for IPC "set-feature".
*/
const FEATURE_WHITELIST = new Set([
"autoUpdate",
"discordRPC",
"customTopBar",
"autoCheckInfo",
"addThumbnail",
"addMetadata",
"verboseLogs",
"autoDownloadPlaylist",
"customCodec",
"theme",
"createPlaylistFolders",
"notifySystem",
"enableHardwareAcceleration"
]);
/**
* Absolute path to the configuration file storing feature flags.
*/
const configFolderPath = featuresPath;
/**
* Directory containing user-installed themes.
*/
const themeFolderPath = userThemesPath;
/**
* Registers all IPC handlers and event listeners for Electron main process.
*
* This function wires renderer → main communication channels:
* - synchronous queries (handle)
* - fire-and-forget events (on)
*
* @param {Function} getMainWindow - Function returning current BrowserWindow instance
*/
function registerIpcHandlers(getMainWindow) {
/**
* Returns application version from config.
*/
ipcMain.handle("version", () => config.version);
/**
* Returns runtime feature configuration object.
*/
ipcMain.handle("features", () => configFeatures);
/**
* Opens native folder selection dialog and validates selected path.
*
* Ensures:
* - user cancellation is handled safely
* - selected path is validated against security rules
* - unsafe directories are rejected
*/
ipcMain.handle("select-download-folder", async () => {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (result.canceled) {
logger.info("Folder selection cancelled by user");
return null;
}
try {
const validated = validateDownloadPath(result.filePaths[0]);
logger.info(`Folder selected and validated: ${validated}`);
return validated;
} catch (err) {
logger.warn(`Unsafe or invalid folder rejected: ${err.message}`);
throw err;
}
});
ipcMain.handle("validate-download-path", (_, userPath) => validateDownloadPath(userPath));
ipcMain.handle("get-default-download-path", () => getDefaultDownloadPath());
/**
* Updates Windows/macOS taskbar progress indicator.
*
* @param {number} percent - Progress value (0100)
*/
ipcMain.on("set-progress", (_, percent) => {
getMainWindow()?.setProgressBar(percent / 100);
});
/**
* Window minimize request from renderer.
*/
ipcMain.on("window-minimize", () => getMainWindow()?.minimize());
/**
* Toggles maximize/unmaximize state of main window.
*/
ipcMain.on("window-maximize", () => {
const win = getMainWindow();
if (!win) return;
win.isMaximized() ? win.unmaximize() : win.maximize();
});
/**
* Closes the main application window.
*/
ipcMain.on("window-close", () => getMainWindow()?.close());
/**
* Opens Chromium DevTools in detached mode.
*/
ipcMain.on("open-devtools", () =>
getMainWindow()?.webContents.openDevTools({ mode: "detach" })
);
/**
* Opens application logs directory in system file explorer.
*/
ipcMain.on("open-logs", () => logDir && shell.openPath(logDir));
/**
* Opens external website in default browser.
*/
ipcMain.on("open-website", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/")
);
/**
* Opens official wiki page in external browser.
*/
ipcMain.on("open-wiki", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Site/wiki")
);
/**
* Opens workshop page in external browser.
*/
ipcMain.on("open-workshop", () =>
shell.openExternal("https://masteracnolo.github.io/Freedom-Loader-Workshop")
);
/**
* Opens configuration folder in system file explorer.
*/
ipcMain.on("open-config", () => shell.openPath(configFolderPath));
/**
* Retrieves available themes from filesystem.
*/
ipcMain.handle("get-themes", () => getThemes());
/**
* Opens theme directory in file explorer.
*/
ipcMain.on("open-theme", () => shell.openPath(themeFolderPath));
/**
* Reloads themes from disk dynamically.
*/
ipcMain.handle("reload-themes", async () => {
return await reloadThemes();
});
/**
* Updates a runtime feature flag and persists it to disk.
*
* Flow:
* - Validates key against whitelist
* - Prevents unnecessary writes if value is unchanged
* - Updates in-memory config object
* - Persists to configuration file
*
* Security note:
* Only whitelisted keys can be modified via IPC to prevent config injection.
*
* @param {Electron.IpcMainEvent} event
* @param {{key: string, value: any}} payload
* @returns {boolean} success state
*/
ipcMain.handle("set-feature", (event, { key, value }) => {
try {
if (!FEATURE_WHITELIST.has(key)) {
logger.warn(`Rejected feature (not whitelisted): ${key}`);
return false;
}
if (configFeatures[key] === value) {
return true;
}
configFeatures[key] = value;
fs.writeFileSync(
configFolderPath,
JSON.stringify(configFeatures, null, 2),
"utf-8"
);
logger.info(`Feature updated: ${key} = ${value}`);
return true;
} catch (err) {
logger.error(`set-feature failed (${key}): ${err.message}`);
return false;
}
});
}
module.exports = { registerIpcHandlers };

90
app/pathValidator.js Normal file
View File

@@ -0,0 +1,90 @@
const fs = require("fs");
const path = require("path");
const { logger } = require("../server/logger");
/**
* Cached resolved default download path.
* Initialized lazily on first access to avoid unnecessary imports.
*/
let _defaultPath = null;
/**
* Returns the application's default download directory.
*
* This value is lazily loaded and cached after first call.
* The path originates from server configuration helpers.
*
* @returns {string} Absolute default download path
*/
function getDefaultDownloadPath() {
if (!_defaultPath) {
/**
* Lazy-loaded dependency providing the configured default download folder.
* Required only when function is executed to reduce startup cost.
*/
const { defaultDownloadFolder } = require("../server/helpers/path.helpers");
_defaultPath = defaultDownloadFolder;
}
return _defaultPath;
}
/**
* Validates and sanitizes a user-provided download path.
*
* Steps performed:
* - Resolves absolute real filesystem path
* - Normalizes symbolic links via realpathSync
* - Checks path safety rules (blocks system-critical directories)
* - Falls back to default path if input is empty
*
* @param {string} userPath - User-provided directory path
* @returns {string} Sanitized absolute path
* @throws Error If path is invalid, unsafe, or inaccessible
*/
function validateDownloadPath(userPath) {
/**
* Lazy-loaded helper used to enforce safe directory constraints.
* Prevents writes to protected system folders.
*/
const { isSafePath } = require("../server/helpers/validation.helpers");
if (!userPath) return getDefaultDownloadPath();
try {
/**
* Resolves filesystem symlinks to ensure canonical absolute path.
* Required to prevent path traversal or alias bypass.
*/
const resolved = fs.realpathSync(path.resolve(userPath));
if (!fs.existsSync(resolved)) {
fs.mkdirSync(resolved, { recursive: true });
logger.info(`Download folder created: ${resolved}`);
}
const real = fs.realpathSync(resolved);
if (!isSafePath(resolved)) {
throw new Error("Path not allowed: system folders are blocked!");
}
return real;
}
catch (err) {
/**
* Handles invalid filesystem paths, permissions issues, or resolution failures.
* Logs diagnostic information before propagating a sanitized error.
*/
logger.error(`Invalid download path: ${userPath}${err.message}`);
throw new Error(`Invalid or inaccessible path: ${err.message}`);
}
}
module.exports = { validateDownloadPath, getDefaultDownloadPath };

107
app/splashManager.js Normal file
View File

@@ -0,0 +1,107 @@
const { BrowserWindow, app } = require("electron");
const path = require("path");
/**
* Reference to the splash screen BrowserWindow instance.
* Used during application startup before main window is ready.
*/
let splashWindow = null;
/**
* Creates and displays the splash screen window.
*
* Responsibilities:
* - Initializes a frameless, always-on-top splash window
* - Loads splash HTML UI
* - Dynamically injects banner image depending on dev/production mode
* - Acts as startup visual feedback before main window loads
*/
function createSplashWindow() {
/**
* Configuration for splash screen BrowserWindow.
* Optimized for minimal UI and fast startup display.
*/
const splashOptions = {
width: 400,
height: 300,
frame: false,
transparent: true,
alwaysOnTop: true,
resizable: false,
skipTaskbar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
};
/**
* Instantiates the splash screen window.
*/
splashWindow = new BrowserWindow(splashOptions);
/**
* Absolute path to splash HTML file used as initial UI.
* Loaded locally from application bundle or project folder.
*/
const splashPath = path.join(__dirname, "../public/splash.html");
splashWindow.loadFile(splashPath);
/**
* Executes once splash HTML is fully loaded.
* Used here to inject dynamic assets (banner image path).
*/
splashWindow.webContents.on('did-finish-load', () => {
/**
* Resolves splash banner image path depending on runtime mode:
* - Production: bundled resources directory
* - Development: local build directory
*/
let bannerPath;
// Check if app is packaged
if (app.isPackaged) {
bannerPath = path.join(process.resourcesPath, 'banner.png');
} else {
// In dev, use build folder
bannerPath = path.join(__dirname, '../build/banner.png');
}
/**
* Injects runtime image source into splash DOM.
* Uses file:// protocol to load local image safely in Electron context.
*/
splashWindow.webContents.executeJavaScript(`
document.querySelector('img[alt="Freedom Loader"]').src = 'file:///${bannerPath.replace(/\\/g, '/')}';
`);
});
}
/**
* Closes and cleans up the splash screen window.
*
* Should be called once main window is ready to display.
*/
function closeSplashWindow() {
if (splashWindow) {
splashWindow.close();
splashWindow = null;
}
}
/**
* Updates splash screen progress indicator from main process.
*
* Sends a step value to renderer via injected JS function.
*
* @param {number} step - Current initialization step or progress value
*/
function setSplashProgress(step) {
if (splashWindow) {
splashWindow.webContents.executeJavaScript(`window.setProgress(${step})`);
}
}
module.exports = { createSplashWindow, closeSplashWindow, setSplashProgress };

337
app/themeManager.js Normal file
View File

@@ -0,0 +1,337 @@
/**
* Theme loading and parsing system.
*
* Supports:
* - Folder-based themes
* - ZIP-based themes
* - Theme validation against required schema
* - Image embedding as base64 data URIs
* - Cached theme registry with sorted priority order
*
* Acts as the core theme ingestion pipeline for the application UI.
*/
const fs = require("fs");
const path = require("path");
const { logger } = require("../server/logger");
const JSZip = require("jszip");
/**
* Maximum allowed size (in bytes) for theme images.
* Prevents memory abuse from large embedded assets.
*/
const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
/**
* List of supported image file extensions for theme assets.
*/
const ALLOWED_IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"];
/**
* Allowed base filenames for theme background/cover images.
* Restricts theme asset resolution to known conventions.
*/
const ALLOWED_IMAGE_NAMES = ["cover", "background"];
/**
* Priority order used to sort themes in UI.
* Themes not listed are placed at the end.
*/
const THEME_ORDER = ["dark", "light"];
/**
* Required JSON schema paths for a valid theme definition.
* Each entry represents a nested property path that must exist.
*/
const REQUIRED_KEYS = [
["meta", "name"],
["meta", "author"],
["meta", "version"],
["meta", "formatVersion"],
["style", "colors", "background"],
["style", "colors", "text", "default"],
["style", "form", "button", "background"],
["style", "progressBar", "fill"],
];
/**
* Safely retrieves a deeply nested value from an object using a key path array.
*
* @param {object} obj - Target object
* @param {string[]} keys - Property path segments
* @returns {*} value or undefined if path is invalid
*/
function getNestedValue(obj, keys) {
return keys.reduce((acc, key) => acc?.[key], obj);
}
/**
* Validates theme JSON structure against required schema.
*
* Ensures all mandatory configuration keys exist before theme is accepted.
*
* @param {object} json - Parsed theme JSON
* @returns {{valid: boolean, reason?: string}}
*/
function validateThemeJson(json) {
for (const keyPath of REQUIRED_KEYS) {
if (getNestedValue(json, keyPath) === undefined) {
return { valid: false, reason: `Missing key: ${keyPath.join(".")}` };
}
}
return { valid: true };
}
/**
* Normalizes raw theme JSON into application-ready theme object.
*
* @param {string} themeId
* @param {object} themeJson
* @param {string|null} imageData - Base64 encoded theme image
* @returns {object} normalized theme object
*/
function buildThemeObject(themeId, themeJson, imageData) {
return {
id: themeId.toLowerCase(),
name: themeJson.meta.name || themeId,
author: themeJson.meta.author || "Unknown",
version: themeJson.meta.version,
subtitle: themeJson.meta.subtitle || "",
style: themeJson.style,
image: imageData,
};
}
/**
* Extracts and encodes an image from theme archive into base64 data URI.
*
* Applies safety checks:
* - Rejects images exceeding MAX_IMAGE_SIZE
* - Converts file extension into valid MIME type
*
* @param {Buffer} buffer
* @param {string} filename
* @returns {string|null} data URI or null if invalid
*/
function extractImage(buffer, filename) {
if (buffer.length > MAX_IMAGE_SIZE) {
logger.warn(`Image too large, ignoring`);
return null;
}
const ext = path.extname(filename).toLowerCase().replace(".", "");
const mime = ext === "jpg" || ext === "jpeg" ? "jpeg" : ext;
return `data:image/${mime};base64,${buffer.toString("base64")}`;
}
/**
* Loads a theme from a ZIP archive.
*
* Steps:
* - Extract ZIP content
* - Locate .theme.json file
* - Validate JSON structure
* - Extract optional image asset
* - Build normalized theme object
*
* @param {string} zipPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromZip(zipPath, themeId) {
try {
const zipBuffer = fs.readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBuffer);
const jsonFile = Object.keys(zip.files).find(f => f.endsWith(".theme.json"));
if (!jsonFile) { logger.warn(`Theme ${themeId}: no .theme.json, skipping`); return null; }
let themeJson;
try { themeJson = JSON.parse(await zip.files[jsonFile].async("string")); }
catch { logger.warn(`Theme ${themeId}: invalid JSON, skipping`); return null; }
const validation = validateThemeJson(themeJson);
if (!validation.valid) { logger.warn(`Theme ${themeId}: ${validation.reason}, skipping`); return null; }
let imageData = null;
const imageFile = Object.keys(zip.files).find(f => {
const ext = path.extname(f).toLowerCase();
const name = path.basename(f, ext).toLowerCase();
return ALLOWED_IMAGE_NAMES.includes(name) && ALLOWED_IMAGE_EXTENSIONS.includes(ext);
});
if (imageFile) {
const buf = await zip.files[imageFile].async("nodebuffer");
imageData = extractImage(buf, imageFile);
}
logger.info(`Theme loaded (zip): ${themeId}`);
return buildThemeObject(themeId, themeJson, imageData);
} catch (err) {
logger.warn(`Theme ${themeId}: failed to load zip — ${err.message}`);
return null;
}
}
/**
* Loads a theme from a directory structure.
*
* Equivalent to ZIP loader but works on filesystem directly.
*
* @param {string} folderPath
* @param {string} themeId
* @returns {Promise<object|null>}
*/
async function loadThemeFromFolder(folderPath, themeId) {
try {
const jsonFile = fs.readdirSync(folderPath).find(f => f.endsWith(".theme.json"));
if (!jsonFile) { logger.warn(`Theme ${themeId}: no .theme.json, skipping`); return null; }
let themeJson;
try { themeJson = JSON.parse(fs.readFileSync(path.join(folderPath, jsonFile), "utf-8")); }
catch { logger.warn(`Theme ${themeId}: invalid JSON, skipping`); return null; }
const validation = validateThemeJson(themeJson);
if (!validation.valid) { logger.warn(`Theme ${themeId}: ${validation.reason}, skipping`); return null; }
let imageData = null;
const imageFile = fs.readdirSync(folderPath).find(f => {
const ext = path.extname(f).toLowerCase();
const name = path.basename(f, ext).toLowerCase();
return ALLOWED_IMAGE_NAMES.includes(name) && ALLOWED_IMAGE_EXTENSIONS.includes(ext);
});
if (imageFile) {
const buf = fs.readFileSync(path.join(folderPath, imageFile));
imageData = extractImage(buf, imageFile);
}
logger.info(`Theme loaded (folder): ${themeId}`);
return buildThemeObject(themeId, themeJson, imageData);
} catch (err) {
logger.warn(`Theme ${themeId}: failed to load folder — ${err.message}`);
return null;
}
}
/**
* In-memory cache of loaded themes.
* Avoids re-parsing filesystem on every request.
*/
let cachedThemes = null;
/**
* Active theme directory path used as source of truth.
*/
let themeFolderPath = null;
/**
* Sets the active theme directory path.
*
* @param {string} folderPath
*/
function setThemeFolderPath(folderPath) {
themeFolderPath = folderPath;
}
/**
* Loads all themes from the configured theme directory.
*
* Supports:
* - ZIP themes (.zip)
* - Folder themes (directory-based)
*
* Iterates through filesystem entries and delegates parsing
* to the appropriate loader (ZIP or folder).
*
* @returns {Promise<object[]>} List of valid theme objects
*/
async function loadThemesFromFolder() {
const themes = [];
if (!fs.existsSync(themeFolderPath)) {
logger.warn(`Theme folder not found: ${themeFolderPath}`);
return themes;
}
const files = fs.readdirSync(themeFolderPath);
for (const file of files) {
const filePath = path.join(themeFolderPath, file);
const themeId = path.basename(file, path.extname(file));
if (file.endsWith(".zip")) {
const theme = await loadThemeFromZip(filePath, themeId);
if (theme) themes.push(theme);
continue;
}
if (fs.statSync(filePath).isDirectory()) {
const theme = await loadThemeFromFolder(filePath, themeId);
if (theme) themes.push(theme);
}
}
return themes;
}
/**
* Returns sorting priority for a theme ID.
*
* Lower index = higher priority.
* Themes not in THEME_ORDER are placed at the end.
*
* @param {string} id
* @returns {number}
*/
function getThemeOrder(id) {
const index = THEME_ORDER.indexOf(id);
return index === -1 ? Infinity : index;
}
/**
* Initializes the theme system.
*
* Steps:
* - Sets theme folder path
* - Loads all themes from disk
* - Sorts themes by priority order
* - Caches result in memory
*
* @param {string} folderPath
*/
async function initThemes(folderPath) {
setThemeFolderPath(folderPath);
const themes = await loadThemesFromFolder();
logger.info(`Themes before sort: ${themes.map(t => t.id).join(", ")}`);
cachedThemes = themes.sort((a, b) => getThemeOrder(a.id) - getThemeOrder(b.id));
logger.info(`Themes after sort: ${cachedThemes.map(t => t.id).join(", ")}`);
}
/**
* Returns cached themes.
*
* If themes are not loaded yet, returns empty array.
*
* @returns {object[]}
*/
function getThemes() {
return cachedThemes ?? [];
}
/**
* Reloads themes from disk and refreshes cache.
*
* Used when themes are modified at runtime without restart.
*
* @returns {Promise<object[]>}
*/
async function reloadThemes() {
const themes = await loadThemesFromFolder();
cachedThemes = themes.sort((a, b) => getThemeOrder(a.id) - getThemeOrder(b.id));
logger.info(`Themes reloaded: ${cachedThemes.length} theme(s)`);
return cachedThemes;
}
module.exports = { initThemes, getThemes , reloadThemes };

108
app/windowManager.js Normal file
View File

@@ -0,0 +1,108 @@
/**
* Electron main window module dependencies.
* Handles BrowserWindow lifecycle and application UI shell.
*/
const { BrowserWindow, app } = require("electron");
const path = require("path");
const { logger } = require("../server/logger");
const { configFeatures } = require("../config");
const config = require("../config");
/**
* Singleton reference to the main Electron BrowserWindow instance.
* Ensures only one application window exists at runtime.
*/
let mainWindow = null;
/**
* Creates and initializes the main Electron application window.
*
* Responsibilities:
* - Ensures singleton window instance (prevents duplicates)
* - Configures window options (size, icon, preload, security settings)
* - Loads frontend application from local dev server
* - Handles lifecycle events (close, errors)
*
* @returns {Promise<BrowserWindow>} The created or existing window instance
*/
async function createMainWindow() {
/**
* Prevents multiple instances of the main window.
* Returns existing instance if already created.
*/
if (mainWindow) {
logger.warn("Window already exists, no new creation!");
return mainWindow;
}
/**
* Resolves application icon path depending on runtime mode:
* - Development: local build folder
* - Production: packaged Electron resources
*/
const iconPath = config.devMode
? path.join(__dirname, "../build/app-icon.ico")
: path.join(process.resourcesPath, "build/app-icon.ico");
/**
* Electron BrowserWindow configuration object.
* Defines UI behavior, security settings, and preload bridge.
*/
const windowOptions = {
title: `Freedom Loader ${config.version}`,
icon: iconPath,
width: 750,
height: 800,
minWidth: 750,
minHeight: 800,
frame: !configFeatures.customTopBar,
devTools: !app.isPackaged,
show: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "../preload.js"),
},
};
mainWindow = new BrowserWindow(windowOptions);
/**
* Loads the frontend application served by the local dev server.
* In production, this could be replaced with a file:// build.
*/
try {
await mainWindow.loadURL(`http://localhost:${config.applicationPort}`);
logger.info("Window loaded");
} catch (err) {
logger.error("Window loading error:", err);
throw err;
}
/**
* Cleans up window reference when the main window is closed.
* Prevents memory leaks and allows recreation if needed.
*/
mainWindow.on("closed", () => {
logger.info("Main window closed");
mainWindow = null;
});
return mainWindow;
}
/**
* Returns the current main window instance.
*
* Useful for IPC handlers and background services
* needing access to the renderer process.
*
* @returns {BrowserWindow | null}
*/
function getMainWindow() {
return mainWindow;
}
module.exports = { createMainWindow, getMainWindow };

35
app/ytDlpUpdater.js Normal file
View File

@@ -0,0 +1,35 @@
const { execFile } = require("child_process");
const { logger } = require("../server/logger");
/**
* Attempts to update the local yt-dlp binary using its built-in update command.
*
* This function:
* - Executes yt-dlp with the `-U` flag
* - Logs update output or warnings if update fails
* - Never throws: update failure is non-blocking by design
*
* @param {string} ytDlpPath - Absolute path to the yt-dlp executable
*/
function updateYtDlp(ytDlpPath) {
logger.info("yt-dlp update check starting...");
/**
* Executes yt-dlp CLI process in a non-interactive mode.
* Used here specifically for self-update via `-U` flag.
*/
execFile(ytDlpPath, ["-U"], (err, stdout) => {
/**
* Handles yt-dlp update result:
* - Logs warning if update fails (network, binary issues, permissions)
* - Logs stdout output when update succeeds
*/
if (err) logger.warn("yt-dlp update failed (continuing):", err.message);
else logger.info(`yt-dlp update: ${stdout.trim()}`);
});
}
module.exports = { updateYtDlp };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

BIN
build/app-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

107
config.js
View File

@@ -3,24 +3,107 @@ const { app } = require("electron");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const localMode = !app.isPackaged; /**
* Indicates whether the application is running in development mode.
* Determined by Electron's packaging state.
*/
const devMode = !app.isPackaged;
const featuresPath = localMode ? path.join(__dirname, "./config/config.json") : path.join(path.join(process.resourcesPath,"config/" ,"config.json")); /**
* Resolves the configuration file path depending on runtime environment.
*
* In development:
* - Uses local config/config.dev.json
* - Creates it from default if missing
*
* In production:
* - Uses app userData/config.json
* - Creates it from bundled default config if missing
*
* @returns {string} Absolute path to the active configuration file
*/
function resolveConfigPath() {
if (devMode) {
const devConfigPath = path.join(__dirname, "config", "config.dev.json");
let features = {}; if (!fs.existsSync(devConfigPath)) {
const defaultConfigPath = path.join(
function loadFeatures() { __dirname,
const raw = fs.readFileSync(featuresPath, "utf-8"); "config",
features = JSON.parse(raw); "config.default.json"
return features; );
fs.copyFileSync(defaultConfigPath, devConfigPath);
} }
return devConfigPath;
}
const userConfigPath = path.join(app.getPath("userData"), "config.json");
if (!fs.existsSync(userConfigPath)) {
const defaultConfigPath = path.join(
process.resourcesPath,
"config",
"config.default.json"
);
fs.copyFileSync(defaultConfigPath, userConfigPath);
}
return userConfigPath;
}
/**
* Path to the active configuration file used at runtime.
*/
const featuresPath = resolveConfigPath();
/**
* Loads and parses the feature configuration file.
*
* @returns {Object} Parsed feature flags configuration
* @throws {Error} If the configuration file is missing or invalid
*/
function loadFeatures() {
const raw = fs.readFileSync(featuresPath, "utf-8");
return JSON.parse(raw);
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart or reload is required.
*/
const configFeatures = loadFeatures(); const configFeatures = loadFeatures();
module.exports = { module.exports = {
version: packageJson.version, /**
* Application version from package.json
*/
version: devMode ? `dev-${packageJson.version}` : `v${packageJson.version}` ,
/**
* Backend Express / internal server port
*/
applicationPort: "8787", applicationPort: "8787",
localMode,
/**
* Indicates whether the app is running in development mode
*/
devMode,
/**
* Discord RPC application identifier
*/
DiscordRPCID: "1410934537051181146", DiscordRPCID: "1410934537051181146",
configFeatures
} /**
* Runtime feature flags loaded from configuration file
*/
configFeatures,
/**
* Path to the active configuration file
*/
featuresPath,
};

View File

@@ -7,9 +7,12 @@
"addMetadata": true, "addMetadata": true,
"verboseLogs": false, "verboseLogs": false,
"autoDownloadPlaylist": true, "autoDownloadPlaylist": true,
"createPlaylistFolders": true,
"customCodec": "h264", "customCodec": "h264",
"logSystem": true, "logSystem": true,
"outputTitleCheck": true, "outputTitleCheck": true,
"downloadSystem": true, "downloadSystem": true,
"notifySystem": true "notifySystem": true,
"enableHardwareAcceleration": true,
"theme": "dark"
} }

359
main.js
View File

@@ -1,270 +1,153 @@
const config = require("./config.js"); /**
const { app, BrowserWindow, ipcMain, dialog, Menu, shell } = require("electron"); * Handle crash application and avoid ghost instance
const path = require("path"); */
const os = require("os"); const { app } = require("electron");
const fs = require("fs");
const { logger, logSessionStart, logSessionEnd, logDir } = require("./server/logger"); process.on("uncaughtException", (err) => {
const { AutoUpdater } = require("./server/update.js"); console.error("Uncaught exception:", err);
const { configFeatures } = require("./config.js");
const { startRPC, stopRPC} = require("./server/discordRPC");
let mainWindow;
const logsFolderPath = logDir;
const basePath = config.localMode
? path.join(__dirname )
: path.join(path.dirname(process.execPath), "resources");
const configFolderPath = path.join(basePath, "config" ,"config.json");
const defaultDownloadPath = path.join(os.homedir(), "Downloads", "Freedom Loader");
app.setAppUserModelId("com.masteracnolo.freedomloader"); // pour notifications Windows
app.disableHardwareAcceleration();
ipcMain.handle("version", () => config.version);
// Gestion single instance
const gotLock = app.requestSingleInstanceLock();
// Native dependencies check (yt-dlp.exe, ffmpeg.exe, ffprobe.exe, Deno)
function checkNativeDependencies() {
const deps = [
{ name: "yt-dlp.exe", path: path.join(process.resourcesPath, "binaries","yt-dlp.exe") },
{ name: "ffmpeg.exe", path: path.join(process.resourcesPath, "binaries", "ffmpeg.exe") },
{ name: "ffprobe.exe", path: path.join(process.resourcesPath, "binaries", "ffprobe.exe") },
{ name: "deno.exe", path: path.join(process.resourcesPath, "binaries", "deno.exe") },
];
const missing = deps.filter(dep => !fs.existsSync(dep.path));
let errorMsg = "";
if (missing.length > 0) {
const missingList = missing.map(dep => dep.name).join(", ");
logger.error(`Missing dependencies: ${missingList}`);
errorMsg += `The following files are missing in the 'ressources' folder:\n${missingList}`;
}
if (errorMsg) {
app.whenReady().then(() => {
dialog.showErrorBox(
"Missing dependencies",
`${errorMsg}\n\nThe application will now exit. Try to reinstall`
);
app.quit(); app.quit();
}); });
return false;
} process.on("unhandledRejection", (reason) => {
return true; console.error("Unhandled rejection:", reason);
app.quit();
});
/**
* True if this is the primary instance (lock acquired successfully)
*/
const isPrimaryInstance = app.requestSingleInstanceLock();
/**
* If we are on a second instance
*/
if (!isPrimaryInstance) {
app.quit();
process.exit(0);
} }
if(!config.localMode){ /**
if (!gotLock) { * Set App Name and AppID
// Une instance existe déjà -> fermer l'ancienne et continuer la nouvelle */
// Ici la nouvelle instance continue normalement app.setName("Freedom Loader");
} else { app.setAppUserModelId("com.masteracnolo.freedomloader");
if (!checkNativeDependencies()) {
// Arrêt déjà géré dans la fonction
/**
* Load the app dependencies for hardware choice
*/
const { logger, logSessionStart, logSessionEnd } = require("./server/logger");
const { configFeatures } = require("./config");
/**
* On start, enable or disable hardWareAcceleration
*/
if (!configFeatures.enableHardwareAcceleration){
app.disableHardwareAcceleration();
logger.info("Disabled Hardware Acceleration")
} else { } else {
logger.info("Enable Hardware Acceleration")
}
/**
* In-memory snapshot of application feature flags.
*
* Note: Changes to the config file are not automatically reflected.
* A restart could be required.
*/
const config = require("./config");
if(config.devMode){
/**
* Start devTron extensions - @see https://github.com/electron/devtron
*/
const { devtron } = require('@electron/devtron');
devtron.install();
logger.info("Loaded DevTron Extension")
}
/**
* Load the app dependencies
*/
const { initThemes } = require("./app/themeManager");
const { initAutoUpdater } = require("./app/autoUpdater");
const { startRPC, stopRPC } = require("./app/discordRPC");
const { checkNativeDependencies } = require("./app/dependencyCheck");
const { updateYtDlp } = require("./app/ytDlpUpdater");
const { createMainWindow, getMainWindow } = require("./app/windowManager");
const { registerIpcHandlers } = require("./app/ipcHandlers");
const { createSplashWindow, closeSplashWindow, setSplashProgress } = require("./app/splashManager");
const { userThemesPath, initUserThemes, isWindows } = require("./server/helpers/path.helpers");
/**
* If another instance want to run
*/
app.on("second-instance", () => { app.on("second-instance", () => {
// La vieille instance se ferme logger.info("Second instance detected");
if (mainWindow) {
logger.info("New Instance Detected, closing the older...");
mainWindow.destroy();
mainWindow = null;
}
});
}
}
}
// Création fenêtre principale
async function createMainWindow() {
if (mainWindow) {
logger.warn("Window already exists, no new creation!");
return;
}
mainWindow = new BrowserWindow({ const mainWindow = require("./app/windowManager").getMainWindow();
title: `Freedom Loader ${config.version}`,
width: 750, if (!mainWindow) return;
height: 800,
minWidth: 750, if (mainWindow.isMinimized()) mainWindow.restore();
minHeight: 800,
frame: !configFeatures.customTopBar, mainWindow.show();
webPreferences: { mainWindow.focus();
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js"),
},
}); });
try {
await mainWindow.loadURL(`http://localhost:${config.applicationPort}`);
logger.info("Window Loaded");
} catch (err) {
logger.error("Window Loading Error:", err);
}
mainWindow.on("closed", () => {
logger.info("Main Window Closed");
mainWindow = null;
});
}
function validateDownloadPath(userPath) {
const userHome = os.homedir(); // C:\Users\<User>
if (!userPath) return path.join(userHome, "Downloads", "Freedom Loader");
// Résolution canonique et suivi des symlinks
const resolved = fs.realpathSync(path.resolve(userPath));
const normalizedHome = path.resolve(userHome) + path.sep;
if (!resolved.startsWith(normalizedHome)) {
throw new Error("Chemin non autorisé : uniquement les sous-dossiers du dossier utilisateur sont permis !");
}
return resolved;
}
// IPC
ipcMain.handle("select-download-folder", async () => {
try {
const result = await dialog.showOpenDialog({ properties: ["openDirectory"] });
if (!result.canceled && result.filePaths.length > 0) {
const validatedPath = validateDownloadPath(result.filePaths[0]);
logger.info(`Folder Checked and Valid : ${validatedPath}`);
return validatedPath;
}
return null;
} catch (err) {
logger.error(`An Error Occured when validating folder : ${err.message}`);
return null;
}
});
ipcMain.handle("validate-download-path", (event, userPath) => {
return validateDownloadPath(userPath);
});
ipcMain.handle("get-default-download-path", () => defaultDownloadPath);
ipcMain.on("set-progress", (event, percent) => {
if (mainWindow) mainWindow.setProgressBar(percent / 100); // Electron attend 0 → 1
});
// Topbar window controls
ipcMain.on("window-minimize", () => {
if (mainWindow) mainWindow.minimize();
});
ipcMain.on("window-maximize", () => {
if (mainWindow) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.on("window-close", () => {
if (mainWindow) mainWindow.close();
});
// Topbar custom actions
ipcMain.on("open-devtools", () => {
if (mainWindow) mainWindow.webContents.openDevTools({ mode: 'detach' });
});
ipcMain.on("open-logs", () => {
if (logsFolderPath) shell.openPath(logsFolderPath);
});
ipcMain.on("open-website", () => {
shell.openExternal("https://masteracnolo.github.io/FreedomLoader/index.html");
});
ipcMain.on("open-wiki", () => {
shell.openExternal("https://masteracnolo.github.io/FreedomLoader/pages/wiki.html");
});
ipcMain.on("open-config", () => {
if (configFolderPath) shell.openPath(configFolderPath);
});
// App ready
app.whenReady().then(async () => { app.whenReady().then(async () => {
logSessionStart(); logSessionStart();
logger.info("App Ready, Server Express starting...");
const serverPath = path.join(__dirname, "server", "server.js") createSplashWindow();
const expressServer = require(serverPath); if (!config.devMode && !checkNativeDependencies()) return;
const { userYtDlp } = require("./server/helpers/path.helpers");
updateYtDlp(userYtDlp);
try { try {
await expressServer.startServer(); setSplashProgress(0); // Checking dependencies
logger.info("Express Server Started"); await require("./server/server").startServer();
ipcMain.handle("features", () => { setSplashProgress(1); // Starting server
return configFeatures; registerIpcHandlers(getMainWindow);
});
const featureWhitelist = new Set([ setSplashProgress(2); // Loading themes
"autoUpdate",
"discordRPC",
"customTopBar",
"autoCheckInfo",
"addThumbnail",
"addMetadata",
"verboseLogs",
"autoDownloadPlaylist",
"customCodec"
]);
// TODO: Patch this, i disable this features for the moment
ipcMain.handle("set-feature", (event, { key, value }) => { // Themes are currently disabled if we are not on Windows
try { if(isWindows){
if (!featureWhitelist.has(key)) { initUserThemes();
logger.warn(`Rejected feature (not whitelisted): ${key}`); await initThemes(userThemesPath);
return false; } else {
logger.info(`OS is ${process.platform}, Skipping themes loading`)
} }
// optionnel mais propre setSplashProgress(3); // Almost ready
if (configFeatures[key] === value) {
return true;
}
configFeatures[key] = value;
fs.writeFileSync(
configFolderPath,
JSON.stringify(configFeatures, null, 2),
"utf-8"
);
logger.info(`Feature updated: ${key} = ${value}`);
return true;
} catch (err) {
logger.error(`set-feature failed (${key}): ${err.message}`);
return false;
}
});
configFeatures.discordRPC ? startRPC() : "";
await createMainWindow(); await createMainWindow();
configFeatures.autoUpdate ? AutoUpdater(mainWindow) : ""; // Auto Update
closeSplashWindow();
getMainWindow().show();
if (configFeatures.discordRPC) startRPC();
if (configFeatures.autoUpdate) initAutoUpdater(getMainWindow());
} catch (err) { } catch (err) {
logger.error("Window or Server error :", err); logger.error("Boot error:", err);
app.quit(); app.quit();
} }
}); });
app.on("window-all-closed", () => { app.on("window-all-closed", () => {
logger.info("All Window Closed, shuting down app"); logger.info("Shutting down...");
if (process.platform !== "darwin") app.quit(); app.quit();
}); });
app.on("before-quit", () => { app.on("before-quit", async () => {
logSessionEnd() await stopRPC();
stopRPC() logger.info("All services stopped. Have a nice day!");
logSessionEnd();
}); });

890
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,17 @@
{ {
"name": "freedom-loader", "name": "freedom-loader",
"productName": "Freedom Loader", "productName": "Freedom Loader",
"version": "1.4.2", "version": "1.6.0",
"author": "MasterAcnolo", "author": "MasterAcnolo <MasterAcnolo@users.noreply.github.com>",
"description": "Freedom Loader", "description": "Freedom Loader",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron --trace-warnings .", "start": "electron --trace-warnings .",
"postinstall": "chmod +x resources/binaries/linux/* 2>/dev/null || true",
"build": "electron-builder", "build": "electron-builder",
"build:win": "electron-builder --win",
"build:linux": "electron-builder --linux",
"build:all": "electron-builder -wl",
"update": "npm update" "update": "npm update"
}, },
"dependencies": { "dependencies": {
@@ -17,13 +21,15 @@
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"jszip": "^3.10.1",
"webidl-conversions": "^8.0.0", "webidl-conversions": "^8.0.0",
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0" "winston-daily-rotate-file": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"electron": "^37.1.0", "electron": "^41.2.0",
"electron-builder": "^26.4.0" "electron-builder": "^26.8.1",
"@electron/devtron": "^2.1.1"
}, },
"build": { "build": {
"appId": "com.masteracnolo.freedomloader", "appId": "com.masteracnolo.freedomloader",
@@ -32,59 +38,38 @@
{ {
"provider": "github", "provider": "github",
"owner": "MasterAcnolo", "owner": "MasterAcnolo",
"repo": "Freedom-Loader" "repo": "Freedom-Loader",
"releaseType": "draft"
} }
], ],
"files": [ "files": [
"**/*", "**/*",
"node_modules/**/*", "node_modules/**/*",
"!logs/*.json" "!config/config.dev.json",
"!theme/**/*"
], ],
"directories": { "directories": {
"buildResources": "build" "buildResources": "build"
}, },
"asar": true, "asar": true,
"extraResources": [ "extraResources": [
{ { "from": "theme", "to": "theme" },
"from": "build/confirm-icon.png", { "from": "build/confirm-icon.png", "to": "confirm-icon.png" },
"to": "confirm-icon.png" { "from": "build/banner.bmp", "to": "banner.bmp" },
}, { "from": "build/banner.png", "to": "banner.png" },
{ { "from": "build/error.png", "to": "error.png" },
"from": "build/app-icon.ico", { "from": "config/config.default.json", "to": "config/config.default.json" }
"to": "app-icon.ico"
},
{
"from": "build/banner.bmp",
"to": "banner.bmp"
},
{
"from": "build/error.png",
"to": "error.png"
},
{
"from": "ressources/yt-dlp.exe",
"to": "binaries/yt-dlp.exe"
},
{
"from": "ressources/ffmpeg.exe",
"to": "binaries/ffmpeg.exe"
},
{
"from": "ressources/ffprobe.exe",
"to": "binaries/ffprobe.exe"
},
{
"from": "ressources/deno.exe",
"to": "binaries/deno.exe"
},
{
"from": "config/config.json",
"to": "config/config.json"
}
], ],
"win": { "win": {
"target": "nsis", "target": "nsis",
"icon": "build/app-icon.ico" "icon": "build/app-icon.ico",
"artifactName": "${productName}-Setup-${version}.${ext}",
"extraResources": [
{ "from": "resources/binaries/win-32/yt-dlp.exe", "to": "binaries/yt-dlp.exe" },
{ "from": "resources/binaries/win-32/ffmpeg.exe", "to": "binaries/ffmpeg.exe" },
{ "from": "resources/binaries/win-32/ffprobe.exe", "to": "binaries/ffprobe.exe" },
{ "from": "resources/binaries/win-32/deno.exe", "to": "binaries/deno.exe" }
]
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
@@ -96,6 +81,41 @@
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "Freedom Loader" "shortcutName": "Freedom Loader"
},
"linux": {
"target": ["AppImage", "deb","rpm", "snap"],
"category": "Utility",
"icon": "build/app-icon.png",
"extraResources": [
{ "from": "resources/binaries/linux/yt-dlp", "to": "binaries/yt-dlp" },
{ "from": "resources/binaries/linux/ffmpeg", "to": "binaries/ffmpeg" },
{ "from": "resources/binaries/linux/ffprobe", "to": "binaries/ffprobe" },
{ "from": "resources/binaries/linux/deno", "to": "binaries/deno" }
]
},
"deb": {
"fpm": [
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
]
},
"rpm": {
"fpm": [
"./package/com.masteracnolo.freedomloader.metainfo.xml=/usr/share/metainfo/com.masteracnolo.freedomloader.metainfo.xml"
]
},
"snap": {
"confinement": "strict",
"plugs": [
"default",
"home",
"removable-media",
"network",
"audio-playback",
"desktop",
"desktop-legacy",
"x11",
"wayland"
]
} }
} }
} }

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.masteracnolo.freedomloader</id>
<name>Freedom Loader</name>
<summary>GUI for yt-dlp to download video and audio with metadata</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-only</project_license>
<developer id="com.masteracnolo">
<name>MasterAcnolo</name>
</developer>
<description>
<p>
Freedom Loader is a free and open-source graphical interface built
around yt-dlp, the powerful command-line media downloader. It lets
you download video and audio from hundreds of supported websites
without ever touching a terminal, with no ads, no tracking, and
no questionable third-party services.
</p>
<p>
Under the hood, Freedom Loader bundles yt-dlp and ffmpeg directly,
so there is nothing extra to install: paste a link, choose a
format and quality, and the app handles fetching, converting
and tagging the file for you.
</p>
<p>Features include:</p>
<ul>
<li>MP4 video or MP3 audio downloads with embedded metadata and thumbnails</li>
<li>Quality selection from best to specific resolutions (1080p, 720p, 480p)</li>
<li>Codec choice: H.264, H.265, AV1, VP9 and Theora</li>
<li>Playlist downloads with automatic per-playlist folders</li>
<li>Cookie-based access to age-restricted and member-only content</li>
<li>Custom theme system with a web-based theme creator</li>
<li>Real-time download progress with network speed indicator</li>
<li>Optional Discord Rich Presence integration</li>
<li>Automatic application and download-engine updates</li>
</ul>
<p>
DRM-protected content is not supported and cannot be downloaded.
</p>
</description>
<launchable type="desktop-id">freedom-loader.desktop</launchable>
<url type="homepage">https://github.com/MasterAcnolo/Freedom-Loader</url>
<url type="bugtracker">https://github.com/MasterAcnolo/Freedom-Loader/issues</url>
<url type="vcs-browser">https://github.com/MasterAcnolo/Freedom-Loader</url>
<categories>
<category>AudioVideo</category>
<category>Utility</category>
<category>Network</category>
</categories>
<releases>
<release version="1.6.0" date="2026-06-23">
<description>
<p>Latest stable release.</p>
</description>
</release>
</releases>
<content_rating type="oars-1.1"/>
<provides>
<binary>freedom-loader</binary>
</provides>
</component>

View File

@@ -0,0 +1,25 @@
Name: freedom-loader
Version: __VERSION__
Release: 1%{?dist}
Summary: GUI for yt-dlp media downloads
License: GPL-3.0-only
Source0: freedom-loader-__VERSION__.x86_64.rpm
BuildArch: x86_64
Requires: glibc, gtk3, nss, libXScrnSaver
%description
GUI for yt-dlp media downloads.
%install
rpm2cpio %{SOURCE0} | (cd %{buildroot} && cpio -idmv)
mkdir -p %{buildroot}/usr/bin
ln -sf "/opt/Freedom Loader/freedom-loader" %{buildroot}/usr/bin/freedom-loader
%files
/opt/*
/usr/bin/freedom-loader
/usr/share/applications/*
/usr/share/metainfo/*
/usr/share/icons/hicolor
%changelog

View File

@@ -1,23 +1,129 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require("electron");
/**
* Exposes the main application API to the renderer process.
*
* This API acts as a secure bridge between the renderer and the
* Electron main process using IPC (Inter-Process Communication).
* All calls are explicitly whitelisted.
*/
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
/**
* Return process.platform to renderer
*/
getProcessPlatform: () => process.platform,
/**
* Returns the default system download directory.
*/
getDefaultDownloadPath: () => ipcRenderer.invoke("get-default-download-path"), getDefaultDownloadPath: () => ipcRenderer.invoke("get-default-download-path"),
/**
* Opens a native dialog allowing the user to select a download folder.
*/
selectDownloadFolder: () => ipcRenderer.invoke("select-download-folder"), selectDownloadFolder: () => ipcRenderer.invoke("select-download-folder"),
/**
* Sends download progress value to the main process (used for taskbar / UI sync).
*
* @param {number} percent - Download progress percentage (0100)
*/
setProgress: (percent) => ipcRenderer.send("set-progress", percent), setProgress: (percent) => ipcRenderer.send("set-progress", percent),
/**
* Retrieves application feature flags (runtime configuration).
*/
getFeatures: () => ipcRenderer.invoke("features"), getFeatures: () => ipcRenderer.invoke("features"),
setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }),
/**
* Updates a feature flag dynamically at runtime.
*
* @param {string} key - Feature name
* @param {any} value - Feature value
*/
setFeature: (key, value) =>
ipcRenderer.invoke("set-feature", { key, value }),
/**
* Returns the current application version.
*/
getVersion: () => ipcRenderer.invoke("version"), getVersion: () => ipcRenderer.invoke("version"),
getValidatedDownloadPath: (path) => ipcRenderer.invoke("validate-download-path", path)
/**
* Validates a download path before using it for file operations.
*
* @param {string} path - Path to validate
*/
getValidatedDownloadPath: (path) =>
ipcRenderer.invoke("validate-download-path", path),
/**
* Retrieves available themes from the filesystem or config layer.
*/
getThemes: () => ipcRenderer.invoke("get-themes"),
/**
* Forces a reload of theme files (useful after modification/import).
*/
reloadThemes: () => ipcRenderer.invoke("reload-themes"),
}); });
// Contrôles de fenêtre et outils custom pour la topbar
/**
* Exposes window control and developer utilities for the custom topbar UI.
*
* These methods forward commands to the Electron main process via IPC.
*/
contextBridge.exposeInMainWorld("topbarAPI", { contextBridge.exposeInMainWorld("topbarAPI", {
/**
* Minimizes the application window.
*/
minimize: () => ipcRenderer.send("window-minimize"), minimize: () => ipcRenderer.send("window-minimize"),
/**
* Toggles maximize/restore state of the application window.
*/
maximize: () => ipcRenderer.send("window-maximize"), maximize: () => ipcRenderer.send("window-maximize"),
/**
* Closes the application window.
*/
close: () => ipcRenderer.send("window-close"), close: () => ipcRenderer.send("window-close"),
/**
* Opens Chromium DevTools for debugging.
*/
openDevTools: () => ipcRenderer.send("open-devtools"), openDevTools: () => ipcRenderer.send("open-devtools"),
/**
* Opens application logs directory or log viewer.
*/
openLogs: () => ipcRenderer.send("open-logs"), openLogs: () => ipcRenderer.send("open-logs"),
/**
* Opens the official website or project homepage.
*/
openWebsite: () => ipcRenderer.send("open-website"), openWebsite: () => ipcRenderer.send("open-website"),
/**
* Opens the theme directory or theme editor interface.
*/
openTheme: () => ipcRenderer.send("open-theme"),
/**
* Opens project wiki/documentation.
*/
openWiki: () => ipcRenderer.send("open-wiki"), openWiki: () => ipcRenderer.send("open-wiki"),
openConfig: () => ipcRenderer.send("open-config")
/**
* Opens the workshop or external content library.
*/
openWorkshop: () => ipcRenderer.send("open-workshop"),
/**
* Opens configuration/settings panel.
*/
openConfig: () => ipcRenderer.send("open-config"),
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

View File

@@ -7,9 +7,8 @@
default-src 'self' 'unsafe-inline' data:; default-src 'self' 'unsafe-inline' data:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src https://fonts.gstatic.com; font-src https://fonts.gstatic.com;
img-src 'self' data: https://i.ytimg.com; img-src 'self' data: https:;
"> ">
<title></title>
<link rel="stylesheet" href="styles/styles.css"> <link rel="stylesheet" href="styles/styles.css">
<link rel="stylesheet" href="styles/layout/topbar.css"> <link rel="stylesheet" href="styles/layout/topbar.css">
@@ -17,12 +16,15 @@
</head> </head>
<body> <body>
<header>
<div class="topbar" id="topbar"> <div class="topbar" id="topbar">
<div class="custom-controls"> <div class="custom-controls">
<button id="devtools-btn" title="Dev Tools">Tools</button> <button id="devtools-btn" title="Dev Tools">Tools</button>
<button id="logs-btn" title="Logs">Logs</button> <button id="logs-btn" title="Logs">Logs</button>
<button id="website-btn" title="Website">Website</button> <button id="website-btn" title="Website">Website</button>
<button id="wiki-btn" title="Wiki">Wiki</button> <button id="wiki-btn" title="Wiki">Wiki</button>
<button id="workshop-btn" title="Workshop">Theme Workshop</button>
<!-- <button id="theme-btn" title="Theme">Theme</button> -->
</div> </div>
<div class="window-controls"> <div class="window-controls">
<button id="minimize-btn" title="Réduire"><img src="assets/icon/minimize.svg"></button> <button id="minimize-btn" title="Réduire"><img src="assets/icon/minimize.svg"></button>
@@ -30,6 +32,7 @@
<button id="close-btn" title="Fermer"><img src="assets/icon/close.svg"></button> <button id="close-btn" title="Fermer"><img src="assets/icon/close.svg"></button>
</div> </div>
</div> </div>
</header>
<button class="settings-button" id="settings-button"> <button class="settings-button" id="settings-button">
<svg width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><title>ionicons-v5-q</title><path d="M262.29,192.31a64,64,0,1,0,57.4,57.4A64.13,64.13,0,0,0,262.29,192.31ZM416.39,256a154.34,154.34,0,0,1-1.53,20.79l45.21,35.46A10.81,10.81,0,0,1,462.52,326l-42.77,74a10.81,10.81,0,0,1-13.14,4.59l-44.9-18.08a16.11,16.11,0,0,0-15.17,1.75A164.48,164.48,0,0,1,325,400.8a15.94,15.94,0,0,0-8.82,12.14l-6.73,47.89A11.08,11.08,0,0,1,298.77,470H213.23a11.11,11.11,0,0,1-10.69-8.87l-6.72-47.82a16.07,16.07,0,0,0-9-12.22,155.3,155.3,0,0,1-21.46-12.57,16,16,0,0,0-15.11-1.71l-44.89,18.07a10.81,10.81,0,0,1-13.14-4.58l-42.77-74a10.8,10.8,0,0,1,2.45-13.75l38.21-30a16.05,16.05,0,0,0,6-14.08c-.36-4.17-.58-8.33-.58-12.5s.21-8.27.58-12.35a16,16,0,0,0-6.07-13.94l-38.19-30A10.81,10.81,0,0,1,49.48,186l42.77-74a10.81,10.81,0,0,1,13.14-4.59l44.9,18.08a16.11,16.11,0,0,0,15.17-1.75A164.48,164.48,0,0,1,187,111.2a15.94,15.94,0,0,0,8.82-12.14l6.73-47.89A11.08,11.08,0,0,1,213.23,42h85.54a11.11,11.11,0,0,1,10.69,8.87l6.72,47.82a16.07,16.07,0,0,0,9,12.22,155.3,155.3,0,0,1,21.46,12.57,16,16,0,0,0,15.11,1.71l44.89-18.07a10.81,10.81,0,0,1,13.14,4.58l42.77,74a10.8,10.8,0,0,1-2.45,13.75l-38.21,30a16.05,16.05,0,0,0-6.05,14.08C416.17,247.67,416.39,251.83,416.39,256Z" style="fill:none;stroke:var(--settings-button-text-color);stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/></svg> <svg width="800px" height="800px" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><title>ionicons-v5-q</title><path d="M262.29,192.31a64,64,0,1,0,57.4,57.4A64.13,64.13,0,0,0,262.29,192.31ZM416.39,256a154.34,154.34,0,0,1-1.53,20.79l45.21,35.46A10.81,10.81,0,0,1,462.52,326l-42.77,74a10.81,10.81,0,0,1-13.14,4.59l-44.9-18.08a16.11,16.11,0,0,0-15.17,1.75A164.48,164.48,0,0,1,325,400.8a15.94,15.94,0,0,0-8.82,12.14l-6.73,47.89A11.08,11.08,0,0,1,298.77,470H213.23a11.11,11.11,0,0,1-10.69-8.87l-6.72-47.82a16.07,16.07,0,0,0-9-12.22,155.3,155.3,0,0,1-21.46-12.57,16,16,0,0,0-15.11-1.71l-44.89,18.07a10.81,10.81,0,0,1-13.14-4.58l-42.77-74a10.8,10.8,0,0,1,2.45-13.75l38.21-30a16.05,16.05,0,0,0,6-14.08c-.36-4.17-.58-8.33-.58-12.5s.21-8.27.58-12.35a16,16,0,0,0-6.07-13.94l-38.19-30A10.81,10.81,0,0,1,49.48,186l42.77-74a10.81,10.81,0,0,1,13.14-4.59l44.9,18.08a16.11,16.11,0,0,0,15.17-1.75A164.48,164.48,0,0,1,187,111.2a15.94,15.94,0,0,0,8.82-12.14l6.73-47.89A11.08,11.08,0,0,1,213.23,42h85.54a11.11,11.11,0,0,1,10.69,8.87l6.72,47.82a16.07,16.07,0,0,0,9,12.22,155.3,155.3,0,0,1,21.46,12.57,16,16,0,0,0,15.11,1.71l44.89-18.07a10.81,10.81,0,0,1,13.14,4.58l42.77,74a10.8,10.8,0,0,1-2.45,13.75l-38.21,30a16.05,16.05,0,0,0-6.05,14.08C416.17,247.67,416.39,251.83,416.39,256Z" style="fill:none;stroke:var(--settings-button-text-color);stroke-linecap:round;stroke-linejoin:round;stroke-width:32px"/></svg>
@@ -52,12 +55,18 @@
<div class="setting-item" id="theme" style="margin-left: 32px;"> <div class="setting-item" id="theme" style="margin-left: 32px;">
<div class="setting-info"> <div class="setting-info">
<div style="display: flex;"> <div style="display: flex; align-items: center; gap: 10px;">
<span class="setting-title">Thème</span> <span class="setting-title">Thème</span>
<select id="themeSelect" data-key="theme" aria-label="Choisir le thème"> <select id="themeSelect" data-key="theme" aria-label="Choisir le thème">
</select> </select>
<button id="refresh-themes-btn" title="Refresh themes" class="refresh-themes-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 4v6h6M23 20v-6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div> </div>
<small>Choose the theme of the application</small> <small>Choose the theme of the application</small>
</div> </div>
@@ -80,20 +89,20 @@
</div> </div>
</div> </div>
<!-- <div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="createPlaylistFolders" id="createPlaylistFolders">
<div class="setting-info">
<span class="setting-title">Create Playlist Folders</span>
<small>Create a dedicated folder for each playlist with its name.</small>
</div>
</div>
<div class="setting-item">
<input type="checkbox" data-key="notifySystem" id="notifySystem"> <input type="checkbox" data-key="notifySystem" id="notifySystem">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Notification at the end</span> <span class="setting-title">Notification at the end</span>
<small>Notify when a download finishes.</small> <small>Notify when a download finishes.</small>
</div> </div>
</div> -->
<div class="setting-item">
<input type="checkbox" data-key="customTopBar" id="customTopBar">
<div class="setting-info">
<span class="setting-title">Custom Top bar</span>
<small>Use this if the topbar is missing or you prefer a custom one.</small>
</div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
@@ -142,6 +151,14 @@
</div> </div>
</div> </div>
<div class="setting-item">
<input type="checkbox" data-key="customTopBar" id="customTopBar">
<div class="setting-info">
<span class="setting-title">Custom Top bar</span>
<small>Use this if the topbar is missing or you prefer a custom one.</small>
</div>
</div>
<div class="setting-item" style="margin-left: 32px; margin-top: 20px;"> <div class="setting-item" style="margin-left: 32px; margin-top: 20px;">
<div class="setting-info"> <div class="setting-info">
<span class="setting-title">Custom Codec</span> <span class="setting-title">Custom Codec</span>
@@ -158,28 +175,20 @@
</div> </div>
</div> </div>
<div class="setting-item">
<input type="checkbox" data-key="enableHardwareAcceleration" id="enableHardwareAcceleration">
<div class="setting-info">
<span class="setting-title">Enable Hardware Acceleration</span>
<small>Uses GPU acceleration for rendering when available. <br> (Be effective on next launch)</small>
</div>
</div>
</details> </details>
<!-- Developer Settings --> <!-- Developer Settings -->
<details class="settings-section"> <details class="settings-section">
<summary>Developer Settings</summary> <summary>Developer Settings</summary>
<!-- <div class="setting-item">
<input type="checkbox" data-key="downloadSystem" id="downloadSystem">
<div class="setting-info">
<span class="setting-title">Download System</span>
<small>Enable/disable the internal download system.</small>
</div>
</div> -->
<!-- <div class="setting-item">
<input type="checkbox" data-key="logSystem" id="logSystem">
<div class="setting-info">
<span class="setting-title">Log System</span>
<small>Enable logging system.</small>
</div>
</div> -->
<div class="setting-item"> <div class="setting-item">
<input type="checkbox" data-key="verboseLogs" id="verboseLogs"> <input type="checkbox" data-key="verboseLogs" id="verboseLogs">
<div class="setting-info"> <div class="setting-info">
@@ -191,6 +200,7 @@
</details> </details>
<div class="settings-footer"> <div class="settings-footer">
<button id="open-theme">Open Theme Folder</button>
<button id="open-json-btn">Open JSON Configuration</button> <button id="open-json-btn">Open JSON Configuration</button>
</div> </div>
@@ -198,15 +208,20 @@
</div> </div>
</div> </div>
<main>
<div class="container" id="container"> <div class="container" id="container">
<header>
<h1 id="title"> Freedom Loader </h1> <h1 id="title"> Freedom Loader </h1>
<p id="subtitle"> Because why not? </p> <p id="subtitle"> Because why not? </p>
</header>
<form action="/download" method="POST" id="downloadForm"> <form action="/download" method="POST" id="downloadForm">
<input name="url" placeholder="URL" id="UrlInput"> <div class="url-input-container">
<input type="text" name="url" placeholder="URL" id="UrlInput">
<button type="button" id="pasteBtn" class="paste-btn" title="Paste from clipboard">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 5H7C5.89543 5 5 5.89543 5 7V19C5 20.1046 5.89543 21 7 21H17C18.1046 21 19 20.1046 19 19V7C19 5.89543 18.1046 5 17 5H15M9 5C9 6.10457 9.89543 7 11 7H13C14.1046 7 15 6.10457 15 5M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5M12 12H15M12 16H15M9 12H9.01M9 16H9.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<label class="checkbox-inline"> <label class="checkbox-inline">
@@ -227,6 +242,7 @@
<button type="submit" onclick="startProgress()">Download</button> <button type="submit" onclick="startProgress()">Download</button>
</form> </form>
</div> </div>
<div class="download-path"> <div class="download-path">
@@ -234,16 +250,20 @@
<button id="changePath">Edit</button> <button id="changePath">Edit</button>
</div> </div>
<div class="progress-container">
<div id="downloadStage" style="display: none; font-size: 0.85rem; color: var(--default-text-color); margin-top: 8px;"></div>
<div id="downloadProgressWrapper"> <div id="downloadProgressWrapper">
<div id="downloadProgress"></div> <div id="downloadProgress"></div>
</div> </div>
<div style="display: flex; gap: 20px; align-items: center;">
<div style="display: flex; gap: 20px;">
<div id="downloadProgressText"></div> <div id="downloadProgressText"></div>
<div id="downloadSpeedText"></div> <div id="downloadSpeedText"></div>
<div id="playlistInfoText" style="display: none; font-size: 0.9rem;"></div>
<button type="button" id="stopBtn" onclick="stopDownload()" class="stop-btn" style="display:none;" title="Stop download"></button>
</div>
</div> </div>
<div id="downloadStatus"></div> <div id="downloadStatus" style="display: none;"></div>
<div class="infos-container"> <div class="infos-container">
@@ -259,10 +279,19 @@
<span id="version-badge" class="version-badge"></span> <span id="version-badge" class="version-badge"></span>
</div>
</main>
<!-- Toast Container -->
<div id="toast-container"></div>
<h2 id="reference"> Because Why Not ? </h2> <h2 id="reference"> Because Why Not ? </h2>
<!-- Scripts --> <!-- Scripts -->
<script src="script/toast.js"></script>
<script src="script/customConfiguration.js"></script>
<script src="script/clipboardPaste.js"></script>
<script src="script/custompath.js"></script> <script src="script/custompath.js"></script>
<script src="script/settingsPanel.js"></script> <script src="script/settingsPanel.js"></script>
<script src="script/appVersion.js"></script> <script src="script/appVersion.js"></script>

View File

@@ -1,6 +1,21 @@
/**
* Retrieves the application version from the Electron main process
* and displays it in the UI version badge (bottom-right corner).
*
* This is mainly used for debugging and build identification.
*/
async function versionLabel() { async function versionLabel() {
const appVersion = await window.electronAPI.getVersion(); const appVersion = await window.electronAPI.getVersion();
document.getElementById("version-badge").textContent = `v${appVersion}`;
}; /**
* UI element displaying the current application version.
* Updated at runtime after IPC call resolves.
*/
const versionBadge = document.getElementById("version-badge");
if (!versionBadge) return;
versionBadge.textContent = `${appVersion}`;
}
versionLabel(); versionLabel();

View File

@@ -0,0 +1,40 @@
/**
* Initializes clipboard paste functionality for the URL input field.
*
* When the user clicks the paste button, the system attempts to read
* the clipboard content and inject it into the URL input field.
* It also triggers an input event to notify any bound listeners.
*/
document.addEventListener("DOMContentLoaded", () => {
const pasteBtn = document.getElementById("pasteBtn");
const urlInput = document.getElementById("UrlInput");
if (!pasteBtn || !urlInput) return;
/**
* Handles clipboard paste action triggered by the paste button.
* Reads clipboard text and inserts it into the URL input field.
*/
pasteBtn.addEventListener("click", async () => {
try {
urlInput.value = await navigator.clipboard.readText();
// Notify frameworks / listeners bound to input changes
urlInput.dispatchEvent(new Event("input", { bubbles: true }));
urlInput.focus();
// Temporary UI feedback on paste action
pasteBtn.style.backgroundColor = "var(--form-button-bg-hover-color)";
setTimeout(() => {
pasteBtn.style.backgroundColor = "";
}, 200);
} catch (err) {
console.error("Clipboard access failed:", err);
alert(
"Unable to access clipboard. Please paste manually using Ctrl+V."
);
}
});
});

View File

@@ -0,0 +1,30 @@
async function initPlatformRestrictions() {
const platform = await window.electronAPI.getProcessPlatform();
const themeSelect = document.getElementById("themeSelect");
const refreshBtn = document.getElementById("refresh-themes-btn");
/**
* Is the OS windows
* @type {boolean}
*/
const isWindows = platform === "win32";
console.log(isWindows);
// If it's windows, the button is not disabled.
themeSelect.disabled = !isWindows;
refreshBtn.disabled = !isWindows;
themeSelect.classList.toggle("disabled", !isWindows);
refreshBtn.classList.toggle("disabled", !isWindows);
if (!isWindows) {
themeSelect.title = "Themes are only available on Windows";
refreshBtn.title = "Themes are only available on Windows";
}
}
document.addEventListener('DOMContentLoaded', function(){
initPlatformRestrictions();
})

View File

@@ -1,9 +1,24 @@
/**
* Initializes the download path system once the UI is ready.
*
* Responsibilities:
* - Restore last known user-selected download path (localStorage)
* - Validate it against backend rules
* - Synchronize with backend authoritative path
* - Setup folder selection UI interaction
*/
window.addEventListener("DOMContentLoaded", async () => { window.addEventListener("DOMContentLoaded", async () => {
const savePathElem = document.getElementById("savePath"); const savePathElem = document.getElementById("savePath");
const form = document.getElementById("downloadForm"); const form = document.getElementById("downloadForm");
// input caché = DERNIER chemin validé par le back /**
* Hidden form field storing the validated download path
* used during download submission.
*
* Created dynamically if not already present in DOM.
*/
let hidden = document.getElementById("savePathInput"); let hidden = document.getElementById("savePathInput");
if (!hidden) { if (!hidden) {
hidden = document.createElement("input"); hidden = document.createElement("input");
hidden.type = "hidden"; hidden.type = "hidden";
@@ -12,12 +27,32 @@ window.addEventListener("DOMContentLoaded", async () => {
form.appendChild(hidden); form.appendChild(hidden);
} }
/**
* Applies a validated download path received from backend.
*
* Updates:
* - UI display (visible path)
* - tooltip (full path)
* - hidden form input (used for submission)
* - localStorage cache (UX persistence only, not authoritative)
*
* @param {string} path - validated absolute download path
*/
async function applyPathFromBack(path) { async function applyPathFromBack(path) {
savePathElem.textContent = path; savePathElem.textContent = path;
savePathElem.title = path;
hidden.value = path; hidden.value = path;
localStorage.setItem("customDownloadPath", path); // UX only localStorage.setItem("customDownloadPath", path); // UX only
} }
/**
* Loads the initial download path on application startup.
*
* Priority order:
* 1. cached localStorage value (UX only, not trusted)
* 2. backend validated version of cached path
* 3. backend default fallback path if validation fails
*/
async function loadInitialPath() { async function loadInitialPath() {
// On affiche ce que le user a vu la dernière fois (UX) // On affiche ce que le user a vu la dernière fois (UX)
const cached = localStorage.getItem("customDownloadPath"); const cached = localStorage.getItem("customDownloadPath");
@@ -41,22 +76,22 @@ window.addEventListener("DOMContentLoaded", async () => {
await loadInitialPath(); await loadInitialPath();
document /**
.getElementById("changePath") * Opens system folder selector and updates download path.
.addEventListener("click", async () => { *
* The selected folder is validated by Electron backend
* before being applied to the UI and persisted.
*/
document.getElementById("changePath").addEventListener("click", async () => {
try { try {
const selectedPath = // selectDownloadFolder already returns a validated path
await window.electronAPI.selectDownloadFolder(); const validatedPath = await window.electronAPI.selectDownloadFolder();
if (!selectedPath) return; // annulé if (!validatedPath) return; // cancelled
// Validation back obligatoire
const validatedPath =
await window.electronAPI.getValidatedDownloadPath(selectedPath);
await applyPathFromBack(validatedPath); await applyPathFromBack(validatedPath);
} catch (err) { } catch (err) {
alert("Dossier non autorisé."); alert("This folder is not allowed. Only specific folders (Users, Downloads, Documents) are authorized for downloads.");
} }
}); });
}); });

View File

@@ -1,62 +1,256 @@
const themes = {
dark: { label: "Sombre", subtitle: "Darkness is my ally" },
light: { label: "Clair", subtitle: "Qui aime ce thème ?" },
neon: { label: "Purple", subtitle: "How was your day ?"},
nf: { label: "NF", subtitle: "You call it music, i call it my Therapist" },
songbird: { label: "Songbird", subtitle: "From Her to Eternity" },
drift: { label: "Drift", subtitle: "Si la route t'appelle, contre appel" },
fanatic: { label: "Fanatic", subtitle: "Always Fnatic !" },
chirac: { label: "Chirac", subtitle: "J'aime les pommes" },
spicy: { label: "Spicy", subtitle: "The Spiciest One" },
};
const themeSelect = document.getElementById("themeSelect"); const themeSelect = document.getElementById("themeSelect");
/**
* Currently selected themes loaded from backend.
* Used as cache for UI theme switching.
*/
let loadedThemes = [];
function populateThemeSelect() { /**
for (const [themeKey, themeInfo] of Object.entries(themes)) { * Applies a theme object to the application UI by mapping
* theme configuration values to CSS variables and DOM properties.
*
* This function mutates:
* - document.body (background styling)
* - :root CSS variables (design system tokens)
* - subtitle DOM element (theme metadata display)
*
* @param {Object} theme - Theme definition object
* @param {Object} theme.style - Style configuration
*/
function applyTheme(theme) {
const root = document.documentElement;
const style = theme.style;
// Background
if (theme.image) {
document.body.style.backgroundImage = `url('${theme.image}')`;
document.body.style.backgroundSize = style.background?.size || "cover";
document.body.style.backgroundPosition = style.background?.position || "center";
document.body.style.backgroundAttachment = style.background?.attachment || "fixed";
} else {
document.body.style.backgroundImage = "";
}
// CSS variables (design tokens)
root.style.setProperty("--background-color", style.colors?.background || "#121212");
root.style.setProperty("--default-text-color", style.colors?.text?.default || "#e0e0e0");
root.style.setProperty("--subtitle-color", style.colors?.text?.subtitle || "#2196f3");
root.style.setProperty("--audio-only-label-color", style.colors?.text?.audioOnly || "#f5f5f5");
root.style.setProperty("--form-bg-color", style.form?.background || "#1e1e1e");
root.style.setProperty("--form-input-bg-color", style.form?.input?.background || "#2a2a2a");
root.style.setProperty("--form-input-border-color", style.form?.input?.border || "#444444");
root.style.setProperty("--form-input-border-focus-color", style.form?.input?.borderFocus || "#2196f3");
root.style.setProperty("--form-input-text-color", style.form?.input?.text || "#f5f5f5");
root.style.setProperty("--form-input-placeholder-color", style.form?.input?.placeholder || "#888888");
root.style.setProperty("--form-button-bg-color", style.form?.button?.background || "#2196f3");
root.style.setProperty("--form-button-text-color", style.form?.button?.text || "#ffffff");
root.style.setProperty("--form-button-bg-hover-color", style.form?.button?.hover || "#1976d2");
root.style.setProperty("--paste-button-icon-color", style.form?.pasteButtonIcon || "#ffffff");
root.style.setProperty("--checkbox-unchecked-bg-color", style.checkbox?.background?.unchecked || "#555555");
root.style.setProperty("--checkbox-checked-bg-color", style.checkbox?.background?.checked || "#2196f3");
root.style.setProperty("--checkbox-checkmark-border-color", style.checkbox?.checkmarkBorder || "#ffffff");
root.style.setProperty("--checkbox-pulse-color", style.checkbox?.pulse || "rgba(33, 150, 243, 0.5)");
root.style.setProperty("--download-status-color", style.download?.status || "#2196f3");
root.style.setProperty("--progress-bar-bg-color", style.progressBar?.background || "#444444");
root.style.setProperty("--progress-bar-fill-color", style.progressBar?.fill || "#2196f3");
root.style.setProperty("--video-info-bg-color", style.videoInfo?.background || "#1a1a1a");
root.style.setProperty("--video-info-text-color", style.videoInfo?.text || "#e0e0e0");
root.style.setProperty("--video-info-heading-color", style.videoInfo?.heading || "#2196f3");
root.style.setProperty("--video-info-list-color", style.videoInfo?.list || "#cccccc");
root.style.setProperty("--video-info-list-strong-color", style.videoInfo?.strong || "#ffffff");
root.style.setProperty("--video-info-link-color", style.videoInfo?.link || "#2196f3");
root.style.setProperty("--video-info-link-hover-color", style.videoInfo?.linkHover || "#1976d2");
root.style.setProperty("--playlist-background-color", style.playlist?.background || "#303030");
root.style.setProperty("--settings-button-bg-color", style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--settings-button-text-color", style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--open-theme-button-bg-color", style.settings?.openThemeButton?.background || "#2196f3");
root.style.setProperty("--open-theme-button-text-color", style.settings?.openThemeButton?.text || "#ffffff");
root.style.setProperty("--open-json-button-bg-color", style.settings?.openJsonButton?.background || style.settings?.button?.background || "#1e1e1e");
root.style.setProperty("--open-json-button-text-color", style.settings?.openJsonButton?.text || style.settings?.button?.text || "#e0e0e0");
root.style.setProperty("--settings-modal-bg-color", style.settings?.background?.modal || "#1e1e1e");
root.style.setProperty("--settings-section-bg-color", style.settings?.background?.section || "#2a2a2a");
root.style.setProperty("--settings-text-color", style.settings?.text || "#e0e0e0");
root.style.setProperty("--settings-subtitle-color", style.settings?.subtitle || "#aaaaaa");
const subtitleEl = document.getElementById("subtitle");
if (subtitleEl) subtitleEl.textContent = theme.subtitle || theme.name;
}
/**
* Persists selected theme ID into Electron backend features storage.
*
* @param {string} themeId
*/
function saveTheme(themeId) {
window.electronAPI.setFeature("theme", themeId);
}
/**
* Populates theme dropdown selector with available themes.
*
* @param {Array} themes
*/
function populateThemeSelect(themes) {
themeSelect.innerHTML = "";
for (const theme of themes) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = themeKey; option.value = theme.id;
option.textContent = themeInfo.label; option.textContent = theme.name;
themeSelect.appendChild(option); themeSelect.appendChild(option);
} }
} }
function applyTheme(themeKey) { /**
document.body.classList.remove(...Object.keys(themes)); * Initializes theme system:
document.body.classList.add(themeKey); * - loads themes from backend
* - restores saved theme
* - applies selected theme
*/
async function initThemes() {
loadedThemes = await window.electronAPI.getThemes();
const subtitleElement = document.getElementById("subtitle"); if (!loadedThemes.length) {
if (subtitleElement && themes[themeKey]) { const defaultTheme = {
subtitleElement.textContent = themes[themeKey].subtitle; id: "dark",
name: "Dark",
subtitle: "Darkness is my ally",
style: {
background: {
size: "cover",
position: "center",
attachment: "fixed"
},
colors: {
background: "#121212",
text: {
default: "#e0e0e0",
subtitle: "#2196f3",
audioOnly: "#f5f5f5"
} }
},
} form: {
background: "#1e1e1e",
input: {
background: "#2a2a2a",
border: "#444444",
borderFocus: "#2196f3",
text: "#f5f5f5",
placeholder: "#888888"
},
button: {
background: "#2196f3",
text: "#ffffff",
hover: "#1976d2"
},
pasteButtonIcon: "#ffffff"
},
function saveTheme(themeKey) { checkbox: {
localStorage.setItem("selectedTheme", themeKey); background: {
} unchecked: "#555555",
checked: "#2196f3"
},
checkmarkBorder: "#ffffff",
pulse: "rgba(33, 150, 243, 0.5)"
},
function loadTheme() { download: {
const savedTheme = localStorage.getItem("selectedTheme"); status: "#2196f3"
},
if (savedTheme && themes[savedTheme]) { progressBar: {
applyTheme(savedTheme); background: "#444444",
themeSelect.value = savedTheme; fill: "#2196f3"
} else { },
applyTheme("dark"); // thème par défaut
themeSelect.value = "dark"; videoInfo: {
background: "#1a1a1a",
text: "#e0e0e0",
heading: "#2196f3",
list: "#cccccc",
strong: "#ffffff",
link: "#2196f3",
linkHover: "#1976d2"
},
playlist: {
background: "#303030"
},
settings: {
button: {
background: "#1e1e1e",
text: "#e0e0e0"
},
openThemeButton: {
background: "#2196f3",
text: "#ffffff"
},
openJsonButton: {
background: "#2196f3",
text: "#ffffff"
},
background: {
modal: "#1e1e1e",
section: "#2a2a2a"
},
text: "#e0e0e0",
subtitle: "#aaaaaa"
} }
} }
};
// Quand l'utilisateur change le thème depuis le select loadedThemes = [defaultTheme];
themeSelect.addEventListener("change", (event) => {
const selectedTheme = event.target.value;
if (themes[selectedTheme]) {
applyTheme(selectedTheme);
saveTheme(selectedTheme);
} }
populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures();
const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id;
applyTheme(theme);
}
/**
* Handles theme selection change from UI dropdown.
*/
themeSelect.addEventListener("change", (e) => {
const theme = loadedThemes.find(t => t.id === e.target.value);
if (!theme) return;
applyTheme(theme);
saveTheme(theme.id);
}); });
// Initialisation /**
populateThemeSelect(); * Reloads themes from backend and reapplies current selection.
loadTheme(); */
async function refreshThemes() {
loadedThemes = await window.electronAPI.reloadThemes();
populateThemeSelect(loadedThemes);
const features = await window.electronAPI.getFeatures();
const savedId = features.theme;
const theme = loadedThemes.find(t => t.id === savedId) || loadedThemes[0];
themeSelect.value = theme.id;
applyTheme(theme);
}
window.refreshThemes = refreshThemes;
initThemes();

View File

@@ -1,11 +1,55 @@
const form = document.getElementById("downloadForm"); const form = document.getElementById("downloadForm");
const statusDiv = document.getElementById("downloadStatus"); const statusDiv = document.getElementById("downloadStatus");
const button = form.querySelector("button"); const button = form.querySelector("button[type=\"submit\"]");
const stopBtn = document.getElementById("stopBtn");
/**
* Indicates whether a download process is currently active.
* Used to prevent duplicate submissions and to gate UI actions
* such as disabling the form or showing progress.
*/
let isDownloading = false;
/**
* Indicates whether the current download was explicitly
* stopped by the user via the cancel action.
*
* Used to differentiate between:
* - user-initiated cancellation
* - failures or natural completion
*/
let userStoppedDownload = false;
/**
* Handles download form submission.
*
* - Validates user input (URL)
* - Disables UI during download
* - Sends request to backend `/download`
* - Manages success / error states
* - Restores UI state after completion
*/
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
button.disabled = true; // Empêche les clics multiples
statusDiv.textContent = "Download in progress..."; const urlInput = form.querySelector("input[name='url']");
const url = urlInput.value.trim();
// Basic URL validation
if (!url) {
window.showError("Please enter a URL");
return;
}
button.disabled = true;
statusDiv.style.display = "none";
isDownloading = true;
userStoppedDownload = false;
// Show progress UI immediately with initial message
showInitialProgress();
const formData = new FormData(form); const formData = new FormData(form);
const params = new URLSearchParams(formData); const params = new URLSearchParams(formData);
@@ -18,20 +62,113 @@ form.addEventListener("submit", async (e) => {
}); });
if (!res.ok) { if (!res.ok) {
statusDiv.textContent = res; if (!userStoppedDownload) {
const errorText = await res.text();
window.showError(errorText);
resetProgressBar();
}
return; return;
} }
const text = await res.text(); // Download successful - check if notifications are enabled
statusDiv.textContent = text; const features = await window.electronAPI.getFeatures();
if (features.notifySystem) {
window.showSuccess("Download completed!");
}
} catch { } catch (err) {
statusDiv.textContent = "❌ An Error has Occured."; if (!isDownloading && !userStoppedDownload) {
statusDiv.style.display = "none";
}
} finally { } finally {
isDownloading = false;
button.disabled = false; button.disabled = false;
stopBtn.style.display = "none";
setTimeout(() => {
statusDiv.textContent = "";
}, 5000);
} }
}); });
/**
* Displays initial download progress UI immediately
* when a download starts.
*
* Initializes progress bar, speed indicator, stage text
* and shows the stop button.
*/
function showInitialProgress() {
const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBarText = document.getElementById("downloadProgressText");
const speedElement = document.getElementById("downloadSpeedText");
const stageElement = document.getElementById("downloadStage");
const stopBtn = document.getElementById("stopBtn");
if (progressWrapper) progressWrapper.style.display = "block";
if (progressBarText) {
progressBarText.style.display = "block";
progressBarText.innerHTML = "0%";
}
if (speedElement) {
speedElement.style.display = "block";
speedElement.textContent = "0 Mib/s";
}
if (stageElement) {
stageElement.style.display = "block";
stageElement.textContent = "Retrieving data...";
}
if (stopBtn) stopBtn.style.display = "inline-flex";
}
/**
* Cancels the current download process.
*
* Sends a request to backend `/download/cancel`,
* updates UI state and resets progress indicators.
*
* Prevents duplicate calls if no download is active.
*/
async function stopDownload() {
if (!isDownloading) return;
userStoppedDownload = true;
button.disabled = true;
stopBtn.disabled = true;
try {
await fetch("/download/cancel", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
window.showWarning("Download stopped");
} catch (err) {
console.error("Error stopping download:", err);
} finally {
isDownloading = false;
button.disabled = false;
stopBtn.disabled = false;
stopBtn.style.display = "none";
resetProgressBar();
}
}
/**
* Resets all download progress UI elements to their
* default hidden state.
*
* Used after completion or cancellation of a download.
*/
function resetProgressBar() {
const progressWrapper = document.getElementById("downloadProgressWrapper");
if (progressWrapper) progressWrapper.style.display = "none";
const progressBar = document.getElementById("downloadProgress");
if (progressBar) progressBar.style.width = "0%";
const progressBarText = document.getElementById("downloadProgressText");
if (progressBarText) progressBarText.style.display = "none";
const speedElement = document.getElementById("downloadSpeedText");
if (speedElement) speedElement.style.display = "none";
const playlistInfoElement = document.getElementById("playlistInfoText");
if (playlistInfoElement) playlistInfoElement.style.display = "none";
}

View File

@@ -1,12 +1,33 @@
/**
* Converts a raw upload date string (format YYYYMMDD)
* into a human-readable format (DD/MM/YYYY).
*
* Returns "Inconnue" if the input is invalid or malformed.
*/
function formatDate(dateStr) { function formatDate(dateStr) {
if (!dateStr || dateStr.length !== 8) return "Inconnue"; if (!dateStr || dateStr.length !== 8) return "Inconnue";
return `${dateStr.slice(6,8)}/${dateStr.slice(4,6)}/${dateStr.slice(0,4)}`; return `${dateStr.slice(6,8)}/${dateStr.slice(4,6)}/${dateStr.slice(0,4)}`;
} }
/**
* Converts a file size in bytes into megabytes (MB).
*
* Returns a formatted string with 2 decimals (e.g. "12.34 Mo").
* If input is falsy, returns "Inconnue".
*/
function formatSize(bytes) { function formatSize(bytes) {
return bytes ? (bytes / (1024*1024)).toFixed(2) + " Mo" : "Inconnue"; return bytes ? (bytes / (1024*1024)).toFixed(2) + " Mo" : "Inconnue";
} }
/**
* Fetches video or playlist metadata from the backend `/info` endpoint.
*
* Sends a POST request with URL-encoded body.
* Handles network errors, HTTP errors and JSON parsing safely.
*
* @param {string} url - Video or playlist URL
* @returns {Promise<Object>} Parsed metadata or error object
*/
async function fetchVideoInfo(url) { async function fetchVideoInfo(url) {
try { try {
const res = await fetch("/info", { const res = await fetch("/info", {
@@ -26,13 +47,34 @@ async function fetchVideoInfo(url) {
} }
} }
/**
* Initializes the video info system UI.
*
* - Listens to URL input changes
* - Automatically fetches video/playlist metadata if enabled
* - Renders either playlist UI or single video UI
* - Handles loading states and caching (lastFetchedUrl)
*
* Also manages dynamic playlist title injection into the form.
*/
async function init() { async function init() {
// Main input listener: triggers auto-fetch when URL changes
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const urlInput = document.getElementById("UrlInput"); const urlInput = document.getElementById("UrlInput");
const infoDiv = document.getElementById("videoInfo"); const infoDiv = document.getElementById("videoInfo");
const loaderBox = document.getElementById("loaderBox"); const loaderBox = document.getElementById("loaderBox");
const form = document.getElementById("downloadForm");
// Input caché pour stocker le titre de la playlist
let playlistTitleInput = document.getElementById("playlistTitle");
if (!playlistTitleInput) {
playlistTitleInput = document.createElement("input");
playlistTitleInput.type = "hidden";
playlistTitleInput.name = "playlistTitle";
playlistTitleInput.id = "playlistTitle";
form.appendChild(playlistTitleInput);
}
let lastFetchedUrl = ""; let lastFetchedUrl = "";
@@ -43,10 +85,11 @@ async function init() {
const url = urlInput.value.trim(); const url = urlInput.value.trim();
// Si champ vide -> reset total // IF field empty -> total reset
if (!url || url.length < 2) { if (!url || url.length < 2) {
infoDiv.innerHTML = ""; infoDiv.innerHTML = "";
infoDiv.classList.remove("visible", "playlist-mode"); infoDiv.classList.remove("visible", "playlist-mode");
playlistTitleInput.value = "";
lastFetchedUrl = ""; lastFetchedUrl = "";
return; return;
} }
@@ -58,7 +101,7 @@ async function init() {
const data = await fetchVideoInfo(url); const data = await fetchVideoInfo(url);
loaderBox.style.display = "none"; loaderBox.style.display = "none";
// Gestion des erreurs
if (data.error) { if (data.error) {
infoDiv.innerHTML = ` infoDiv.innerHTML = `
<div style=" <div style="
@@ -70,17 +113,37 @@ async function init() {
`; `;
infoDiv.classList.add("visible"); infoDiv.classList.add("visible");
infoDiv.classList.remove("playlist-mode"); infoDiv.classList.remove("playlist-mode");
playlistTitleInput.value = "";
loaderBox.style.display = "none"; loaderBox.style.display = "none";
return; return;
} }
// ---------- PLAYLIST ---------- // ---------- PLAYLIST ----------
if (data.type === "playlist") { if (data.type === "playlist") {
playlistTitleInput.value = data.title;
infoDiv.classList.add("playlist-mode"); infoDiv.classList.add("playlist-mode");
infoDiv.innerHTML = ` infoDiv.innerHTML = `
<h3 style="color:var(--video-info-heading-color);"><strong>Playlist Detected: ${data.title}</strong></h3> <div class="playlist-header">
<h3 style="color:var(--video-info-heading-color);"><strong>Video Count: ${data.count}</strong></h3> <div class="playlist-info">
<p><strong>Channel :</strong> ${data.channel || "Unknown"}</p> <div class="playlist-badge">Detected Playlist</div>
<h3 class="playlist-title">${data.title}</h3>
<div class="playlist-meta">
<span class="playlist-count">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6H20M4 12H20M4 18H11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
${data.count} vidéos
</span>
${data.channel ? `<span class="playlist-channel">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2"/>
<path d="M5 20C5 16.134 8.134 13 12 13C15.866 13 19 16.134 19 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
${data.channel}
</span>` : ''}
</div>
</div>
</div>
<div id="playlistVideos"></div> <div id="playlistVideos"></div>
`; `;
@@ -88,39 +151,58 @@ async function init() {
data.videos.forEach(v => { data.videos.forEach(v => {
const durationStr = v.duration const durationStr = v.duration
? `${Math.floor(v.duration / 60)}m ${(v.duration % 60).toString().padStart(2,"0")}s` : "Inconnue"; ? `${Math.floor(v.duration / 60)}:${(v.duration % 60).toString().padStart(2,"0")}` : "--:--";
const videoUrl = v.id ? `https://www.youtube.com/watch?v=${v.id}` : v.url; const videoUrl = v.id ? `https://www.youtube.com/watch?v=${v.id}` : v.url;
listDiv.innerHTML += ` listDiv.innerHTML += `
<div style="margin-bottom:12px;"> <div class="playlist-video-card">
<img src="${v.thumbnail}" width="160" alt="Thumbnail"> <div class="video-thumbnail-wrapper">
<p><strong>${v.title}</strong></p> <img src="${v.thumbnail}" alt="${v.title}" class="video-thumbnail">
<p>Duration : ${durationStr}</p> <span class="video-duration">${durationStr}</span>
<p><a href="${videoUrl}" target="_blank">URL</a> </div>
<button class="copy-btn" data-url="${videoUrl}">📋</button> <div class="video-content">
</p> <h4 class="video-title" title="${v.title}">${v.title}</h4>
${v.uploader ? `<p class="video-uploader">${v.uploader}</p>` : ''}
<div class="video-actions">
<a href="${videoUrl}" target="_blank" class="video-link">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Ouvrir
</a>
<button class="copy-btn" data-url="${videoUrl}" title="Copier l'URL">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" stroke-width="2"/>
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
</div>
</div>
</div> </div>
`; `;
}); });
// Gestion du bouton copier // Copy Button
listDiv.addEventListener("click", (event) => { listDiv.addEventListener("click", (event) => {
if (event.target.classList.contains("copy-btn")) { if (event.target.classList.contains("copy-btn") || event.target.closest(".copy-btn")) {
const btn = event.target; const btn = event.target.closest(".copy-btn") || event.target;
if (btn.disabled) return; if (btn.disabled) return;
btn.disabled = true; btn.disabled = true;
const url = btn.dataset.url; const url = btn.dataset.url;
navigator.clipboard.writeText(url) navigator.clipboard.writeText(url)
.then(() => { .then(() => {
const original = btn.textContent; const original = btn.innerHTML;
btn.style.opacity = 0; btn.style.opacity = 0;
btn.style.transform = "scale(0.7)"; btn.style.transform = "scale(0.7)";
setTimeout(() => { setTimeout(() => {
btn.textContent = "✅"; btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
btn.style.opacity = 1; btn.style.opacity = 1;
btn.style.transform = "scale(1)"; btn.style.transform = "scale(1)";
@@ -129,7 +211,7 @@ async function init() {
btn.style.transform = "scale(0.7)"; btn.style.transform = "scale(0.7)";
setTimeout(() => { setTimeout(() => {
btn.textContent = original; btn.innerHTML = original;
btn.style.opacity = 1; btn.style.opacity = 1;
btn.style.transform = "scale(1)"; btn.style.transform = "scale(1)";
btn.disabled = false; btn.disabled = false;
@@ -140,10 +222,12 @@ async function init() {
}, 300); }, 300);
}) })
.catch(() => { .catch(() => {
const original = btn.textContent; const original = btn.innerHTML;
btn.textContent = "❌"; btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>`;
setTimeout(() => { setTimeout(() => {
btn.textContent = original; btn.innerHTML = original;
btn.disabled = false; btn.disabled = false;
}, 1500); }, 1500);
}); });
@@ -155,6 +239,7 @@ async function init() {
} }
infoDiv.classList.remove("playlist-mode"); infoDiv.classList.remove("playlist-mode");
playlistTitleInput.value = "";
// ---------- VIDEO NORMALE ---------- // ---------- VIDEO NORMALE ----------
const durationStr = data.duration const durationStr = data.duration
@@ -186,6 +271,6 @@ async function init() {
}) })
}; }
init(); init();

View File

@@ -1,25 +1,65 @@
/**
* DOM elements used for download progress UI rendering.
* These elements are updated in real-time via SSE streams.
*/
const progressWrapper = document.getElementById("downloadProgressWrapper"); const progressWrapper = document.getElementById("downloadProgressWrapper");
const progressBar = document.getElementById("downloadProgress"); const progressBar = document.getElementById("downloadProgress");
const progressBarText = document.getElementById("downloadProgressText") const progressBarText = document.getElementById("downloadProgressText");
const speedElement = document.getElementById("downloadSpeedText"); const speedElement = document.getElementById("downloadSpeedText");
const stageElement = document.getElementById("downloadStage");
const playlistInfoElement = document.getElementById("playlistInfoText");
/**
* Server-Sent Events streams for real-time download updates.
* Each stream is responsible for a specific aspect of the download state.
*/
const speedEvt = new EventSource("/download/speed"); const speedEvt = new EventSource("/download/speed");
const stageEvt = new EventSource("/download/stage");
const playlistInfoEvt = new EventSource("/download/playlist-info");
/**
* Resets the progress UI to its initial state.
* Hides all dynamic elements and clears displayed values.
*/
function startProgress() { function startProgress() {
progressWrapper.style.display = "block";
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressBarText.style.display = "block";
progressBarText.innerHTML = "0%"; progressBarText.innerHTML = "0%";
speedElement.style.display = "block";
speedElement.innerHTML = "0 Mib/s"; speedElement.innerHTML = "0 Mib/s";
} }
/**
* Makes the progress UI visible and enables related controls
* such as the stop button.
*/
function showProgress() {
progressWrapper.style.display = "block";
progressBarText.style.display = "block";
speedElement.style.display = "block";
const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "inline-flex";
}
/**
* Updates the progress bar UI with the given percentage.
* Automatically triggers UI visibility if not already shown.
*
* @param {number} percent - Download progress percentage (0100)
*/
function updateProgress(percent) { function updateProgress(percent) {
if (percent > 0 && progressWrapper.style.display !== "block") {
showProgress();
}
progressBar.style.width = `${percent}%`; progressBar.style.width = `${percent}%`;
progressBarText.innerHTML = `${percent}%`; progressBarText.innerHTML = `${percent}%`;
} }
/**
* Fully resets the progress UI and hides all download indicators.
* Called after completion or cancellation.
*/
function resetProgress() { function resetProgress() {
progressBar.style.width = "0%"; progressBar.style.width = "0%";
progressBarText.textContent = ""; progressBarText.textContent = "";
@@ -28,10 +68,26 @@ function resetProgress() {
speedElement.textContent = "0 Mib/s"; speedElement.textContent = "0 Mib/s";
speedElement.style.display = "none"; speedElement.style.display = "none";
stageElement.textContent = "";
stageElement.style.display = "none";
playlistInfoElement.textContent = "";
playlistInfoElement.style.display = "none";
const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "none";
} }
// Connexion SSE /**
* Main SSE stream handling download progress updates.
* Supports:
* - reset signal
* - done signal
* - percentage updates
*/
const evtSource = new EventSource("/download/progress"); const evtSource = new EventSource("/download/progress");
evtSource.onmessage = e => { evtSource.onmessage = e => {
if (e.data === "reset") { if (e.data === "reset") {
startProgress(); startProgress();
@@ -40,8 +96,10 @@ evtSource.onmessage = e => {
} }
if (e.data === "done") { if (e.data === "done") {
setTimeout(() => {
resetProgress(); resetProgress();
window.electronAPI.setProgress(-1); window.electronAPI.setProgress(-1);
}, 2000);
return; return;
} }
@@ -49,15 +107,30 @@ evtSource.onmessage = e => {
if (!isNaN(percent)) { if (!isNaN(percent)) {
updateProgress(percent); updateProgress(percent);
window.electronAPI.setProgress(percent); // update barre des tâches window.electronAPI.setProgress(percent);
if (percent >= 100) setTimeout(() => {
resetProgress();
window.electronAPI.setProgress(-1); // retire la barre
}, 500);
} }
}; };
/**
* SSE stream: download speed updates (e.g. "5.2 MiB/s").
*/
speedEvt.onmessage = e => { speedEvt.onmessage = e => {
speedElement.style.display = "block"; speedElement.style.display = "block";
speedElement.textContent = e.data; // ex: "5.2MiB/s" speedElement.textContent = e.data;
};
/**
* SSE stream: download stage updates (e.g. downloading, merging, extracting).
*/
stageEvt.onmessage = e => {
stageElement.style.display = "block";
stageElement.textContent = e.data;
};
/**
* SSE stream: playlist progress updates (e.g. "Item 1 of 15").
*/
playlistInfoEvt.onmessage = e => {
playlistInfoElement.style.display = "block";
playlistInfoElement.textContent = e.data;
}; };

View File

@@ -1,50 +1,105 @@
/**
* Settings panel DOM elements used to control application feature flags
* and configuration UI interactions.
*/
const settingsPanel = document.getElementById("settings-panel"); const settingsPanel = document.getElementById("settings-panel");
const settingsModal = document.querySelector(".settings-modal"); const settingsModal = document.querySelector(".settings-modal");
const settingsButton = document.getElementById("settings-button"); const settingsButton = document.getElementById("settings-button");
/**
* Tracks current settings panel state.
* NOTE: actual visibility is derived from DOM style in current implementation.
*/
let isOpen = false; let isOpen = false;
/**
* Toggles visibility of the settings panel when clicking the settings button.
*/
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {
const isOpen = settingsPanel.style.display === "block"; const currentlyOpen = settingsPanel.style.display === "block";
settingsPanel.style.display = isOpen ? "none" : "block"; settingsPanel.style.display = currentlyOpen ? "none" : "block";
}); });
/**
* Closes the settings panel and resets internal state.
*/
function closePanel() { function closePanel() {
isOpen = false; isOpen = false;
settingsPanel.style.display = "none"; settingsPanel.style.display = "none";
} }
// charge les features /**
* Loads feature flags from Electron backend and syncs them
* with corresponding UI controls (checkboxes, selects).
*
* Also registers change listeners to persist updates in real time.
*/
async function loadSettings() { async function loadSettings() {
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
document.querySelectorAll(".settings-section input, .settings-section select").forEach(el => { document
.querySelectorAll(".settings-section input, .settings-section select")
.forEach((el) => {
const key = el.dataset.key; const key = el.dataset.key;
if (!key) return;
if (features[key] !== undefined) { if (features[key] !== undefined) {
if (el.type === "checkbox") el.checked = features[key]; if (el.type === "checkbox") el.checked = features[key];
else if (el.tagName === "SELECT") el.value = features[key]; else if (el.tagName === "SELECT") el.value = features[key];
} }
el.addEventListener("change", () => { el.addEventListener("change", () => {
let value = el.type === "checkbox" ? el.checked : el.value; if (key === "theme") return;
const value = el.type === "checkbox" ? el.checked : el.value;
window.electronAPI.setFeature(key, value); window.electronAPI.setFeature(key, value);
}); });
}); });
} }
// ouvrir le JSON /**
document.getElementById("open-json-btn").addEventListener("click", () => { * Opens configuration JSON file via Electron topbar API.
window.topbarAPI.openConfig(); // ton IPC existant */
document.getElementById("open-json-btn")
?.addEventListener("click", () => {
window.topbarAPI.openConfig();
}); });
/**
* Opens theme directory via Electron topbar API.
*/
document.getElementById("open-theme")
?.addEventListener("click", () => {
window.topbarAPI.openTheme();
});
/* clic sur l'overlay = fermer */ /**
settingsPanel.addEventListener("click", closePanel); * Refreshes theme list and updates UI selector with a short animation.
*/
document.getElementById("refresh-themes-btn")
?.addEventListener("click", function () {
this.classList.add("spinning");
window.refreshThemes();
/* clic dans la modal = stop propagation */ setTimeout(() => {
settingsModal.addEventListener("click", (e) => { this.classList.remove("spinning");
}, 600);
});
/**
* Clicking the overlay outside the modal closes the settings panel.
*/
settingsPanel?.addEventListener("click", closePanel);
/**
* Prevents modal clicks from closing the settings panel
* by stopping event propagation to the overlay.
*/
settingsModal?.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
}); });
/**
* Initializes settings UI by loading backend feature flags.
*/
loadSettings(); loadSettings();

83
public/script/toast.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* Displays a toast notification in the global UI container.
*
* Toasts are automatically appended to `#toast-container`,
* support multiple visual types, and can auto-dismiss after a delay.
*
* @param {string} message - Message displayed inside the toast
* @param {"success"|"error"|"warning"|"info"} [type="info"] - Visual style of the toast
* @param {number} [duration=4000] - Auto-dismiss delay in milliseconds (0 disables auto removal)
* @returns {HTMLElement|null} The created toast element or null if container is missing
*/
window.showToast = function (message, type = "info", duration = 4000) {
const container = document.getElementById("toast-container");
if (!container) return null;
const toast = document.createElement("div");
toast.classList.add("toast", type);
const icons = {
success: "✓",
error: "✕",
warning: "⚠",
info: ""
};
const icon = icons[type] || icons.info;
toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-message">${message}</span>
<button class="toast-close" title="Close">×</button>
`;
const closeBtn = toast.querySelector(".toast-close");
closeBtn.addEventListener("click", () => removeToast(toast));
container.appendChild(toast);
if (duration > 0) {
setTimeout(() => removeToast(toast), duration);
}
return toast;
};
/**
* Removes a toast element with a small exit animation.
*
* @param {HTMLElement} toast
*/
function removeToast(toast) {
if (!toast?.parentElement) return;
toast.classList.add("removing");
setTimeout(() => {
toast.remove();
}, 300);
}
/**
* Shortcut: success toast
*/
window.showSuccess = (message, duration = 4000) =>
window.showToast(message, "success", duration);
/**
* Shortcut: error toast
*/
window.showError = (message, duration = 5000) =>
window.showToast(message, "error", duration);
/**
* Shortcut: warning toast
*/
window.showWarning = (message, duration = 4000) =>
window.showToast(message, "warning", duration);
/**
* Shortcut: info toast
*/
window.showInfo = (message, duration = 4000) =>
window.showToast(message, "info", duration);

View File

@@ -1,16 +1,22 @@
// Ajout des listeners pour la topbar /**
* Initializes topbar button event listeners and binds them
* to the Electron preload API exposed in `window.topbarAPI`.
*
* This function is safe to call once DOM is ready.
* It silently aborts if the API or elements are missing.
*/
function setupTopbarListeners() { function setupTopbarListeners() {
document.addEventListener('DOMContentLoaded', () => {
const { topbarAPI } = window; const { topbarAPI } = window;
if (!topbarAPI) return; if (!topbarAPI) return;
const minBtn = document.getElementById('minimize-btn');
const maxBtn = document.getElementById('maximize-btn'); const minBtn = document.getElementById("minimize-btn");
const closeBtn = document.getElementById('close-btn'); const maxBtn = document.getElementById("maximize-btn");
const devtoolsBtn = document.getElementById('devtools-btn'); const closeBtn = document.getElementById("close-btn");
const logsBtn = document.getElementById('logs-btn'); const devtoolsBtn = document.getElementById("devtools-btn");
const websiteBtn = document.getElementById('website-btn'); const logsBtn = document.getElementById("logs-btn");
const wikiBtn = document.getElementById('wiki-btn'); const websiteBtn = document.getElementById("website-btn");
const configBtn = document.getElementById('config-btn'); const wikiBtn = document.getElementById("wiki-btn");
const workshopBtn = document.getElementById("workshop-btn");
if (minBtn) minBtn.onclick = () => topbarAPI.minimize(); if (minBtn) minBtn.onclick = () => topbarAPI.minimize();
if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize(); if (maxBtn) maxBtn.onclick = () => topbarAPI.maximize();
@@ -19,16 +25,33 @@ function setupTopbarListeners() {
if (logsBtn) logsBtn.onclick = () => topbarAPI.openLogs(); if (logsBtn) logsBtn.onclick = () => topbarAPI.openLogs();
if (websiteBtn) websiteBtn.onclick = () => topbarAPI.openWebsite(); if (websiteBtn) websiteBtn.onclick = () => topbarAPI.openWebsite();
if (wikiBtn) wikiBtn.onclick = () => topbarAPI.openWiki(); if (wikiBtn) wikiBtn.onclick = () => topbarAPI.openWiki();
if (configBtn) configBtn.onclick = () => topbarAPI.openConfig(); if (workshopBtn) workshopBtn.onclick = () => topbarAPI.openWorkshop();
});
} }
setupTopbarListeners(); // IF it put it the if check. It don't work. Why ? /**
* Registers topbar event listeners after DOM is fully loaded.
*/
document.addEventListener("DOMContentLoaded", setupTopbarListeners);
/**
* Applies UI layout adjustments depending on feature flags
* provided by the Electron backend.
*
* Specifically handles:
* - hiding custom topbar
* - adjusting layout spacing
* - repositioning theme switcher
*/
(async function applyFeatureLayout() {
const features = await window.electronAPI.getFeatures(); const features = await window.electronAPI.getFeatures();
if (!features.customTopBar) { if (!features.customTopBar) {
document.getElementById("topbar").style.display = "none"; const topbar = document.getElementById("topbar");
document.getElementById("container").style.marginTop = "0"; const container = document.getElementById("container");
document.getElementById("theme-switcher").style.top = "30px"; const themeSwitcher = document.getElementById("theme-switcher");
if (topbar) topbar.style.display = "none";
if (container) container.style.marginTop = "0";
if (themeSwitcher) themeSwitcher.style.top = "30px";
} }
})();

94
public/splash.html Normal file
View File

@@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
}
.splash-container {
width: 100%;
height: 100%;
background: #D9D9D9;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 28px;
font-family: "Segoe UI", sans-serif;
}
.progress-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.progress-track {
width: 180px;
height: 3px;
background: #d0d0d0;
border-radius: 99px;
overflow: hidden;
}
.progress-bar {
height: 100%;
width: 0%;
background: #1a1a1a;
border-radius: 99px;
transition: width 0.3s ease;
}
.status {
font-size: 14px;
color: #6d6d6d;
letter-spacing: 0.5px;
}
</style>
</head>
<body>
<div class="splash-container">
<img src="./banner.png" alt="Freedom Loader" style="width: 90%;">
<div class="progress-wrapper">
<div class="progress-track">
<div class="progress-bar" id="progress-bar"></div>
</div>
<span class="status" id="status">Starting...</span>
</div>
</div>
<script>
const steps = [
"Checking dependencies...",
"Starting server...",
"Loading themes...",
"Almost ready...",
];
let current = 0;
const bar = document.getElementById("progress-bar");
const status = document.getElementById("status");
function advance(label, percent) {
bar.style.width = percent + "%";
status.textContent = label;
}
window.setProgress = (step) => {
if (step >= steps.length) {
advance("", 100);
return;
}
const percent = Math.round(((step + 1) / steps.length) * 100);
advance(steps[step], percent);
};
window.setProgress(0);
</script>
</body>
</html>

View File

@@ -15,13 +15,13 @@
} }
.checkmark { .checkmark {
--clr: var(--checkbox-bg-checked); --clr: var(--checkbox-checked-bg-color);
position: relative; position: relative;
top: 0; top: 0;
left: 0; left: 0;
height: 1.3em; height: 1.3em;
width: 1.3em; width: 1.3em;
background-color: var(--checkbox-bg-default); background-color: var(--checkbox-unchecked-bg-color);
border-radius: .5rem; border-radius: .5rem;
transition: background-color 300ms, border-radius 300ms; transition: background-color 300ms, border-radius 300ms;
} }

View File

@@ -17,9 +17,11 @@
border-radius: 6px; border-radius: 6px;
font-family: monospace; font-family: monospace;
border: 1px dashed var(--form-input-border-color); border: 1px dashed var(--form-input-border-color);
word-break: break-all; display: inline-flex;
display: inline-block; max-width: clamp(200px, 60vw, 600px);
max-width: 80%; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }

View File

@@ -4,9 +4,9 @@
gap: 10px; gap: 10px;
padding: 10px 20px; padding: 10px 20px;
background: var(--form-bg-color); background: var(--form-bg-color);
border: 2px solid var(--mp3-text-color); border: 2px solid var(--audio-only-label-color);
border-radius: 8px; border-radius: 8px;
color: var(--mp3-text-color); color: var(--audio-only-label-color);
font-weight: bold; font-weight: bold;
animation: pulse 1.2s infinite; animation: pulse 1.2s infinite;
} }

View File

@@ -1,22 +1,34 @@
/* Container pour isoler la progress bar */
.progress-container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 0;
margin-top: 20px;
}
/* Wrapper de la barre de téléchargement */ /* Wrapper de la barre de téléchargement */
#downloadProgressWrapper { #downloadProgressWrapper {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
height: 12px; height: 12px;
background-color: #444; background-color: var(--progress-bar-bg-color, #cccccc);
border-radius: 6px; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin: 10px auto; margin: 0;
display: none; display: none;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5); box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
box-sizing: border-box;
} }
/* Barre de progression */ /* Barre de progression */
#downloadProgress { #downloadProgress {
height: 100%; height: 100%;
width: 0%; width: 0%;
background-color: var(--video-info-link-color, #4caf50); background-color: var(--progress-bar-fill-color, #007bff);
border-radius: 6px 0 0 6px; border-radius: 10px;
transition: width 0.2s ease; transition: width 0.2s ease;
} }
@@ -25,7 +37,7 @@
font-size: 0.8rem; font-size: 0.8rem;
color: #fff; color: #fff;
content: ""; content: "";
display: none;
} }
/* Responsive pour mobile */ /* Responsive pour mobile */
@@ -39,3 +51,39 @@
font-size: 0.7rem; font-size: 0.7rem;
} }
} }
/* Stop Button */
.stop-btn {
width: 40px;
height: 40px;
min-width: 40px;
padding: 0;
border: none;
border-radius: 8px;
background-color: var(--form-button-bg-color);
color: var(--form-button-text-color);
font-size: 20px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.stop-btn:hover:not(:disabled) {
background-color: var(--form-button-bg-hover-color);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
transform: scale(1.05);
}
.stop-btn:active:not(:disabled) {
transform: scale(0.96);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.stop-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,136 @@
/* Toast Container */
#toast-container {
position: fixed;
top: 140px;
right: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 9999;
pointer-events: none;
}
/* Toast */
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background-color: rgba(50, 50, 50, 0.9);
border-radius: 6px;
color: #ffffff;
font-size: 0.9rem;
max-width: 220px;
min-width: 100px;
animation: slideIn 0.25s ease-out;
pointer-events: auto;
backdrop-filter: blur(8px);
}
.toast.success {
background-color: rgba(40, 167, 69, 0.15);
}
.toast.error {
background-color: rgba(220, 53, 69, 0.15);
}
.toast.warning {
background-color: rgba(255, 193, 7, 0.15);
}
.toast.info {
background-color: rgba(23, 162, 184, 0.15);
}
/* Toast Icon */
.toast-icon {
flex-shrink: 0;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.toast.success .toast-icon {
color: #28a745;
}
.toast.error .toast-icon {
color: #dc3545;
}
.toast.warning .toast-icon {
color: #ffc107;
}
.toast.info .toast-icon {
color: #17a2b8;
}
/* Toast Message */
.toast-message {
flex: 1;
font-weight: 500;
}
/* Toast Close Button */
.toast-close {
flex-shrink: 0;
background: none;
border: none;
color: currentColor;
cursor: pointer;
font-size: 18px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
transition: opacity 0.2s ease;
}
.toast-close:hover {
opacity: 1;
}
/* Animations */
@keyframes slideIn {
from {
transform: translateX(350px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(350px);
opacity: 0;
}
}
.toast.removing {
animation: slideOut 0.25s ease-out;
}
/* Responsive */
@media (max-width: 480px) {
#toast-container {
bottom: 12px;
right: 12px;
left: 12px;
}
.toast {
max-width: none;
min-width: auto;
}
}

View File

@@ -4,7 +4,6 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 1em; padding: 1em;
z-index: 1000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@@ -40,8 +40,7 @@ form button:hover {
form#downloadForm { form#downloadForm {
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
max-width: 600px; width: 350px;
width: 100%;
justify-content: center; justify-content: center;
} }
@@ -50,7 +49,7 @@ form#downloadForm {
align-items: center; align-items: center;
align-self: center; align-self: center;
gap: 0.5em; gap: 0.5em;
color: var(--form-input-text-color); color: var(--audio-only-label-color);
} }
#UrlInput { #UrlInput {
@@ -62,6 +61,7 @@ form#downloadForm {
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
background-color: var(--form-input-bg-color); background-color: var(--form-input-bg-color);
color: var(--form-input-text-color); color: var(--form-input-text-color);
flex: 1;
} }
#UrlInput:focus { #UrlInput:focus {
@@ -74,3 +74,40 @@ form#downloadForm {
#UrlInput::placeholder { #UrlInput::placeholder {
color: var(--form-input-placeholder-color); color: var(--form-input-placeholder-color);
} }
.url-input-container {
display: flex;
align-items: center;
gap: 0.5em;
width: 100%;
}
.paste-btn {
background-color: var(--form-button-bg-color);
color: var(--form-button-text-color);
border: none;
padding: 0.75em;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 44px;
}
.paste-btn:hover {
background-color: var(--form-button-bg-hover-color);
transform: scale(1.05);
}
.paste-btn:active {
transform: scale(0.95);
}
.paste-btn svg {
color: var(--paste-button-icon-color);
width: 30px;
height: 30px;
}

View File

@@ -1,11 +1,13 @@
header { header {
position: fixed;
top: 0;
width: 100%;
height: 48px;
z-index: 1100;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
margin-bottom: 2vw;
} }
#title {
header h1{ color: var(--default-text-color);
margin-bottom: 0.2vw;
} }

View File

@@ -7,6 +7,7 @@
top: 60px; top: 60px;
right: 20px; right: 20px;
opacity: 0.8; opacity: 0.8;
z-index: 15;
background-color: var(--settings-button-bg-color); background-color: var(--settings-button-bg-color);
border-radius: 14px; border-radius: 14px;
padding: 8px; padding: 8px;
@@ -40,7 +41,7 @@
width: 40vw; width: 40vw;
max-width: 320px; max-width: 320px;
max-height: 80vh; max-height: 80vh;
background-color: var(--settings-bg-primary-color); background-color: var(--settings-modal-bg-color);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4); box-shadow: 0 8px 24px rgba(0,0,0,0.4);
@@ -89,7 +90,7 @@
flex-direction: column; flex-direction: column;
margin-bottom: 20px; margin-bottom: 20px;
padding: 10px 15px; padding: 10px 15px;
background: var(--settings-bg-secondary-color); background: var(--settings-section-bg-color);
border-radius: 12px; border-radius: 12px;
gap: 6px; gap: 6px;
} }
@@ -190,8 +191,18 @@ details summary {
border: none; border: none;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
background-color: var(--form-button-bg-color); background-color: var(--open-json-button-bg-color);
color: #fff; color: var(--open-json-button-text-color);
}
#open-theme {
padding: 10px 14px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 600;
background-color: var(--open-theme-button-bg-color);
color: var(--open-theme-button-text-color);
} }
#experimental-args { #experimental-args {
@@ -222,6 +233,33 @@ details summary {
opacity: 1; opacity: 1;
} }
/* REFRESH THEMES BUTTON */
.refresh-themes-btn {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--form-input-border-color);
background-color: var(--form-input-bg-color);
color: var(--form-input-text-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.refresh-themes-btn:hover {
background-color: var(--form-button-bg-hover-color);
}
.refresh-themes-btn.spinning svg {
animation: spin 0.6s ease-in-out;
}
.refresh-themes-btn svg {
color: currentColor;
transition: transform 0.6s ease-in-out;
}
/* ANIMATION */ /* ANIMATION */
@keyframes popIn { @keyframes popIn {
from { transform: scale(0.95); opacity: 0; } from { transform: scale(0.95); opacity: 0; }
@@ -232,3 +270,8 @@ details summary {
from { transform: translateX(20px); opacity: 0; } from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; } to { transform: translateX(0); opacity: 1; }
} }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -2,20 +2,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
position: fixed;
top: 0;
z-index: 1100;
height: 48px;
width: 100%; width: 100%;
height: 100%;
background: #1f1f1f98; background: #1f1f1f98;
color: #eaeaea; color: #eaeaea;
-webkit-app-region: drag; -webkit-app-region: drag;
user-select: none; user-select: none;
} }
/* LEFT - custom buttons */ /* LEFT - custom buttons */

View File

@@ -4,7 +4,7 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
padding: 1.5em 2em; padding: 1.5em 2em;
background-color: var(--infos-box-color); background-color: var(--video-info-bg-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 16px rgba(0,0,0,0.1); box-shadow: 0 8px 16px rgba(0,0,0,0.1);
color: var(--video-info-text-color); color: var(--video-info-text-color);
@@ -69,80 +69,293 @@
/* --- Playlist Mode --- */ /* --- Playlist Mode --- */
#videoInfo.playlist-mode { #videoInfo.playlist-mode {
max-width: 900px; max-width: 95%;
} }
/* Style du bloc "Playlist détectée" */ /* Playlist Header */
#videoInfo.playlist-mode > p { .playlist-header {
text-align: center; display: flex;
font-size: 1.1rem; flex-direction: column;
padding: 2rem;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border-radius: 16px;
margin-bottom: 2rem;
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(10px);
}
.playlist-badge {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 0.4rem 0.9rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600; font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--video-info-text-color);
margin-bottom: 1rem;
}
.playlist-info {
flex: 1;
}
.playlist-title {
font-size: 1.9rem;
margin: 0 0 0.6rem 0;
color: var(--video-info-heading-color);
font-weight: 700;
line-height: 1.2;
}
.playlist-meta {
display: flex;
gap: 1.5rem;
font-size: 0.95rem;
color: var(--video-info-text-color);
opacity: 0.85;
flex-wrap: wrap;
}
.playlist-count,
.playlist-channel {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.8rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
font-weight: 500;
}
.playlist-count svg,
.playlist-channel svg {
width: 16px;
height: 16px;
flex-shrink: 0;
} }
/* Layout liste des vidéos */ /* Layout liste des vidéos */
#playlistVideos { #playlistVideos {
margin-top: 1em; margin-top: 1em;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px; gap: 1.5rem;
} }
/* Un item de playlist */ /* Carte vidéo playlist */
#playlistVideos > div { .playlist-video-card {
background: var(--playlist-background-color); background: var(--playlist-background-color);
border-radius: 10px; border-radius: 14px;
padding: 10px; overflow: hidden;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); box-shadow: 0 2px 12px rgba(0,0,0,0.12);
text-align: center; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: transform 0.2s ease;
}
#playlistVideos > div p {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center; border: 1px solid rgba(255, 255, 255, 0.1);
flex-wrap: wrap; position: relative;
} }
#videoInfo.playlist-mode { .playlist-video-card::before {
max-width: 95%; content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--video-info-link-color), transparent);
opacity: 0;
transition: opacity 0.25s ease;
} }
#playlistVideos > div:hover { .playlist-video-card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 32px rgba(0,0,0,0.25);
border-color: rgba(255, 255, 255, 0.15);
}
.playlist-video-card:hover::before {
opacity: 1;
}
/* Thumbnail wrapper avec durée */
.video-thumbnail-wrapper {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
background: #000;
}
.video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.3s ease;
}
.playlist-video-card:hover .video-thumbnail {
transform: scale(1.05); transform: scale(1.05);
} }
#playlistVideos img { .video-duration {
width: 100%; position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 700;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace;
letter-spacing: 0.5px;
backdrop-filter: blur(4px);
}
/* Contenu vidéo */
.video-content {
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
flex: 1;
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.02));
}
.video-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--video-info-heading-color);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 2.8em;
transition: color 0.2s ease;
}
.playlist-video-card:hover .video-title {
color: var(--video-info-link-color);
}
.video-uploader {
font-size: 0.9rem;
color: var(--video-info-text-color);
opacity: 0.7;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Actions vidéo */
.video-actions {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: auto;
padding-top: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.video-link {
flex: 1;
text-decoration: none;
color: var(--video-info-link-color);
font-size: 0.9rem;
padding: 0.5rem 0.9rem;
border-radius: 8px; border-radius: 8px;
margin-bottom: 8px; background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
transition: all 0.2s ease;
text-align: center;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.video-link svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.video-link:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
text-decoration: none;
transform: translateY(-1px);
} }
.copy-btn { .copy-btn {
all: unset; background: rgba(255, 255, 255, 0.06);
background: transparent; border: 1px solid rgba(255, 255, 255, 0.08);
border: none;
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 4px; padding: 0.5rem;
vertical-align: middle; border-radius: 8px;
transition: transform 0.1s ease, color 0.1s ease, opacity 0.15s ease; transition: all 0.2s ease;
font-size: 1em; width: 40px;
height: 40px;
flex-shrink: 0;
} }
#playlistVideos > div p a{ .copy-btn svg {
text-decoration: none; width: 18px;
color: var(--video-info-link-color); height: 18px;
color: var(--video-info-text-color);
} }
#playlistVideos > div p a:hover{ .copy-btn:hover {
text-decoration: underline; background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-1px);
}
.copy-btn:active {
transform: scale(0.95);
}
.copy-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
} }
/* Responsive */ /* Responsive */
@media (max-width: 768px) {
#playlistVideos {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 1rem;
}
.playlist-header {
padding: 1.5rem;
}
.playlist-title {
font-size: 1.5rem;
}
.playlist-meta {
gap: 0.8rem;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
#videoInfo { #videoInfo {
padding: 1em; padding: 1em;
@@ -152,4 +365,21 @@
#videoInfo h3 { #videoInfo h3 {
font-size: 1.4rem; font-size: 1.4rem;
} }
#playlistVideos {
grid-template-columns: 1fr;
}
.playlist-header {
padding: 1.2rem;
}
.playlist-title {
font-size: 1.3rem;
}
.playlist-badge {
font-size: 0.75rem;
padding: 0.4rem 0.8rem;
}
} }

View File

@@ -1,29 +1,41 @@
/* Layout */ /* Layout */
@import url("layout/form.css"); @import url("layout/topbar.css");
@import url("layout/settingsPanel.css");
@import url("layout/container.css");
@import url("layout/header.css"); @import url("layout/header.css");
@import url("layout/form.css");
@import url("layout/container.css");
@import url("layout/settingsPanel.css");
@import url("layout/videoinfo.css"); @import url("layout/videoinfo.css");
/* Components */ /* Components */
@import url("components/checkbox.css"); @import url("components/checkbox.css");
@import url("components/loader.css"); @import url("components/loader.css");
@import url("theme/themeimport.css");
@import url("components/editpathbutton.css"); @import url("components/editpathbutton.css");
@import url("components/progressBar.css"); @import url("components/progressBar.css");
@import url("components/toast.css");
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
#app {
display: block; /* Mark element as disabled */
opacity: 0; .disabled {
transition: opacity 0.6s ease; pointer-events: none;
opacity: 0.5;
filter: grayscale(40%);
cursor: not-allowed;
} }
#app.visible {
opacity: 1; /* Because why not */
#reference{
position: fixed;
pointer-events: none;
bottom:0vh;
left: 10px;
font-size: 1em;
color: #b0b0b02f;
z-index: 9999;
} }
html,body{ html,body{
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -35,42 +47,44 @@ html,body{
} }
body{ body{
margin: 0;
background-color: var(--background-color); background-color: var(--background-color);
width: 100%;
height: 100%;
color: var(--default-text-color); color: var(--default-text-color);
display: flex; }
justify-content: center;
align-items: center; header {
position: fixed;
top: 0;
width: 100%;
z-index: 1100;
height: 48px;
}
main {
width: 100%;
height: calc(100vh - 48px);
margin-top: 48px;
overflow-y: auto;
overflow-x: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start;
align-items: center; align-items: center;
padding: 0;
gap: 0;
} }
/* Because why not */ /* Scrollbar pour main */
#reference{ main::-webkit-scrollbar {
position: fixed;
pointer-events: none;
bottom:0vh;
left: 10px;
font-size: 1em;
color: #b0b0b02f;
}
/* Scrollbar globale pour Chromium/Electron */
body::-webkit-scrollbar {
width: 10px; width: 10px;
height: 10px; height: 10px;
} }
body::-webkit-scrollbar-track { main::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border-radius: 5px; border-radius: 5px;
} }
body::-webkit-scrollbar-thumb { main::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
border-radius: 5px; border-radius: 5px;
border: 2px solid transparent; border: 2px solid transparent;
@@ -78,36 +92,32 @@ body::-webkit-scrollbar-thumb {
transition: background-color 0.2s ease, transform 0.2s ease; transition: background-color 0.2s ease, transform 0.2s ease;
} }
body::-webkit-scrollbar-thumb:hover { main::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.35); background-color: rgba(255, 255, 255, 0.35);
transform: scaleX(1.05); transform: scaleX(1.05);
} }
/* Thème clair */ /* Thème clair */
body.light::-webkit-scrollbar-track { body.light main::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
body.light::-webkit-scrollbar-thumb { body.light main::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
} }
body.light::-webkit-scrollbar-thumb:hover { body.light main::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.45); background-color: rgba(0, 0, 0, 0.45);
} }
.container{ .container{
margin-top: 50px; margin-top: 0px;
} }
header p{ #subtitle{
color: var(--subtitle-color); color: var(--subtitle-color);
} }
.checkbox-inline{
color: var(--mp3-text-color);
}
body, body,
.container, .container,
input, input,

View File

@@ -1,103 +0,0 @@
body.chirac {
background-image: url('../../assets/images/Chirac.png');
background-repeat: no-repeat;
background-size: cover;
background-attachment: fixed;
--background-color: rgba(255, 255, 255, 0.85);
--default-text-color: #002395;
--subtitle-color: #EF4135;
--mp3-text-color: #002395;
/* Formulaire */
--form-bg-color: rgba(255, 255, 255, 0.9);
--form-input-bg-color: #f5f5f5;
--form-input-border-color: #002395;
--form-input-border-focus-color: #EF4135;
--form-input-text-color: #002395;
--form-input-placeholder-color: #999999;
--form-button-bg-color: #002395;
--form-button-text-color: #ffffff;
--form-button-bg-hover-color: #EF4135;
/* Checkbox */
--checkbox-bg-default: #cccccc;
--checkbox-bg-checked: #002395;
--checkbox-checkmark-border-color: #ffffff;
--checkbox-pulse-color: rgba(0, 35, 149, 0.5);
/* Download status */
--download-status-color: #002395;
/* Video Info Box */
--infos-box-color: rgba(255, 255, 255, 0.9);
--video-info-text-color: #000000;
--video-info-heading-color: #EF4135;
--video-info-list-color: #444444;
--video-info-list-strong-color: #000000;
--video-info-link-color: #002395;
--video-info-link-hover-color: #EF4135;
/* Playlist Color */
--playlist-background-color: #d1d1d1ca;
/* Settings */
--settings-button-bg-color: #00196a;
--settings-button-text-color: #dcdcdc;
--settings-bg-primary-color: #0b0528;
--settings-bg-secondary-color: #1c1055;
--settings-text-color: #cecece;
--settings-subtitle-color: #adadad;
}
/*
Je serai le président de tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Tous les Français
Mes chers compatriotes
Je serai le président de tous les Français, de tous les Français
Je mesure la difficulté de la tâche qui nous attend
Tous les Français
Tous les Français
Je veux un État, je veux les Français
Je veux un État, je veux
Je veux un État-tat-tat-tat vigoureux
Un État-tat-tat-tat impartial
Un État-tat-tat-tat vigoureux
Un État-tat-tat-tat exigeant
Un État-tat-tat-tat
Je serai le président de tous les Français
Tous les Français
Tous les Français
Tous les Français
Un État qui n'isole pas, tous, pas, pas
Tous, pas, tous, pas, pas
Tous, pas, tous, pas, pas (les Français)
Tous, tous, pas, tous, tous, pas, pas
Tous, tous, pas, tous, tous, pas, pas
Tous, tous, tous, pas, tous, pas, pas (les Français)
Tous, pas, tous, tous, pas, pas
Tous, tous, tous, pas, tous, tous, pas, pas
Tous, tous, pas, tous, tous, pas, pas
Tous, tous, tous-tous, tous-tous, pas, pas
Tous, tous-tous, tous-tous, pas, pas
Un État-tat-tat-tat vigoureux
Un État-tat-tat-tat impartial
Un État-tat-tat-tat exigeant
Un État-tat-tat-tat
Je serai le président de tous les Français
*/

View File

@@ -1,49 +0,0 @@
body.dark {
/* Couleurs générales */
--background-color: #121212; /* noir/gris très foncé */
--default-text-color: #e0e0e0; /* texte clair */
--subtitle-color: #2196f3; /* bleu accent */
--mp3-text-color: #f5f5f5; /* texte checkbox */
/* Formulaire */
--form-bg-color: #1e1e1e; /* fond formulaire */
--form-input-bg-color: #2a2a2a; /* fond inputs */
--form-input-border-color: #444444; /* bordure */
--form-input-border-focus-color: #2196f3; /* focus bleu */
--form-input-text-color: #f5f5f5; /* texte clair */
--form-input-placeholder-color: #888888; /* placeholder gris */
--form-button-bg-color: #2196f3; /* bouton bleu */
--form-button-text-color: #ffffff; /* texte bouton */
--form-button-bg-hover-color: #1976d2; /* hover bleu plus foncé */
/* Checkbox */
--checkbox-bg-default: #555555; /* gris moyen */
--checkbox-bg-checked: #2196f3; /* bleu accent */
--checkbox-checkmark-border-color: #ffffff; /* blanc */
--checkbox-pulse-color: rgba(33, 150, 243, 0.5);
/* Download status */
--download-status-color: #2196f3;
/* Video Info Box */
--infos-box-color: #1a1a1a;
--video-info-text-color: #e0e0e0;
--video-info-heading-color: #2196f3;
--video-info-list-color: #cccccc;
--video-info-list-strong-color: #ffffff;
--video-info-link-color: #2196f3;
--video-info-link-hover-color: #1976d2;
/* Playlist Color */
--playlist-background-color: #303030;
/* Settings */
--settings-button-bg-color: #1e1e1e;
--settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: #1e1e1e;
--settings-bg-secondary-color: #2a2a2a;
--settings-text-color: var(--default-text-color);
--settings-subtitle-color: #aaa;
}

View File

@@ -1,61 +0,0 @@
body.drift {
background-image: url('../../assets/images/Drift.jpg');
background-size: cover;
background-position: center;
background-attachment: fixed;
/* Palette principale */
--background-color: #0f1218;
--default-text-color: #3d4b61;
--subtitle-color: #1a1a1a;
--mp3-text-color: #c0d4ff;
/* Formulaire */
--form-bg-color: #1e222a;
--form-input-bg-color: #13171d;
--form-input-border-color: #3b4b6d;
--form-input-border-focus-color: #6ea8ff;
--form-input-text-color: #f0f4ff;
--form-input-placeholder-color: #7a8fae;
--form-button-bg-color: #2a2979;
--form-button-text-color: #ffffff;
--form-button-bg-hover-color: #353393;
/* Checkbox */
--checkbox-bg-default: #2a2e38;
--checkbox-bg-checked: #2a2979;
--checkbox-checkmark-border-color: #ffffff;
--checkbox-pulse-color: rgba(76,108,255,0.3);
/* Download status */
--download-status-color: #ff8e8e;
/* Video Info Box */
--infos-box-color: #1e222ad0;
--video-info-text-color: #e8e8f0;
--video-info-heading-color: #9ab6ff;
--video-info-list-color: #c0d4ff;
--video-info-list-strong-color: #ffffff;
--video-info-link-color: #4c6cff;
--video-info-link-hover-color: #6c8dff;
/* Playlist Color */
--playlist-background-color: #424b5cc2;
/* Settings */
--settings-button-bg-color: #1e222ace;
--settings-button-text-color: #b3b3b4;
--settings-bg-primary-color: #1e222a;
--settings-bg-secondary-color: #272d38;
--settings-text-color: #bababa;
--settings-subtitle-color: #cacaca;
}
body.drift .download-path{
color: #ededed;
}

View File

@@ -1,49 +0,0 @@
body.fanatic{
/* Couleurs générales */
--background-color: #121212;
--default-text-color: #eee;
--subtitle-color: #FF5900;
--mp3-text-color: #ffffff;
/* Formulaire */
--form-bg-color: #1e1e1e;
--form-input-bg-color: #2b2b2b;
--form-input-border-color: #333333;
--form-input-border-focus-color: #FF5900;
--form-input-text-color: #eeeeee;
--form-input-placeholder-color: #666666;
--form-button-bg-color: #FF5900;
--form-button-text-color: #121212;
--form-button-bg-hover-color: #e65500;
/* Checkbox */
--checkbox-bg-default: #555555;
--checkbox-bg-checked: #ff6600;
--checkbox-checkmark-border-color: #eee;
--checkbox-pulse-color: rgba(255, 102, 0, 0.5);
/* Download status */
--download-status-color: #FF5900;
/* Video Info Box */
--infos-box-color: #222222;
--video-info-text-color: #eee;
--video-info-heading-color: #FF5900;
--video-info-list-color: #ccc;
--video-info-list-strong-color: #fff;
--video-info-link-color: #FF5900;
--video-info-link-hover-color: #e65500;
/* Playlist Color */
--playlist-background-color: #2b2b2b;
/* Settings */
--settings-button-bg-color: #1e1e1e;
--settings-button-text-color: #dcdcdc;
--settings-bg-primary-color: #1e1e1e;
--settings-bg-secondary-color: #2d2d2d;
--settings-text-color: #cecece;
--settings-subtitle-color: #adadad;
}

View File

@@ -1,48 +0,0 @@
body.light {
/* Couleurs générales */
--background-color: #dadada;
--default-text-color: #222222;
--subtitle-color: #007bff;
--mp3-text-color: #000000;
/* Formulaire */
--form-bg-color: #ffffff;
--form-input-bg-color: #fafafa;
--form-input-border-color: #cccccc;
--form-input-border-focus-color: #007bff;
--form-input-text-color: #333333;
--form-input-placeholder-color: #888888;
--form-button-bg-color: #007bff;
--form-button-text-color: #ffffff;
--form-button-bg-hover-color: #0056b3;
/* Checkbox */
--checkbox-bg-default: #cccccc;
--checkbox-bg-checked: #007bff;
--checkbox-checkmark-border-color: #ffffff;
--checkbox-pulse-color: rgba(0, 123, 255, 0.4);
/* Download status */
--download-status-color: #007bff;
/* Video Info Box */
--infos-box-color: #ffffff;
--video-info-text-color: #222222;
--video-info-heading-color: #007bff;
--video-info-list-color: #444444;
--video-info-list-strong-color: #000000;
--video-info-link-color: #007bff;
--video-info-link-hover-color: #0056b3;
/* Playlist Color */
--playlist-background-color: #c5c5c5b2;
/* Settings */
--settings-button-bg-color: #e4e4e4;
--settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: #cbcbcb;
--settings-bg-secondary-color: #cbcbcb;
--settings-text-color: var(--default-text-color);
--settings-subtitle-color: #656565;
}

View File

@@ -1,59 +0,0 @@
body.neon {
background: (url("../../assets/images/drift.jpg"));
background-image: linear-gradient(135deg, #0f0c29, #302b63, #24243e); /* dégradé sombre violet/bleu */
background-size: cover;
background-position: center;
background-attachment: fixed;
/* Couleurs générales */
--background-color: #1b1b2f;
--default-text-color: #e0e0ff;
--subtitle-color: #a3a3ff;
--mp3-text-color: #c0c0ff;
/* Formulaire */
--form-bg-color: #2c2c4a;
--form-input-bg-color: #1f1f3a;
--form-input-border-color: #5858f0;
--form-input-border-focus-color: #a3a3ff;
--form-input-text-color: #ffffff;
--form-input-placeholder-color: #8888ff;
--form-button-bg-color: #5858f0;
--form-button-text-color: #ffffff;
--form-button-bg-hover-color: #7a7aff;
/* Checkbox */
--checkbox-bg-default: #302b63;
--checkbox-bg-checked: #5858f0;
--checkbox-checkmark-border-color: #ffffff;
--checkbox-pulse-color: rgba(88,88,240,0.3);
/* Download status */
--download-status-color: #a3a3ff;
/* Video Info Box */
--infos-box-color: #2c2c4a;
--video-info-text-color: #e0e0ff;
--video-info-heading-color: #a3a3ff;
--video-info-list-color: #c0c0ff;
--video-info-list-strong-color: #ffffff;
--video-info-link-color: #5858f0;
--video-info-link-hover-color: #7a7aff;
/* Playlist Color */
--playlist-background-color: #3e3e64;
/* Settings */
--settings-button-bg-color: #202038;
--settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: #242454;
--settings-bg-secondary-color: #2f2f6f;
--settings-text-color: var(--default-text-color);
--settings-subtitle-color: #d2d2d2;
}
body.neon .download-path {
color: var(--default-text-color);
}

View File

@@ -1,58 +0,0 @@
body.nf {
background-image: url('../../assets/images/NF.jpg');
background-size: cover;
background-position: 70% center;
background-attachment: fixed;
/* Couleurs générales */
--background-color: #1a1a1a;
--default-text-color: #e6dfd5;
--subtitle-color: #f5f0e6;
--mp3-text-color: #d8cfc2;
/* Formulaire */
--form-bg-color: #2a2a2a;
--form-input-bg-color: #222222;
--form-input-border-color: #4a4036;
--form-input-border-focus-color: #cbb89d;
--form-input-text-color: #f0e8dd;
--form-input-placeholder-color: #9a8f80;
--form-button-bg-color: #cbb89d;
--form-button-text-color: #1a1a1a;
--form-button-bg-hover-color: #b7a588;
/* Checkbox */
--checkbox-bg-default: #4a4036;
--checkbox-bg-checked: #cbb89d;
--checkbox-checkmark-border-color: #1a1a1a;
--checkbox-pulse-color: rgba(203,184,157,0.3);
/* Download status */
--download-status-color: #cbb89d;
/* Video Info Box */
--infos-box-color: #2a2a2a;
--video-info-text-color: #e6dfd5;
--video-info-heading-color: #f5f0e6;
--video-info-list-color: #d8cfc2;
--video-info-list-strong-color: #ffffff;
--video-info-link-color: #cbb89d;
--video-info-link-hover-color: #b7a588;
/* Playlist Color */
--playlist-background-color: #4f4f4f;
/* Settings */
--settings-button-bg-color: #2a2a2a;
--settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: #2a2a2a;
--settings-bg-secondary-color: #313131;
--settings-text-color: var(--default-text-color);
--settings-subtitle-color: #7a7979;
}
body.nf .download-path {
color: var(--default-text-color);
}

View File

@@ -1,58 +0,0 @@
body.songbird {
background-image: url('../../assets/images/Songbird.webp');
background-size: cover;
background-position: center center;
background-attachment: fixed;
/* Couleurs générales */
--background-color: #0a0a0a; /* noir profond */
--default-text-color: #ff2a2a; /* rouge néon */
--subtitle-color: #f2f2f2; /* rouge sombre */
--mp3-text-color: #ff4d4d; /* rouge un peu plus clair */
/* Formulaire */
--form-bg-color: rgba(20,0,0,0.85); /* noir semi-transparent avec rouge */
--form-input-bg-color: #1a0a0a;
--form-input-border-color: #ff2a2a;
--form-input-border-focus-color: #b30000;
--form-input-text-color: #ffffff;
--form-input-placeholder-color: #950404;
--form-button-bg-color: #b30000;
--form-button-text-color: #ffffff;
--form-button-bg-hover-color: #ff2a2a;
/* Checkbox */
--checkbox-bg-default: #630000;
--checkbox-bg-checked: #ff2a2a;
--checkbox-checkmark-border-color: #ffffff;
--checkbox-pulse-color: rgba(255,42,42,0.3);
/* Download status */
--download-status-color: #ff2a2a;
/* Video Info Box */
--infos-box-color: rgba(30,0,0,0.9);
--video-info-text-color: #ff4d4d;
--video-info-heading-color: #ff2a2a;
--video-info-list-color: #b30000;
--video-info-list-strong-color: #ffffff;
--video-info-link-color: #ff2a2a;
--video-info-link-hover-color: #b30000;
/* Playlist Color */
--playlist-background-color: #3f0000;
/* Settings */
--settings-button-bg-color: rgba(29, 0, 0, 0.784);
--settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: #121212;
--settings-bg-secondary-color: #1a1a1a;
--settings-text-color: var(--default-text-color);
--settings-subtitle-color: #7a7979;
}
body.cyberpunk .download-path {
color: var(--default-text-color);
}

View File

@@ -1,59 +0,0 @@
body.spicy {
/* Couleurs générales */
--background-color: #121212; /* fond noir profond */
--default-text-color: #ffffff; /* texte clair */
--subtitle-color: #f87918; /* accent rouge piment */
--mp3-text-color: #ffffff; /* texte checkbox inline */
/* Formulaire */
--form-bg-color: #1a1a1a; /* fond formulaire gris très foncé */
--form-input-bg-color: #2b2b2b; /* fond inputs gris foncé */
--form-input-border-color: #444444; /* bordure gris */
--form-input-border-focus-color: #ff1a1a; /* focus rouge piment */
--form-input-text-color: #eeeeee; /* texte inputs */
--form-input-placeholder-color: #888888; /* placeholder gris moyen */
--form-button-bg-color: #ff1a1a; /* bouton rouge vif */
--form-button-text-color: #ffffff; /* texte bouton blanc */
--form-button-bg-hover-color: #cc0000; /* hover rouge foncé */
/* Checkbox */
--checkbox-bg-default: #555555; /* gris moyen */
--checkbox-bg-checked: #ff1a1a; /* rouge piment */
--checkbox-checkmark-border-color: #f5f5f5; /* blanc cassé */
--checkbox-pulse-color: rgba(255, 26, 26, 0.5); /* pulse rouge translucide */
/* Download status */
--download-status-color: #ffffff; /* rouge vif */
/* Video Info Box */
--infos-box-color: #1a1a1a; /* fond infos gris foncé */
--video-info-text-color: #f5f5f5; /* texte clair */
--video-info-heading-color: #ff1a1a; /* titres rouges */
--video-info-list-color: #cccccc; /* texte liste gris clair */
--video-info-list-strong-color: #ffffff; /* texte important blanc */
--video-info-link-color: #ff1a1a; /* liens rouges */
--video-info-link-hover-color: #cc0000; /* liens hover rouge foncé */
/* Playlist Color */
--playlist-background-color: #353232;
/* Settings */
--settings-button-bg-color: #1a1a1a;
--settings-button-text-color: #dcdcdc;
--settings-bg-primary-color: #1a1a1a;
--settings-bg-secondary-color: #292929;
--settings-text-color: #cecece;
--settings-subtitle-color: #adadad;
}
/* Optionnel: background piments */
body.spicy {
background-image: url('../../assets/images/Spicy.png');
background-size:cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
}

View File

@@ -1,9 +0,0 @@
@import url("chirac-theme.css");
@import url("dark-theme.css");
@import url("fanatic-theme.css");
@import url("light-theme.css");
@import url("nf-theme.css");
@import url("drift-theme.css");
@import url("neon-theme.css");
@import url("songbird-theme.css");
@import url("spicy-theme.css")

View File

@@ -3,7 +3,7 @@
--background-color: #001224; --background-color: #001224;
--default-text-color: #eee; --default-text-color: #eee;
--subtitle-color: #007bff; --subtitle-color: #007bff;
--mp3-text-color: #000000; --audio-only-label-color: #000000;
/* Formulaire */ /* Formulaire */
--form-bg-color: #ffffff; --form-bg-color: #ffffff;
@@ -15,18 +15,23 @@
--form-button-bg-color: #007bff; --form-button-bg-color: #007bff;
--form-button-text-color: #ffffff; --form-button-text-color: #ffffff;
--form-button-bg-hover-color: #0056b3; --form-button-bg-hover-color: #0056b3;
--paste-button-icon-color: #ffffff;
/* Checkbox */ /* Checkbox */
--checkbox-bg-default: #cccccc; --checkbox-unchecked-bg-color: #cccccc;
--checkbox-bg-checked: #3B82F6; --checkbox-checked-bg-color: #3B82F6;
--checkbox-checkmark-border-color: #E0E0E2; --checkbox-checkmark-border-color: #E0E0E2;
--checkbox-pulse-color: rgba(59, 130, 246, 0.5); --checkbox-pulse-color: rgba(59, 130, 246, 0.5);
/* Download status */ /* Download status */
--download-status-color: #007bff; --download-status-color: #007bff;
/* Progress bar */
--progress-bar-bg-color: #cccccc;
--progress-bar-fill-color: #007bff;
/* Video Info Box */ /* Video Info Box */
--infos-box-color: #f0f0f0; --video-info-bg-color: #f0f0f0;
--video-info-text-color: #222222; --video-info-text-color: #222222;
--video-info-heading-color: #007bff; --video-info-heading-color: #007bff;
--video-info-list-color: #444444; --video-info-list-color: #444444;
@@ -34,12 +39,15 @@
--video-info-link-color: #007bff; --video-info-link-color: #007bff;
--video-info-link-hover-color: #0056b3; --video-info-link-hover-color: #0056b3;
/* Playlist */
--playlist-background-color: #4f4f4f;
/* Settings */ /* Settings */
--settings-button-bg-color: var(--form-bg-color); --settings-button-bg-color: var(--form-bg-color);
--settings-button-text-color: var(--default-text-color); --settings-button-text-color: var(--default-text-color);
--settings-bg-primary-color: var(--form-bg-color); --settings-modal-bg-color: var(--form-bg-color);
--settings-bg-secondary-color: var(--form-bg-color); --settings-section-bg-color: var(--form-bg-color);
--settings-text-color: var(--default-text-color); --settings-text-color: var(--default-text-color);
--settings-subtitle-color: #aaa; --settings-subtitle-color: #aaa;

BIN
resources/binaries/linux/yt-dlp Executable file

Binary file not shown.

20
scripts/make-copr-srpm.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
VERSION=$(node -p "require('$ROOT_DIR/package.json').version")
mkdir -p "$ROOT_DIR/srpm-out"
TMP_DIR="$ROOT_DIR/.copr-build-tmp"
mkdir -p "$TMP_DIR"
TMP_SPEC="$TMP_DIR/freedom-loader.spec"
sed "s/__VERSION__/$VERSION/g" "$ROOT_DIR/package/freedom-loader.spec" > "$TMP_SPEC"
rpmbuild -bs "$TMP_SPEC" \
--define "_sourcedir $ROOT_DIR/dist" \
--define "_srcrpmdir $ROOT_DIR/srpm-out"
rm -rf "$TMP_DIR"
echo "SRPM généré : $ROOT_DIR/srpm-out/freedom-loader-$VERSION-1*.src.rpm"

View File

@@ -1,11 +1,50 @@
const { fetchDownload } = require("../services/download.services"); const { fetchDownload, cancelDownload } = require("../services/download.services");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl, isSafePath } = require("../helpers/validation"); const { isValidUrl, isSafePath } = require("../helpers/validation.helpers");
const { notifyDownloadFinished } = require("../helpers/notify"); const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const { configFeatures } = require("../../config");
const listeners = []; /**
* Active Server-Sent Event subscribers receiving
* download progress updates.
*
* Each listener receives a percentage value between
* 0 and 100 during the download lifecycle.
*/
const progressListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download speed updates.
*/
const speedListeners = []; const speedListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* download stage updates.
*/
const stageListeners = [];
/**
* Active Server-Sent Event subscribers receiving
* playlist progress information.
*/
const playlistListeners = [];
/**
* Handles download requests and starts a yt-dlp download.
*
* Validates user input, prepares download options and
* delegates the download process to the download service.
*
* Once the download completes successfully, a desktop
* notification may be displayed depending on the
* application configuration.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
* @returns {Promise<void>}
*/
async function downloadController(req, res) { async function downloadController(req, res) {
try { try {
const options = { const options = {
@@ -13,21 +52,39 @@ async function downloadController(req, res) {
audioOnly: req.body.audioOnly === "1", audioOnly: req.body.audioOnly === "1",
quality: req.body.quality || "best", quality: req.body.quality || "best",
outputFolder: req.body.savePath || undefined, outputFolder: req.body.savePath || undefined,
playlistTitle: req.body.playlistTitle || undefined,
}; };
if (!options.url || !isValidUrl(options.url)) return res.status(400).send("Invalid URL !"); if (!options.url || !isValidUrl(options.url)) return res.status(400).send("Invalid URL !");
if (options.outputFolder && !isSafePath(options.outputFolder)) return res.status(400).send("❌ Save Path Not Allowed."); if (options.outputFolder && !isSafePath(options.outputFolder)) {
logger.warn(`Unsafe download path rejected: ${options.outputFolder}`);
return res.status(400).send("Save Path Not Allowed.");
}
const filePath = await fetchDownload(options, listeners, speedListeners); const outputFolder = await fetchDownload(
notifyDownloadFinished(filePath); options,
res.send("✅ Download Done !"); progressListeners,
speedListeners,
stageListeners,
playlistListeners
);
notifyDownloadFinished(outputFolder, configFeatures.notifySystem);
res.send("Download Done !");
} catch (err) { } catch (err) {
logger.error(`Server Error in /download : ${err.message}`); logger.error(`Server Error in /download: ${err?.message ?? err}`);
res.status(500).send(`Server Error`); res.status(500).send("Server Error");
} }
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download progress updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function progressController(req, res) { function progressController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -36,14 +93,21 @@ function progressController(req, res) {
}); });
const sendProgress = percent => res.write(`data: ${percent}\n\n`); const sendProgress = percent => res.write(`data: ${percent}\n\n`);
listeners.push(sendProgress); progressListeners.push(sendProgress);
req.on('close', () => { req.on('close', () => {
listeners.splice(listeners.indexOf(sendProgress), 1); progressListeners.splice(progressListeners.indexOf(sendProgress), 1);
res.end(); res.end();
}); });
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download speed updates to the client.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function speedController(req, res) { function speedController(req, res) {
res.set({ res.set({
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -60,5 +124,75 @@ function speedController(req, res) {
}); });
} }
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream download stage updates such as downloading,
* merging, extracting audio or embedding metadata.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function stageController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
module.exports = { downloadController, progressController, speedController}; const sendStage = stage => res.write(`data: ${stage}\n\n`);
stageListeners.push(sendStage);
req.on('close', () => {
stageListeners.splice(stageListeners.indexOf(sendStage), 1);
res.end();
});
}
/**
* Cancels the currently running download process.
*
* Returns an error response if no download is active.
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function cancelDownloadController(req, res) {
const cancelled = cancelDownload();
if (cancelled) {
logger.info("Download and queue cancelled by user");
res.send("Download stopped! Queue cleared.");
} else {
logger.warn("No download to cancel");
res.status(400).send("No download in progress !");
}
}
/**
* Opens a Server-Sent Events (SSE) connection used to
* stream playlist progress information to the client.
*
* Example:
* - Item 1 of 20
* - Item 12 of 20
* - Item 20 of 20
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
function playlistInfoController(req, res) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const sendPlaylistInfo = info => res.write(`data: ${info}\n\n`);
playlistListeners.push(sendPlaylistInfo);
req.on('close', () => {
playlistListeners.splice(playlistListeners.indexOf(sendPlaylistInfo), 1);
res.end();
});
}
module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController};

View File

@@ -1,46 +1,116 @@
const { fetchInfo } = require("../services/info.services"); const { fetchInfo } = require("../services/info.services");
const { parseVideo, parsePlaylist } = require("../helpers/parseInfo"); const { parseVideo, parsePlaylist } = require("../helpers/parseInfo.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { isValidUrl } = require("../helpers/validation"); const { isValidUrl } = require("../helpers/validation.helpers");
/**
* Handles metadata retrieval for a video or playlist.
*
* This controller:
* - Validates incoming URL (query or body)
* - Calls yt-dlp metadata extraction service
* - Detects playlist vs single video context
* - Applies fallback strategy if playlist extraction fails
* - Returns normalized parsed response (video or playlist)
*
* @param {import("express").Request} req
* @param {import("express").Response} res
*/
async function infoController(req, res) { async function infoController(req, res) {
const url = req.body.url || req.query.url; // Gérer POST et GET const url = req.body.url || req.query.url; // Supports POST and GET requests
/**
/* Si pas d'url, url non-string ou url invalide*/ * Basic validation:
if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("❌ Invalid URL Or Missing"); * - URL must exist
* - Must be a string
logger.info(`/Info Request receive by the Info Controller. URL: ${url}`); * - Must pass custom URL validation
*/
if (url.includes("?list")) { if (!url || typeof url !== "string" || !isValidUrl(url)) {
logger.info("Estimated Data Type: Playlist") return res.status(400).send("Invalid URL Or Missing");
} else{
logger.info("Estimated Data Type: Video")
} }
try { logger.info(`Info request received. URL: ${url}`);
const data = await fetchInfo(url);
// Lightweight heuristic to detect playlist URLs
const isPlaylistUrl = url.includes("&list") || url.includes("?list");
logger.info(
isPlaylistUrl
? "Estimated Data Type: Playlist"
: "Estimated Data Type: Video"
);
try {
let data;
/**
* Primary metadata fetch attempt
*/
try {
data = await fetchInfo(url);
} catch (err) {
/**
* Fallback strategy:
* If playlist extraction fails, retry as single video
*/
if (isPlaylistUrl) {
logger.info(
`Playlist fetch failed: ${err.message}. Retrying as single video...`
);
// Remove playlist query parameter (best-effort cleanup)
const singleVideoUrl = url.split("&list")[0].split("?list")[0];
try {
data = await fetchInfo(singleVideoUrl);
logger.info("Single video fetch succeeded after playlist failure");
} catch (retryErr) {
logger.error(
`Single video fetch also failed: ${retryErr.message}`
);
// Preserve original error context
throw err;
}
} else {
throw err;
}
}
/**
* Normalize response based on yt-dlp output type
*/
if (data._type === "playlist") { if (data._type === "playlist") {
const playlist = parsePlaylist(data); const playlist = parsePlaylist(data);
logger.info(`Playlist : ${playlist.title} (${playlist.count} vidéos)`);
logger.info(
`Playlist fetched: ${playlist.title} (${playlist.count} videos)`
);
return res.json(playlist); return res.json(playlist);
} else { } else {
const video = parseVideo(data); const video = parseVideo(data);
logger.info(`Unique Video: ${video.title}`);
logger.info(`Video fetched: ${video.title}`);
return res.json(video); return res.json(video);
} }
} catch (err) { } catch (err) {
logger.error(`Info controller error: ${err && err.message ? err.message : err}`);
return res.status(500).send(`❌ Unable to fetch info`);
}
logger.error(
`Info controller error: ${err && err.message ? err.message : err}`
);
return res.status(500).send("Unable to fetch info");
}
} }
module.exports = { infoController }; module.exports = { infoController };

View File

@@ -1,51 +0,0 @@
const config = require('../config');
const RPC = require("discord-rpc");
const { logger } = require("./logger");
const clientId = `${config.DiscordRPCID}`;
const rpc = new RPC.Client({ transport: "ipc" });
let intervalId;
function startRPC() {
rpc.on("ready", () => {
const presence = {
largeImageKey: "icon",
smallImageKey: "acnolo_pfp",
smallImageText: "By MasterAcnolo",
startTimestamp: new Date(),
details: `Open Source Download Tools - ${config.version}`,
state: "masteracnolo.github.io/FreedomLoader",
};
rpc.clearActivity()
rpc.setActivity(presence);
// Met à jour la présence toutes les 15s
intervalId = setInterval(() => {
rpc.setActivity(presence);
}, 15000);
});
rpc.login({ clientId }).catch(err => {
logger.error("Unable to connect to the RPC:", err);
});
}
async function stopRPC(){
try {
if (intervalId) clearInterval(intervalId);
if (rpc && rpc.transport) {
await rpc.clearActivity()
await rpc.destroy();
} else{
logger.error("Not Able to close RPC")
}
} catch (err) {
logger.error("Error while closing the RPC:", err);
}
}
module.exports = { startRPC, stopRPC};

View File

@@ -1,10 +1,30 @@
const path = require("path"); const path = require("path");
const getUserBrowser = require("./getBrowser"); const getUserBrowser = require("./getBrowser.helpers.js");
const { ffmpegPath, denoPath} = require("./path"); const { ffmpegPath, denoPath} = require("./path.helpers.js");
const { configFeatures } = require("../../config.js"); const { configFeatures } = require("../../config.js");
const { logger } = require("../logger"); const { logger } = require("../logger.js");
/**
* Validates whether the provided codec is supported by the application.
*
* If the codec is supported, it is returned unchanged.
* Otherwise, a warning is logged and the default codec ("h264")
* is returned as a fallback.
*
* Supported codecs:
* - av01
* - vp9.2
* - vp9
* - h265
* - h264
* - vp8
* - h263
* - theora
*
* @param {string} codec - Codec identifier to validate.
* @returns {string} A valid codec identifier.
*/
function validateCodec(codec){ function validateCodec(codec){
const validCodec = [ const validCodec = [
@@ -20,6 +40,28 @@ function validateCodec(codec){
} }
} }
/**
* Builds the complete yt-dlp argument list based on the
* application configuration and download options.
*
* The generated arguments include:
* - Browser cookie extraction
* - FFmpeg integration
* - Deno runtime configuration
* - Metadata and thumbnail embedding
* - Playlist handling
* - Video/audio format selection
* - Quality filtering
* - Output file destination
*
* @param {Object} options - Download configuration.
* @param {string} options.url - Video or playlist URL.
* @param {boolean} options.audioOnly - Whether to extract audio only.
* @param {string} options.quality - Requested quality preset.
* @param {string} options.outputFolder - Download destination folder.
*
* @returns {string[]} Array of arguments ready to be passed to yt-dlp.
*/
function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) { function buildYtDlpArgs({ url, audioOnly, quality, outputFolder }) {
logger.info("--- CONFIGURATION ---"); logger.info("--- CONFIGURATION ---");

View File

@@ -0,0 +1,102 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const notify = require("./notify.helpers")
const { logger } = require("../logger");
/**
* Detects the first supported browser installed on the system.
*
* The detected browser is used by yt-dlp to import authentication
* cookies when required by certain platforms.
*
* Currently, Firefox is the only browser officially supported
* by the application.
*
* If no supported browser is found:
* - A warning is logged
* - A notification is displayed to the user
* - "firefox" is returned as a fallback to allow yt-dlp to
* handle the error gracefully without crashing the application
*
* @returns {string} The detected browser identifier.
*/
function getUserBrowser() {
//const userProfile = os.homedir();
const firefoxPath = getFirefoxProfilePath();
/**
* Browsers supported by yt-dlp cookie extraction.
*
* Only Firefox is currently enabled and tested.
* Additional browsers can be enabled in the future
* once compatibility has been verified.
*/
const browsers = [
{ name: "firefox", path: firefoxPath },
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
// { name: "brave", path: path.join(userProfile, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default") },
// { name: "edge", path: path.join(userProfile, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default") },
// { name: "opera", path: path.join(userProfile, "AppData", "Roaming", "Opera Software", "Opera Stable") },
// { name: "vivaldi", path: path.join(userProfile, "AppData", "Local", "Vivaldi", "User Data", "Default") },
// { name: "safari", path: path.join(userProfile, "AppData", "Local", "Apple Computer", "Safari") },
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
];
// Search for the first available browser profile.
for (const browser of browsers) {
if (browser.path && fs.existsSync(browser.path)) {
logger.info(`Browser found: ${browser.name}`);
return browser.name;
}
}
// No supported browser found => Notify User
logger.warn("No supported browser found on the system");
// If you somehow managed to live without Firefox and need help installing it. - Don't applied to my Linux chad
if (process.platform === "win32") {
notify.notifyFirefoxBrowserMissing();
}
// Fallback to Firefox and let yt-dlp handle the error gracefully.
// This prevents the application from crashing
return "firefox";
}
function getFirefoxProfilePath() {
const home = os.homedir();
if (process.platform === "win32") {
return path.join(
home,
"AppData",
"Roaming",
"Mozilla",
"Firefox",
"Profiles"
);
}
if (process.platform === "linux") {
return path.join(
"usr",
"bin",
"firefox"
);
}
if (process.platform === "darwin") {
return path.join(
home,
"Library",
"Application Support",
"Firefox",
"Profiles"
);
}
return null;
}
module.exports = getUserBrowser

View File

@@ -1,32 +0,0 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const notify = require("./notify")
function getUserBrowser() {
const userProfile = os.homedir();
// Liste des navigateurs supportés par yt-dlp (Actuellement je peux que garantir Firefox, Désolé)
const browsers = [
{ name: "firefox", path: path.join(userProfile, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles") },
// { name: "chrome", path: path.join(userProfile, "AppData", "Local", "Google", "Chrome", "User Data", "Default") },
// { name: "brave", path: path.join(userProfile, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default") },
// { name: "edge", path: path.join(userProfile, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default") },
// { name: "opera", path: path.join(userProfile, "AppData", "Roaming", "Opera Software", "Opera Stable") },
// { name: "vivaldi", path: path.join(userProfile, "AppData", "Local", "Vivaldi", "User Data", "Default") },
// { name: "safari", path: path.join(userProfile, "AppData", "Local", "Apple Computer", "Safari") },
// { name: "whale", path: path.join(userProfile, "AppData", "Local", "Naver", "Naver Whale", "User Data", "Default") }
];
for (const browser of browsers) {
if (fs.existsSync(browser.path)) {
return browser.name;
} else{
notify.notifyFirefoxBrowserMissing();
}
}
notify.notifyCookiesBrowserError()
return ;
}
module.exports = getUserBrowser

View File

@@ -0,0 +1,81 @@
const { Notification, shell } = require("electron");
const { iconPaths } = require("./path.helpers");
const { logger } = require("../logger");
/**
* Displays a system notification when a download completes successfully.
*
* If enabled, clicking the notification opens the download folder
* using the system file explorer.
*
* @param {string} folder - Path to the completed download folder.
* @param {boolean} notifyEnabled - Whether notifications are enabled in config.
*/
function notifyDownloadFinished(folder, notifyEnabled = true) {
if (!notifyEnabled) return;
if (!folder) return;
const notif = new Notification({
title: "Freedom Loader",
body: "Your download is complete, click here to open it.",
icon: iconPaths.confirm,
});
notif.on("click", () => shell.openPath(folder));
notif.show();
}
/**
* Displays a notification when browser cookies cannot be extracted.
*
* This usually indicates:
* - User is not logged in to the browser
* - Browser profile is inaccessible
*
* Clicking the notification opens a tutorial link.
*/
function notifyCookiesBrowserError(){
const notif = new Notification({
title: "Cookies Error",
body: "Unable to retrieve cookies. Please log in to your browser and click here to view the tutorial.",
icon: iconPaths.error,
});
notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
notif.show();
}
/**
* Displays a notification when Firefox is not detected
* on the user system.
*
* This is required for cookie extraction via yt-dlp.
*
* Clicking the notification opens an installation guide.
*/
function notifyFirefoxBrowserMissing() {
const notif = new Notification({
title: "Firefox Missing",
body: "Firefox was not found on your system. Click here to follow the installation guide",
icon: iconPaths.error,
});
notif.on("click", () =>
shell.openExternal(
"https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"
)
);
notif.show();
}
module.exports = {
notifyDownloadFinished,
notifyCookiesBrowserError,
notifyFirefoxBrowserMissing,
};

View File

@@ -1,44 +0,0 @@
const { Notification, shell } = require("electron");
const path = require("path");
function notifyDownloadFinished(folder) {
const iconPath = path.join(process.resourcesPath, "confirm-icon.png");
const notif = new Notification({
title: "Freedom Loader",
body: "Your download is complete, click here to open it.",
icon: iconPath,
});
notif.on("click", () => shell.openPath(folder));
notif.show();
}
function notifyCookiesBrowserError(){
const iconPath = path.join(process.resourcesPath, "error.png");
const notif = new Notification({
title: "Cookies Error",
body: "Unable to retrieve cookies. Please log in to your browser and click here to view the tutorial.",
icon: iconPath,
});
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"));
notif.show();
}
function notifyFirefoxBrowserMissing(){
const iconPath = path.join(process.ressourcesPath, "error.png");
const notif = new Notification({
title: "Firefox Missing",
body: "Firefox was not found on your system. Click here to follow the installation guide",
icon: iconPath,
});
notif.on("click", () => shell.openExternal("https://youtube.com/shorts/cN9f4s1Mf88?si=519QCVd_-fzJqRf1"))
notif.show();
}
module.exports = {
notifyDownloadFinished,
notifyCookiesBrowserError,
notifyFirefoxBrowserMissing
};

View File

@@ -0,0 +1,38 @@
/**
* Normalizes a video payload by attaching the resource type.
*
* @param {Object} data - Raw video metadata.
* @returns {Object} Normalized video object.
*/
function parseVideo(data) {
return {
type: "video",
...data
};
}
/**
* Transforms a raw playlist payload into a normalized structure
* containing playlist metadata and a simplified list of videos.
*
* @param {Object} data - Raw playlist metadata.
* @returns {Object} Normalized playlist object.
*/
function parsePlaylist(data) {
return {
type: "playlist",
title: data.title || data.id,
channel: data.uploader,
count: data.entries.length,
videos: (data.entries || []).map(v => ({
id: v.id,
title: v.title,
url: v.webpage_url,
duration: v.duration,
thumbnail: v.thumbnail,
uploader: v.uploader
}))
};
}
module.exports = { parseVideo, parsePlaylist };

View File

@@ -1,33 +0,0 @@
function parseVideo(data) {
return {
type: "video",
// id: data.id,
// title: data.title,
// url: data.webpage_url,
// duration: data.duration,
// thumbnail: data.thumbnail,
// uploader: data.uploader
...data
};
}
function parsePlaylist(data) {
return {
type: "playlist",
title: data.title || data.id,
channel: data.uploader,
count: data.entries.length,
videos: (data.entries || []).map(v => ({
id: v.id,
title: v.title,
url: v.webpage_url,
duration: v.duration,
thumbnail: v.thumbnail,
uploader: v.uploader
}))
};
}
module.exports = { parseVideo, parsePlaylist };

View File

@@ -0,0 +1,256 @@
const path = require("path");
const fs = require("fs");
const { app } = require("electron");
const config = require("../../config.js");
const { logger } = require("../logger.js");
/**
* Is the OS windows
*/
const isWindows = process.platform === 'win32'
/**
* On windows the binaries are name.exe on Linux/macOS no
*/
const ext = isWindows ? '.exe' : ''
/** Centralized resource paths */
const resourcesPath = config.devMode
? path.join(__dirname, "../../resources")
: process.resourcesPath;
/** Default download folder (centralized) */
const defaultDownloadFolder = path.join(app.getPath("downloads"), "Freedom Loader");
/**
* Current platform:
* win32 | linux | darwin
*/
const platform = process.platform;
/**
* Dev source tree uses platform-named subfolders (matches resources/binaries/{win-32,linux,darwin}).
* Packaged builds flatten everything directly under binaries/, since each
* platform build only ships its own binaries — no subfolder needed.
*/
const devPlatformFolder = {
win32: "win-32",
linux: "linux",
darwin: "darwin"
}[platform];
const binariesPath = config.devMode
? path.join(resourcesPath, "binaries", devPlatformFolder)
: path.join(resourcesPath, "binaries");
/**
* Runtime-resolved binary paths.
* These values differ between development and production builds.
*/
let userYtDlp;
let ffmpegPath;
let denoPath;
/** Source binary bundled inside application resources (used for first-time copy) */
const sourceYtDlp = path.join(
binariesPath,
`yt-dlp${ext}`
);
/**
* Resolve binary locations depending on environment:
* - dev: local resources folder
* - prod: extracted userData + packaged resources
*/
if (config.devMode) {
userYtDlp = path.join(
binariesPath,
`yt-dlp${ext}`
);
ffmpegPath = path.join(
binariesPath,
`ffmpeg${ext}`
);
denoPath = path.join(
binariesPath,
`deno${ext}`
);
} else {
userYtDlp = path.join(
app.getPath("userData"),
`yt-dlp${ext}`
);
ffmpegPath = path.join(
binariesPath,
`ffmpeg${ext}`
);
denoPath = path.join(
binariesPath,
`deno${ext}`
);
if (!fs.existsSync(userYtDlp) && fs.existsSync(sourceYtDlp)) {
fs.copyFileSync(sourceYtDlp, userYtDlp);
// Linux/macOS executable permission
if (!isWindows) {
try {
fs.chmodSync(userYtDlp, 0o755);
} catch (err) {
logger.warn(`Failed to chmod yt-dlp: ${err.message}`);
}
}
}
}
/**
* Centralized notification and UI icon paths.
* Used by Electron notifications and UI components.
*/
const iconPaths = {
confirm: path.join(resourcesPath, "confirm-icon.png"),
error: path.join(resourcesPath, "error.png"),
app: path.join(resourcesPath, "app-icon.ico")
};
/**
* Static reference paths to bundled binaries.
* Used mainly for verification and diagnostics.
*/
const binaryPaths = {
ytDlp: path.join(
binariesPath,
`yt-dlp${ext}`
),
ffmpeg: path.join(
binariesPath,
`ffmpeg${ext}`
),
ffprobe: path.join(
binariesPath,
`ffprobe${ext}`
),
deno: path.join(
binariesPath,
`deno${ext}`
)
};
/**
* Theme storage system:
* - default themes come from app resources
* - user themes are persisted in userData to survive updates
*/
const userThemesPath = path.join(app.getPath("userData"), "themes");
const defaultThemesSourcePath = path.join(resourcesPath, "theme");
/**
* Initializes user theme directory and ensures default themes exist.
*
* - Creates user themes folder if missing
* - Copies bundled themes from resources if not present
* - Applies permissive permissions for runtime modification
*/
function initUserThemes() {
try {
if (!fs.existsSync(userThemesPath)) {
fs.mkdirSync(userThemesPath, { recursive: true });
// Ensure folder is writable
fs.chmodSync(userThemesPath, 0o777);
logger.info("Created user themes directory");
}
// Copy default themes from resources to user directory if they don't exist
if (fs.existsSync(defaultThemesSourcePath)) {
const defaultThemes = fs.readdirSync(defaultThemesSourcePath);
for (const theme of defaultThemes) {
const srcPath = path.join(defaultThemesSourcePath, theme);
const destPath = path.join(userThemesPath, theme);
if (!fs.existsSync(destPath)) {
try {
copyDirectory(srcPath, destPath);
fs.chmodSync(destPath, 0o777);
logger.info(`Copied default theme: ${theme}`);
} catch (copyErr) {
logger.warn(
`Failed to copy theme ${theme}: ${copyErr.message}. Theme folder will be created, add theme files manually.`
);
try {
fs.mkdirSync(destPath, { recursive: true });
fs.chmodSync(destPath, 0o777);
} catch (mkdirErr) {
logger.warn(
`Could not create theme directory ${theme}: ${mkdirErr.message}`
);
}
}
}
}
} else {
logger.warn(
`Default themes source path not found: ${defaultThemesSourcePath}`
);
}
} catch (err) {
logger.error(`Failed to initialize user themes: ${err.message}`);
}
}
/**
* Recursively copies a directory and its contents.
*
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @throws Error If copy operation fails
*/
function copyDirectory(src, dest) {
try {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, {
recursive: true,
mode: 0o777
});
}
const files = fs.readdirSync(src);
for (const file of files) {
const srcFile = path.join(src, file);
const destFile = path.join(dest, file);
if (fs.statSync(srcFile).isDirectory()) {
copyDirectory(srcFile, destFile);
} else {
fs.copyFileSync(srcFile, destFile);
}
}
} catch (err) {
throw new Error(`Copy error: ${err.message}`);
}
}
// Runtime validation of critical binaries
if (!userYtDlp) {
logger.error("Missing yt-dlp binary");
}
if (!ffmpegPath) {
logger.error("Missing ffmpeg binary");
}
if (!denoPath) {
logger.error("Missing deno binary");
}
module.exports = { userYtDlp, ffmpegPath, denoPath, iconPaths, binaryPaths, resourcesPath, defaultDownloadFolder, userThemesPath, initUserThemes, isWindows };

View File

@@ -1,33 +0,0 @@
const path = require("path");
const fs = require("fs");
const { app } = require("electron");
const config = require("../../config");
const { logger } = require("../logger.js");
let userYtDlp;
let ffmpegPath;
let denoPath;
const sourceYtDlp = path.join(process.resourcesPath, "binaries","yt-dlp.exe");
if (config.localMode) {
userYtDlp = path.join(__dirname, "../../ressources/yt-dlp.exe");
ffmpegPath = path.join(__dirname, "../../ressources/"); // <- contient ffmpeg.exe et ffprobe.exe
denoPath = path.join(__dirname, "../../ressources/deno.exe");
} else {
userYtDlp = path.join(app.getPath("userData"),"yt-dlp.exe");
ffmpegPath = path.join(process.resourcesPath, "binaries","ffmpeg.exe");
denoPath = path.join(process.resourcesPath, "binaries","deno.exe");
if (!fs.existsSync(userYtDlp)) {
fs.copyFileSync(sourceYtDlp, userYtDlp);
}
}
if (!userYtDlp){ logger.error("Missing YT-DLP")}
if (!ffmpegPath){ logger.error("Missing FFMPEG")}
if (!denoPath){ logger.error("Missing DENO")}
module.exports = { userYtDlp, ffmpegPath, denoPath };

View File

@@ -0,0 +1,24 @@
const RateLimit = require("express-rate-limit");
/**
* Rate limiter middleware used to protect public endpoints
* against abusive or excessive requests.
*
* Restrictions:
* - 5 requests per IP address
* - 15-minute sliding window
*
* When the limit is exceeded, a HTTP 429 (Too Many Requests)
* response is returned.
*/
const rateLimit = RateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: "Too many requests, please try again later.",
statusCode: 429,
});
module.exports = {
rateLimit
}

View File

@@ -1,14 +0,0 @@
const RateLimit = require("express-rate-limit");
/* Rate Limite */
const rateLimite = RateLimit({
windowMs: 15 * 60, // 15 minutes
max: 5, // limit each IP to 100 requests per windowMs on the root path
message: "Too many requests, please try again later.",
statusCode: 429, // HTTP status code for "Too Many Requests"
});
module.exports = {
rateLimite
}

View File

@@ -0,0 +1,43 @@
const path = require("path");
/**
* Validates whether the provided string is a well-formed URL.
*
* The validation relies on the native URL constructor and
* returns `true` only if the value can be successfully parsed.
*
* @param {string} url - URL to validate.
* @returns {boolean} True if the URL is valid, otherwise false.
*/
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validates whether a folder path is considered safe for
* file operations.
*
* Security checks:
* - Rejects empty or very short paths.
* - Resolves the absolute path before validation.
* - Prevents access to sensitive Windows system directories.
*
* @param {string} folder - Folder path to validate.
* @returns {boolean} True if the path is considered safe, otherwise false.
*/
function isSafePath(folder) {
if (!folder || folder.length < 3) return false;
const unsafe = ["System32", "\\Windows"];
const resolved = path.resolve(folder);
return !unsafe.some(u => resolved.includes(u));
}
module.exports = { isValidUrl, isSafePath };

View File

@@ -1,21 +0,0 @@
const path = require("path");
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
function isSafePath(folder) {
if (!folder || folder.length < 3) return false;
const unsafe = ["System32", "/etc", "\\Windows"];
const resolved = path.resolve(folder);
return !unsafe.some(u => resolved.includes(u));
}
module.exports = { isValidUrl, isSafePath };

View File

@@ -3,20 +3,33 @@ const DailyRotateFile = require("winston-daily-rotate-file");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const os = require("os"); const os = require("os");
const config = require("../config") const config = require("../config");
const {isWindows} = require("./helpers/path.helpers");
// Dossier de logs Windows // Logs folder in Windows
const logDir = path.join(os.homedir(), "AppData", "Local", "FreedomLoader", "logs"); const logDir = isWindows ? path.join(os.homedir(), "AppData", "Local", "FreedomLoader", "logs") : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share"), "FreedomLoader", "logs");
// Création du dossier "logs" si nécessaire // Create "Logs" folder if needed
try {
fs.mkdirSync(logDir, { recursive: true }); fs.mkdirSync(logDir, { recursive: true });
} catch (error) {
console.error(`Failed to create log directory: ${error.message}`);
}
const logFormat = format.combine( const logFormat = format.combine(
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`) format.printf(({ timestamp, level, message }) => `${timestamp} | ${level.toUpperCase()} | ${message}`)
); );
// Configuration du logger /**
* Logger Instance with differents types
* - info
* - error
* - warn
*
* You need to specify the text that need to be displayed
* @param string
*/
const logger = createLogger({ const logger = createLogger({
level: "info", level: "info",
format: logFormat, format: logFormat,
@@ -36,21 +49,19 @@ const logger = createLogger({
], ],
}); });
function getSessionStartLine() { /**
return `--- Starting session: ${new Date().toISOString()} ---`; * Start Log Session
} */
function getSessionEndLine() {
return `--- Ending session: ${new Date().toISOString()} ---`;
}
function logSessionStart() { function logSessionStart() {
logger.info(getSessionStartLine()); logger.info(`--- Starting session: ${new Date().toISOString()} ---`);
logger.info(`Application Version: ${config.version}`) logger.info(`Application Version: ${config.version}`)
} }
/**
* Stop Log Session
*/
function logSessionEnd() { function logSessionEnd() {
logger.info(getSessionEndLine()); logger.info(`--- Ending session: ${new Date().toISOString()} ---`);
} }
module.exports = { module.exports = {

View File

@@ -1,11 +1,14 @@
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const { downloadController, progressController, speedController } = require("../controller/download.controller"); const { downloadController, progressController, speedController, stageController, cancelDownloadController, playlistInfoController } = require("../controller/download.controller");
router.post("/", downloadController); router.post("/", downloadController);
router.post("/cancel", cancelDownloadController);
// SSE pour la progression du téléchargement // SSE for download progress
router.get("/progress", progressController); router.get("/progress", progressController);
router.get("/speed", speedController); router.get("/speed", speedController);
router.get("/stage", stageController);
router.get("/playlist-info", playlistInfoController);
module.exports = router; module.exports = router;

View File

@@ -1,47 +1,31 @@
const express = require("express"); const express = require("express");
const fs = require("fs");
const path = require("path"); const path = require("path");
const { logger, logSessionEnd } = require("./logger"); const { logger, logSessionEnd } = require("./logger");
const config = require("../config"); const config = require("../config");
const { execFile } = require("child_process"); const { rateLimit } = require("./helpers/rateLimit.helpers");
const { userYtDlp } = require("./helpers/path");
const { rateLimite } = require("./helpers/rateLimit")
const app = express(); const app = express();
app.use(express.json());
// Dossier de téléchargement
const outputFolder = path.join(process.env.USERPROFILE, "Downloads", "Freedom Loader");
// Création du dossier si nécessaire
try {
fs.mkdirSync(outputFolder, { recursive: true });
logger.info(`Freedom Loader folder ready in ${outputFolder}`);
} catch (err) {
logger.error("Unable to create folder:", err);
process.exit(1);
}
// Mise à jour yt-dlp au
execFile(userYtDlp, ["-U"], (err, stdout, stderr) => {
if (err) logger.warn("yt-dlp update error:", err);
else logger.info(`Update yt-dlp : ${stdout}`);
});
// Middlewares // Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
// Routes // Routes
app.use("/download", require("./routes/download.route")); app.use("/download", require("./routes/download.route"));
app.use("/info", require("./routes/info.route")); app.use("/info", require("./routes/info.route"));
app.get("/", rateLimit, (req, res) => {
app.get("/", rateLimite ,(req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html")); res.sendFile(path.join(__dirname, "../public/index.html"));
}); });
// Fonction pour démarrer le serveur /**
* Initializes and starts the Express HTTP server.
*
* - Binds the server to `config.applicationPort`
* - Resolves with the HTTP server instance on successful startup
* - Rejects on server binding or runtime errors
* - Registers SIGINT/SIGTERM handlers for graceful shutdown
*/
async function startServer() { async function startServer() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = app.listen(config.applicationPort, () => { const server = app.listen(config.applicationPort, () => {
@@ -50,14 +34,20 @@ async function startServer() {
}); });
server.on("error", (err) => { server.on("error", (err) => {
logger.error("Express Server Error :", err); logger.error("Express server error:", err);
reject(err); reject(err);
}); });
/**
* Clean exit function
* - Stop Log
* - Stop Express Server
* - Stop Electron App
*/
const gracefulExit = () => { const gracefulExit = () => {
logSessionEnd(); logSessionEnd();
server.close(() => { server.close(() => {
logger.info("Express Server close cleanly."); logger.info("Express server closed cleanly.");
process.exit(); process.exit();
}); });
}; };

View File

@@ -1,27 +1,190 @@
const { execFile } = require("child_process"); const { execFile } = require("child_process");
const { userYtDlp } = require("../helpers/path"); const { userYtDlp, defaultDownloadFolder } = require("../helpers/path.helpers");
const path = require("path");
const fs = require("fs"); const fs = require("fs");
const { logger } = require("../logger"); const { logger } = require("../logger");
const { buildYtDlpArgs } = require("../helpers/buildArgs"); const { buildYtDlpArgs } = require("../helpers/buildArgs.helpers");
const notify = require("../helpers/notify") const notify = require("../helpers/notify.helpers");
const path = require("path");
const { isSafePath } = require("../helpers/validation.helpers");
const { configFeatures } = require("../../config.js");
function fetchDownload(options, listeners, speedListeners) { /**
* Reference to the currently running yt-dlp process.
*
* Used to support download cancellation and lifecycle
* management.
*
* @type {import("child_process").ChildProcess|null}
*/
let currentDownloadProcess = null;
/**
* Determines whether a URL targets a playlist.
*
* The detection is based on the presence of the YouTube
* playlist query parameter (`list`).
*
* @param {string} url - URL to inspect.
* @returns {boolean} True if the URL appears to be a playlist.
*/
function isPlaylistUrl(url) {
return url.includes("?list=") || url.includes("&list=");
}
/**
* Creates a dedicated download folder for a playlist.
*
* The playlist title is sanitized to ensure it can be safely
* used as a filesystem path. If the target folder already
* exists, an incremented suffix is appended until a unique
* folder name is found.
*
* If folder creation fails, a fallback folder named
* "Untitled Playlist X" is generated.
*
* @param {string} basePath - Parent download directory.
* @param {string} playlistTitle - Playlist title returned by yt-dlp.
* @returns {string} Absolute path of the created playlist folder.
* @throws {Error} If no valid folder can be created.
*/
function createPlaylistFolder(basePath, playlistTitle) {
const sanitizedTitle = playlistTitle
.replace(/[<>:"|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 200);
const playlistPath = path.join(basePath, sanitizedTitle);
try {
if (!fs.existsSync(playlistPath)) {
fs.mkdirSync(playlistPath, { recursive: true });
logger.info(`Playlist folder created: ${playlistPath}`);
return playlistPath;
}
let counter = 1;
let newPath;
while (counter <= 1000) {
newPath = path.join(basePath, `${sanitizedTitle} ${counter}`);
if (!fs.existsSync(newPath)) {
fs.mkdirSync(newPath, { recursive: true });
logger.info(`Playlist folder created with increment: ${newPath}`);
return newPath;
}
counter++;
}
logger.error(`Could not find available playlist folder after 1000 attempts`);
throw new Error("Unable to create playlist folder");
} catch (err) {
logger.warn(`Failed to create playlist folder with title "${sanitizedTitle}": ${err.message}`);
// Fallback: create folder "Untitled Playlist X"
let counter = 1;
let fallbackPath;
while (counter <= 1000) {
fallbackPath = path.join(basePath, `Untitled Playlist ${counter}`);
if (!fs.existsSync(fallbackPath)) {
try {
fs.mkdirSync(fallbackPath, { recursive: true });
logger.info(`Fallback playlist folder created: ${fallbackPath}`);
return fallbackPath;
} catch (mkErr) {
counter++;
continue;
}
}
counter++;
}
logger.error(`Could not create playlist folder after 1000 attempts`);
throw new Error("Unable to create playlist folder");
}
}
/**
* Starts a yt-dlp download process and monitors its progress.
*
* Responsibilities:
* - Validates and prepares the output directory
* - Creates playlist folders when enabled
* - Builds yt-dlp command arguments
* - Spawns the yt-dlp process
* - Parses progress, speed and stage information
* - Notifies listeners about download events
* - Handles download completion and failures
*
* @param {Object} options - Download options.
* @param {string} options.url - Video or playlist URL.
* @param {string} [options.outputFolder] - Destination folder.
* @param {string} [options.playlistTitle] - Playlist title.
*
* @param {Function[]} listeners - Progress listeners.
* @param {Function[]} speedListeners - Download speed listeners.
* @param {Function[]} stageListeners - Processing stage listeners.
* @param {Function[]} playlistInfoListeners - Playlist progress listeners.
*
* @returns {Promise<string>}
* Resolves with the final output folder path once the
* download has completed successfully.
*/
function fetchDownload(options, listeners, speedListeners, stageListeners, playlistInfoListeners) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const outputFolder = options.outputFolder || path.join(process.env.USERPROFILE, "Downloads", "Freedom Loader"); logger.info(`CONFIG createPlaylistFolders: ${configFeatures.createPlaylistFolders}`);
fs.mkdirSync(outputFolder, { recursive: true });
const args = buildYtDlpArgs({ ...options, outputFolder }); let outputFolder = options.outputFolder || defaultDownloadFolder;
// Normalize path and validate it's safe (within Users folder)
let safeOutputFolder = path.resolve(outputFolder);
if (!isSafePath(safeOutputFolder)) {
logger.warn(`Path not allowed, using default instead: ${safeOutputFolder}`);
safeOutputFolder = path.resolve(defaultDownloadFolder);
}
// Create download folder if it doesn't exist
try {
fs.mkdirSync(safeOutputFolder, { recursive: true });
logger.info(`Output folder ready: ${safeOutputFolder}`);
} catch (err) {
logger.error(`Failed to create output folder: ${err.message}`);
return reject(new Error(`Unable to create download folder: ${err.message}`));
}
// Détecte si c'est une playlist et crée un dossier approprié
const isPlaylist = options.playlistTitle || isPlaylistUrl(options.url);
if (isPlaylist && configFeatures.createPlaylistFolders) {
try {
const playlistName = options.playlistTitle || "Untitled Playlist";
safeOutputFolder = createPlaylistFolder(safeOutputFolder, playlistName);
logger.info(`Playlist detected, using folder: ${safeOutputFolder}`);
} catch (err) {
logger.error(`Failed to create playlist folder: ${err.message}`);
return reject(err);
}
} else if (isPlaylist && !configFeatures.createPlaylistFolders) {
logger.info(`Playlist detected but createPlaylistFolders is disabled, using base folder`);
}
const args = buildYtDlpArgs({ ...options, outputFolder: safeOutputFolder });
logger.info(`[yt-dlp args] ${args.join(" ")}`); logger.info(`[yt-dlp args] ${args.join(" ")}`);
const child = execFile(userYtDlp, args); const child = execFile(userYtDlp, args);
currentDownloadProcess = child;
child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`))); child.on("error", err => reject(new Error(`YT-DLP Error : ${err.message}`)));
child.on("close", code => { child.on("close", code => {
currentDownloadProcess = null;
listeners.forEach(fn => fn("done")); listeners.forEach(fn => fn("done"));
if (code === 0) resolve(outputFolder); if (code === 0) resolve(safeOutputFolder);
else if (code === null) reject(new Error(`Download cancelled by user`));
else reject(new Error(`YT-DLP failed with code : ${code}`)); else reject(new Error(`YT-DLP failed with code : ${code}`));
}); });
@@ -31,8 +194,11 @@ function fetchDownload(options, listeners, speedListeners) {
if (!line.trim()) return; if (!line.trim()) return;
logger.info(`[yt-dlp] ${line}`); logger.info(`[yt-dlp] ${line}`);
/* Barre de Chargement*/ // Progress Bar
if (line.startsWith("[download] Destination:")) listeners.forEach(fn => fn("reset")); if (line.startsWith("[download] Destination:")) {
listeners.forEach(fn => fn("reset"));
stageListeners.forEach(fn => fn("Downloading..."));
}
const match = line.match(/\[download\]\s+(\d+\.\d+)%/); const match = line.match(/\[download\]\s+(\d+\.\d+)%/);
if (match) listeners.forEach(fn => fn(parseFloat(match[1]))); if (match) listeners.forEach(fn => fn(parseFloat(match[1])));
@@ -44,6 +210,30 @@ function fetchDownload(options, listeners, speedListeners) {
} }
} }
// Playlist item tracking
const itemMatch = line.match(/Downloading\s+(?:item|video)?\s*(\d+)\s+of\s+(\d+)/i);
if (itemMatch) {
const itemInfo = `Item ${itemMatch[1]} of ${itemMatch[2]}`;
playlistInfoListeners.forEach(fn => fn(itemInfo));
}
// Stage messages
if (line.includes("[Merger]")) {
stageListeners.forEach(fn => fn("Merging formats..."));
}
if (line.includes("[ExtractAudio]")) {
stageListeners.forEach(fn => fn("Extracting audio..."));
}
if (line.includes("[Metadata]")) {
stageListeners.forEach(fn => fn("Adding metadata..."));
}
if (line.includes("[ThumbnailsConvertor]")) {
stageListeners.forEach(fn => fn("Converting thumbnail..."));
}
if (line.includes("[EmbedThumbnail]")) {
stageListeners.forEach(fn => fn("Embedding thumbnail..."));
}
if (line.match(/ERROR: Could not copy .* cookie database/i)) { if (line.match(/ERROR: Could not copy .* cookie database/i)) {
notify.notifyCookiesBrowserError(); notify.notifyCookiesBrowserError();
} }
@@ -59,4 +249,41 @@ function fetchDownload(options, listeners, speedListeners) {
}); });
} }
module.exports = { fetchDownload }; /**
* Cancels the currently running yt-dlp process.
*
* Attempts to terminate the entire process tree to ensure
* that yt-dlp and any spawned FFmpeg processes are stopped.
*
* @returns {boolean}
* True if a download process was cancelled,
* otherwise false.
*/
function cancelDownload() {
if (currentDownloadProcess) {
logger.info("Cancelling download and killing all child processes...");
// Force kill the process and all its children with SIGKILL
try {
// Try to kill with SIGKILL on Windows (process group) or Unix
if (process.platform === 'win32') {
// On Windows, kill the process tree
require('child_process').execSync(`taskkill /PID ${currentDownloadProcess.pid} /T /F`, { stdio: 'ignore' });
} else {
// On Unix, send SIGKILL to process group
process.kill(-currentDownloadProcess.pid, 'SIGKILL');
}
} catch (err) {
logger.warn(`Error killing process: ${err.message}`);
// Fallback to regular kill
currentDownloadProcess.kill('SIGKILL');
}
currentDownloadProcess = null;
logger.info("Download cancelled successfully");
return true;
}
return false;
}
module.exports = { fetchDownload, cancelDownload };

View File

@@ -1,10 +1,32 @@
const { execFile } = require("child_process"); const { execFile } = require("child_process");
const { userYtDlp, denoPath } = require("../helpers/path"); const { userYtDlp, denoPath } = require("../helpers/path.helpers");
const getUserBrowser = require("../helpers/getBrowser"); const getUserBrowser = require("../helpers/getBrowser.helpers");
const { logger } = require("../logger"); const { logger } = require("../logger");
/**
* Retrieves metadata for a video or playlist using yt-dlp.
*
* The command executes yt-dlp in JSON mode and returns the
* parsed response object without downloading any media.
*
* Features:
* - Supports videos and playlists
* - Imports browser cookies when available
* - Uses Deno for JavaScript extraction
* - Applies a 30-second timeout
* - Limits response size to 10 MB
*
* @param {string} url - Video or playlist URL to inspect.
* @returns {Promise<Object>} Resolves with the metadata returned by yt-dlp.
*
*/
function fetchInfo(url) { function fetchInfo(url) {
const args = [
/**
* Base yt-dlp arguments used to retrieve metadata
* without downloading media files.
*/
const metadataArgs = [
"--js-runtimes", `deno:${denoPath}`, "--js-runtimes", `deno:${denoPath}`,
"--dump-single-json", "--dump-single-json",
"--ignore-errors", "--ignore-errors",
@@ -16,9 +38,9 @@ function fetchInfo(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
execFile(userYtDlp,[...args, url], execFile(userYtDlp,[...metadataArgs , url],
{ timeout: 30_000, // 30s, si jamais plus, abandon de la requête { timeout: 30_000, // 30s, if more timeout
maxBuffer: 10 * 1024 * 1024 }, // 10MO de réponse max (Par défaut: 200ko) maxBuffer: 10 * 1024 * 1024 }, // Maximum response size: 10 MB (Node.js default is much smaller)
(error, stdout, stderr) => { (error, stdout, stderr) => {

View File

@@ -1,47 +0,0 @@
const { autoUpdater } = require("electron-updater");
const { app, Notification, shell} = require("electron");
const { logger } = require("./logger");
function AutoUpdater() {
autoUpdater.on("update-available", (info) => {
logger.info(`New Version Available : ${info.version}`);
new Notification({
title: "Freedom Loader",
body: `New Version Available : ${info.version}. Application will restart`
}).show();
});
autoUpdater.on("update-downloaded", (info) => {
logger.info(`Update Downloaded : ${info.version}`);
new Notification({
title: "Freedom Loader",
body: `Update ${info.version} downloaded.`
}).on("click", () =>
shell.openExternal("https://github.com/MasterAcnolo/Freedom-Loader/releases/latest")
).show();
setTimeout(() => {
autoUpdater.quitAndInstall();
}, 5000);
});
autoUpdater.on("error", (err) => {
logger.error("Auto Update Error :", err.message);
new Notification({
title: "Freedom Loader - Update Error",
body: err.message
}).show();
});
app.whenReady().then(async () => {
try {
await autoUpdater.checkForUpdates();
logger.info("Update check completed");
} catch (err) {
logger.error("Error during update check :", err.message);
}
});
}
module.exports = { AutoUpdater };

BIN
theme/dark.zip Normal file

Binary file not shown.

BIN
theme/light.zip Normal file

Binary file not shown.

96
theme/template.theme.json Normal file
View File

@@ -0,0 +1,96 @@
{
"meta": {
"name": "",
"author": "",
"version": "1.0.0",
"formatVersion": "1.0"
},
"style": {
"background": {
"image": "",
"size": "cover",
"position": "center",
"attachment": "fixed"
},
"colors": {
"background": "",
"text": {
"default": "",
"subtitle": "",
"audioOnly": ""
}
},
"form": {
"background": "",
"input": {
"background": "",
"border": "",
"borderFocus": "",
"text": "",
"placeholder": ""
},
"button": {
"background": "",
"text": "",
"hover": ""
},
"pasteButtonIcon": ""
},
"checkbox": {
"background": {
"unchecked": "",
"checked": ""
},
"checkmarkBorder": "",
"pulse": ""
},
"download": {
"status": ""
},
"progressBar": {
"background": "",
"fill": ""
},
"videoInfo": {
"background": "",
"text": "",
"heading": "",
"list": "",
"strong": "",
"link": "",
"linkHover": ""
},
"playlist": {
"background": ""
},
"settings": {
"button": {
"background": "",
"text": ""
},
"openThemeButton": {
"background": "",
"text": ""
},
"openJsonButton": {
"background": "",
"text": ""
},
"background": {
"modal": "",
"section": ""
},
"text": "",
"subtitle": ""
}
}
}