diff --git a/public/index.html b/public/index.html
index fd147e0..181659d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -250,6 +250,7 @@
+
+
+
Because Why Not ?
+
diff --git a/public/script/downloadstatus.js b/public/script/downloadstatus.js
index add6b71..868ac9c 100644
--- a/public/script/downloadstatus.js
+++ b/public/script/downloadstatus.js
@@ -7,12 +7,24 @@ let userStoppedDownload = false;
form.addEventListener("submit", async (e) => {
e.preventDefault();
+
+ 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;
- stopBtn.style.display = "inline-flex";
- statusDiv.style.display = "none"; // Hide status div
+ statusDiv.style.display = "none";
isDownloading = true;
userStoppedDownload = false;
+ // Show progress UI immediately with initial message
+ showInitialProgress();
+
const formData = new FormData(form);
const params = new URLSearchParams(formData);
@@ -26,12 +38,15 @@ form.addEventListener("submit", async (e) => {
if (!res.ok) {
if (!userStoppedDownload) {
const errorText = await res.text();
- statusDiv.textContent = errorText;
- statusDiv.style.display = "block";
+ window.showError(errorText);
+ resetProgressBar();
}
return;
}
+ // Download successful
+ window.showSuccess("Download completed!");
+
} catch (err) {
if (!isDownloading && !userStoppedDownload) {
statusDiv.style.display = "none";
@@ -43,6 +58,29 @@ form.addEventListener("submit", async (e) => {
}
});
+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";
+}
+
async function stopDownload() {
if (!isDownloading) return;
@@ -56,6 +94,7 @@ async function stopDownload() {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
+ window.showWarning("Download stopped");
} catch (err) {
console.error("Error stopping download:", err);
} finally {
@@ -63,12 +102,20 @@ async function stopDownload() {
button.disabled = false;
stopBtn.disabled = false;
stopBtn.style.display = "none";
-
- // Reset progress bar
- const progressWrapper = document.getElementById("downloadProgressWrapper");
- if (progressWrapper) progressWrapper.style.display = "none";
-
- const progressBar = document.getElementById("downloadProgress");
- if (progressBar) progressBar.style.width = "0%";
+ resetProgressBar();
}
+}
+
+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";
}
\ No newline at end of file
diff --git a/public/script/progressBar.js b/public/script/progressBar.js
index abe6958..681315c 100644
--- a/public/script/progressBar.js
+++ b/public/script/progressBar.js
@@ -3,23 +3,32 @@ const progressBar = document.getElementById("downloadProgress");
const progressBarText = document.getElementById("downloadProgressText")
const speedElement = document.getElementById("downloadSpeedText");
+const stageElement = document.getElementById("downloadStage");
const speedEvt = new EventSource("/download/speed");
+const stageEvt = new EventSource("/download/stage");
function startProgress() {
- progressWrapper.style.display = "block";
progressBar.style.width = "0%";
- progressBarText.style.display = "block";
progressBarText.innerHTML = "0%";
-
- speedElement.style.display = "block";
speedElement.innerHTML = "0 Mib/s";
+}
+
+function showProgress() {
+ progressWrapper.style.display = "block";
+ progressBarText.style.display = "block";
+ speedElement.style.display = "block";
// Show stop button
const stopBtn = document.getElementById("stopBtn");
- if (stopBtn) stopBtn.style.display = "inline-block";
+ if (stopBtn) stopBtn.style.display = "inline-flex";
}
function updateProgress(percent) {
+ // Show progress div and stop button only when percent > 0
+ if (percent > 0 && progressWrapper.style.display !== "block") {
+ showProgress();
+ }
+
progressBar.style.width = `${percent}%`;
progressBarText.innerHTML = `${percent}%`;
}
@@ -33,6 +42,9 @@ function resetProgress() {
speedElement.textContent = "0 Mib/s";
speedElement.style.display = "none";
+ stageElement.textContent = "";
+ stageElement.style.display = "none";
+
// Hide stop button
const stopBtn = document.getElementById("stopBtn");
if (stopBtn) stopBtn.style.display = "none";
@@ -48,8 +60,11 @@ evtSource.onmessage = e => {
}
if (e.data === "done") {
- resetProgress();
- window.electronAPI.setProgress(-1);
+ // Keep progress bar visible for better UX, let toast handle the feedback
+ setTimeout(() => {
+ resetProgress();
+ window.electronAPI.setProgress(-1);
+ }, 2000); // Wait 2s so user sees 100% progress
return;
}
@@ -58,14 +73,16 @@ evtSource.onmessage = e => {
if (!isNaN(percent)) {
updateProgress(percent);
window.electronAPI.setProgress(percent); // Update Task Bar
- if (percent >= 100) setTimeout(() => {
- resetProgress();
- window.electronAPI.setProgress(-1); // Remove the bar
- }, 500);
+ // Don't hide at 100%, wait for "done" signal instead
}
};
speedEvt.onmessage = e => {
speedElement.style.display = "block";
speedElement.textContent = e.data; // ex: "5.2MiB/s"
+};
+
+stageEvt.onmessage = e => {
+ stageElement.style.display = "block";
+ stageElement.textContent = e.data; // ex: "📥 Downloading..."
};
\ No newline at end of file
diff --git a/public/script/toast.js b/public/script/toast.js
new file mode 100644
index 0000000..0cb3822
--- /dev/null
+++ b/public/script/toast.js
@@ -0,0 +1,54 @@
+window.showToast = function(message, type = 'info', duration = 4000) {
+ const container = document.getElementById('toast-container');
+ if (!container) return;
+
+ // Create toast element
+ const toast = document.createElement('div');
+ toast.classList.add('toast', type);
+
+ // Icons for different types
+ const icons = {
+ success: '✓',
+ error: '✕',
+ warning: '⚠',
+ info: 'ℹ'
+ };
+
+ const icon = icons[type] || icons.info;
+
+ toast.innerHTML = `
+ ${icon}
+ ${message}
+
+ `;
+
+ // Add close functionality
+ const closeBtn = toast.querySelector('.toast-close');
+ closeBtn.addEventListener('click', () => removeToast(toast));
+
+ // Add to container
+ container.appendChild(toast);
+
+ // Auto remove after duration
+ if (duration > 0) {
+ setTimeout(() => removeToast(toast), duration);
+ }
+
+ return toast;
+};
+
+function removeToast(toast) {
+ if (!toast.parentElement) return;
+
+ toast.classList.add('removing');
+ setTimeout(() => {
+ if (toast.parentElement) {
+ toast.remove();
+ }
+ }, 300);
+}
+
+window.showSuccess = (message, duration = 4000) => window.showToast(message, 'success', duration);
+window.showError = (message, duration = 5000) => window.showToast(message, 'error', duration);
+window.showWarning = (message, duration = 4000) => window.showToast(message, 'warning', duration);
+window.showInfo = (message, duration = 4000) => window.showToast(message, 'info', duration);
diff --git a/public/styles/components/progressBar.css b/public/styles/components/progressBar.css
index a16d55f..5130afb 100644
--- a/public/styles/components/progressBar.css
+++ b/public/styles/components/progressBar.css
@@ -37,7 +37,7 @@
font-size: 0.8rem;
color: #fff;
content: "";
-
+ display: none;
}
/* Responsive pour mobile */
diff --git a/public/styles/components/toast.css b/public/styles/components/toast.css
new file mode 100644
index 0000000..5972c0b
--- /dev/null
+++ b/public/styles/components/toast.css
@@ -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;
+ }
+}
diff --git a/public/styles/styles.css b/public/styles/styles.css
index a8776ba..2ace654 100644
--- a/public/styles/styles.css
+++ b/public/styles/styles.css
@@ -11,6 +11,7 @@
@import url("components/loader.css");
@import url("components/editpathbutton.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');
diff --git a/server/controller/download.controller.js b/server/controller/download.controller.js
index 68deb6a..dcc2dc2 100644
--- a/server/controller/download.controller.js
+++ b/server/controller/download.controller.js
@@ -5,6 +5,7 @@ const { notifyDownloadFinished } = require("../helpers/notify.helpers");
const listeners = [];
const speedListeners = [];
+const stageListeners = [];
async function downloadController(req, res) {
try {
@@ -15,16 +16,16 @@ async function downloadController(req, res) {
outputFolder: req.body.savePath || 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)) {
logger.warn(`Unsafe download path rejected: ${options.outputFolder}`);
return res.status(400).send("❌ Save Path Not Allowed.");
}
// Get output folder when the download is finished
- const outputFolder = await fetchDownload(options, listeners, speedListeners);
+ const outputFolder = await fetchDownload(options, listeners, speedListeners, stageListeners);
notifyDownloadFinished(outputFolder);
- res.send("✅ Download Done !");
+ res.send("Download Done !");
} catch (err) {
logger.error(`Server Error in /download : ${err.message}`);
@@ -64,6 +65,22 @@ function speedController(req, res) {
});
}
+function stageController(req, res) {
+ res.set({
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ });
+
+ const sendStage = stage => res.write(`data: ${stage}\n\n`);
+ stageListeners.push(sendStage);
+
+ req.on('close', () => {
+ stageListeners.splice(stageListeners.indexOf(sendStage), 1);
+ res.end();
+ });
+}
+
function cancelDownloadController(req, res) {
const cancelled = cancelDownload();
if (cancelled) {
@@ -75,4 +92,4 @@ function cancelDownloadController(req, res) {
}
}
-module.exports = { downloadController, progressController, speedController, cancelDownloadController};
+module.exports = { downloadController, progressController, speedController, stageController, cancelDownloadController};
diff --git a/server/controller/info.controller.js b/server/controller/info.controller.js
index f9b0fe9..beb85da 100644
--- a/server/controller/info.controller.js
+++ b/server/controller/info.controller.js
@@ -10,7 +10,7 @@ async function infoController(req, res){
// If no url, non-string url or invalid url
- if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("❌ Invalid URL Or Missing");
+ if (!url || typeof url !== "string" || !isValidUrl(url)) return res.status(400).send("Invalid URL Or Missing");
logger.info(`/Info Request receive by the Info Controller. URL: ${url}`);
diff --git a/server/routes/download.route.js b/server/routes/download.route.js
index 24267ab..5310285 100644
--- a/server/routes/download.route.js
+++ b/server/routes/download.route.js
@@ -1,6 +1,6 @@
const express = require("express");
const router = express.Router();
-const { downloadController, progressController, speedController, cancelDownloadController } = require("../controller/download.controller");
+const { downloadController, progressController, speedController, stageController, cancelDownloadController } = require("../controller/download.controller");
router.post("/", downloadController);
router.post("/cancel", cancelDownloadController);
@@ -8,5 +8,6 @@ router.post("/cancel", cancelDownloadController);
// SSE for download progress
router.get("/progress", progressController);
router.get("/speed", speedController);
+router.get("/stage", stageController);
module.exports = router;
\ No newline at end of file
diff --git a/server/services/download.services.js b/server/services/download.services.js
index 5df2923..a258282 100644
--- a/server/services/download.services.js
+++ b/server/services/download.services.js
@@ -9,7 +9,7 @@ const { isSafePath } = require("../helpers/validation.helpers");
let currentDownloadProcess = null;
-function fetchDownload(options, listeners, speedListeners) {
+function fetchDownload(options, listeners, speedListeners, stageListeners) {
return new Promise((resolve, reject) => {
const outputFolder = options.outputFolder || defaultDownloadFolder;
@@ -54,7 +54,10 @@ function fetchDownload(options, listeners, speedListeners) {
logger.info(`[yt-dlp] ${line}`);
// 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+)%/);
if (match) listeners.forEach(fn => fn(parseFloat(match[1])));
@@ -66,6 +69,23 @@ function fetchDownload(options, listeners, speedListeners) {
}
}
+ // 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)) {
notify.notifyCookiesBrowserError();
}