/* frontend/static/js/app.js – synced with backend changes */ /* jul 15 2025 */ const $ = (id) => document.getElementById(id); const qs = (s) => encodeURIComponent(s); const anime = window?.anime; /* DOM references */ const urlForm = $("urlForm"); const urlInput = $("downloadURL"); const fmtCard = $("formatCard"); const platBadge = $("platformBadge"); const fmtOpt = $("formatOptions"); const hidUrl = $("hiddenUrl"); const hidSid = $("hiddenSid"); const dlBtn = $("downloadBtn"); const cancelBtn = $("cancelBtn"); const fmtTitle = $("formatTitle"); const fmtForm = $("formatForm"); const closeBtn = $("closeFormatCard"); /* Mutable state */ let fetching = false; let downloading = false; let esStream = null; const sizeCache = Object.create(null); let baseSid = sessionStorage.getItem("sid"); if (!baseSid) { baseSid = crypto.randomUUID(); sessionStorage.setItem("sid", baseSid); } let sidSeq = 0; const newSid = () => `${baseSid}-${(sidSeq++).toString(36)}`; const hide = (el) => { el.classList.add("hidden"); el.style.display = "none"; }; const show = (el, display="block") => { el.classList.remove("hidden"); el.style.display = display; }; const errBlink = (el) => { el.classList.add("erroring"); el.addEventListener("animationend", () => el.classList.remove("erroring"), { once:true }); }; function resetBtn() { dlBtn.disabled = true; dlBtn.classList.remove("loading"); dlBtn.textContent = "Download"; } /* Fade‑out animation for format card */ function fadeOutCard() { if (fmtCard.classList.contains("hidden")) return; anime({ targets: fmtCard, opacity: [1,0], translateY: [0,-10], easing: "easeInQuad", duration: 300, complete: () => { hide(fmtCard); fmtOpt.innerHTML = ""; } }); platBadge.style.display = "none"; } function resetUI() { downloading = false; fadeOutCard(); resetBtn(); urlInput.value = ""; urlInput.disabled = false; urlInput.classList.remove("fetching"); urlInput.focus(); } /* SSE helpers */ function closeStream() { esStream?.close(); esStream = null; } function startSSE(sid, onFinish = () => {}) { closeStream(); esStream = new EventSource(`/api/progress/${sid}`); esStream.onmessage = ({ data }) => { const j = JSON.parse(data); if (["finished","error","cancelled","cached"].includes(j.status)) { finish(); onFinish(); } }; esStream.onerror = closeStream; } /* Finalise flow (download finished / errored / cancelled) */ function finish() { closeStream(); resetUI(); } /* Cancel current download or dismiss card */ function cancelFlow() { if (downloading) { fetch(`/cancel_download?sid=${hidSid.value}`, { method:"POST" }) .finally(finish); } else { fadeOutCard(); } } urlInput.addEventListener("keydown", (e) => { if (e.key !== "Enter") return; e.preventDefault(); if (!fetching && !downloading) urlForm.requestSubmit(); }); urlForm.addEventListener("submit", async (e) => { e.preventDefault(); if (fetching || downloading) return; const raw = urlInput.value.trim(); if (!raw) { errBlink(urlInput); return; } fetching = true; urlInput.classList.add("fetching"); urlInput.disabled = true; const maxRetries = 3; let lastError = null; let result = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const r = await fetch("/choose_format", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: raw }) }); result = await r.json().catch(() => ({ error: "invalid json" })); if (r.ok && result && !result.error && Array.isArray(result.formats)) { break; } lastError = result?.error || "Unknown error"; } catch (err) { lastError = "Network error"; } if (attempt < maxRetries) { await new Promise(res => setTimeout(res, 750 * attempt)); } } if (!result || result.error || !Array.isArray(result.formats)) { errBlink(urlInput); console.warn("choose_format failed:", lastError); fetching = false; urlInput.classList.remove("fetching"); urlInput.disabled = false; return; } if (!result.sid) { errBlink(urlInput); console.error("Missing sid from server response"); fetching = false; urlInput.classList.remove("fetching"); urlInput.disabled = false; return; } const sid = result.sid; hidSid.value = sid; // ✅ Use server-issued SID only hidUrl.value = result.url; fmtTitle.textContent = result.title || "Select format"; const platform = (result.platform || "other").toLowerCase(); platBadge.textContent = platform === "twitterx" ? "X / Twitter" : platform[0].toUpperCase() + platform.slice(1); platBadge.className = `radix-badge badge-${platform}`; show(platBadge, "inline-flex"); platBadge.style.opacity = 0; anime({ targets: platBadge, translateY: [-10, 0], opacity: [0, 1], duration: 300 }); fmtOpt.innerHTML = ""; const sourceUrl = result.url; result.formats.forEach((f) => { const row = document.createElement("div"); row.className = "format-option"; const radio = document.createElement("input"); radio.type = "radio"; radio.id = `f_${f.format_id}`; radio.name = "format_id"; radio.value = f.format_id; const label = document.createElement("label"); label.htmlFor = radio.id; label.textContent = f.label; row.append(radio, label); // Size prefetch let hoverTimer = null; const fetchSize = async () => { if (sizeCache[f.format_id] || downloading) return; try { const szResp = await fetch(`/format_size?url=${qs(sourceUrl)}&fmt_id=${f.format_id}`); const js = await szResp.json(); if (szResp.ok && js.size) { const mb = js.size / 1_048_576; sizeCache[f.format_id] = `≈ ${(mb > 99 ? Math.round(mb) : mb.toFixed(1))}\u00A0MB`; label.dataset.size = sizeCache[f.format_id]; } } catch {} }; row.addEventListener("mouseenter", () => hoverTimer = setTimeout(fetchSize, 1200)); row.addEventListener("mouseleave", () => clearTimeout(hoverTimer)); radio.addEventListener("change", () => { dlBtn.disabled = false; }); fmtOpt.append(row); }); show(fmtCard); fmtCard.style.opacity = 0; anime({ targets: fmtCard, opacity: [0, 1], translateY: [-10, 0], duration: 300 }); fetching = false; urlInput.classList.remove("fetching"); }); fmtForm.addEventListener("submit", (e) => { e.preventDefault(); if (downloading) return; const fmtId = new FormData(fmtForm).get("format_id"); if (!fmtId) { errBlink(dlBtn); return; } downloading = true; const sid = hidSid.value; dlBtn.disabled = true; dlBtn.classList.add("loading"); dlBtn.textContent = "Downloading"; startSSE(sid); const ifr = document.createElement("iframe"); ifr.style.display = "none"; ifr.src = `/download_file?sid=${sid}&url=${qs(hidUrl.value)}&format_id=${fmtId}`; document.body.appendChild(ifr); /* fadeoutcard not needed */ fadeOutCard(); }); cancelBtn.addEventListener("click", cancelFlow); closeBtn .addEventListener("click", cancelFlow);