This commit is contained in:
cash
2026-03-29 23:50:49 -05:00
commit eb5e194331
56 changed files with 4010 additions and 0 deletions

1
frontend/static/js/anime.min.js vendored Normal file

File diff suppressed because one or more lines are too long

259
frontend/static/js/app.js Normal file
View File

@@ -0,0 +1,259 @@
/* 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";
}
/* Fadeout 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);