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 @@
+
@@ -279,10 +280,14 @@
+ +
+

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(); }