260 lines
7.8 KiB
JavaScript
260 lines
7.8 KiB
JavaScript
/* 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);
|