Status pill + git-SHA asset versioning

- _origin_probe(): 3-min check of anidb.app pages/API; OK/DOWN + reason
  in GET /api/cdn and server.log ([probe] ORIGIN ...)
- header pill: upstream-site dot (red = down) + CDN dot + label;
  per-dot hover tooltips, tap -> toast legend (mobile has no hover)
- ?v={{VERSION}} injected from 'git describe --always --dirty' at
  startup; JSV reads it from its own script URL - replaces hand-bumped
  numbers (JSV/?v drift) and gives play-probes the running commit
This commit is contained in:
jozamudi committed 2026-09-03 16:33:51 +00:00
1 parent 3f715434f0
commit ff8311c1a8
4 files changed
+178 -59

No files matched your search

+99 -43
View File
@@ -1,6 +1,8 @@
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
const JSV = 14; // bump with every app.js change; play-probe reports it so a
// stale-cache phone is detectable server-side
// JSV = this file's cache-bust version (the server injects the git SHA into
// its ?v= URL), reported by play-probes so a stale-cache phone is
// detectable server-side. No hand-bumping.
const JSV = ((document.currentScript && document.currentScript.src).match(/[?&]v=([^&]+)/) || [])[1] || "unknown";
// BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/)
const BASE = (() => {
@@ -31,6 +33,16 @@ function el(tag, cls, text) {
return e;
}
// lazy poster img with a broken-image fallback (a dead upstream poster URL
// must not leave a broken-image icon in an otherwise fine card)
function poster(src, alt) {
const i = document.createElement("img");
i.alt = alt || "";
if (src) { i.src = src; i.loading = "lazy"; i.decoding = "async"; }
i.onerror = () => i.replaceWith(el("div", "ph-img"));
return i;
}
function fail(err) {
main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message));
@@ -129,10 +141,7 @@ const homeCard = x => {
const done = histClass(x) === "done", hold = histClass(x) === "hold";
const c = el("a", "card" + (hold ? " hold" : ""));
c.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
c.append(poster(x.poster, x.title));
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
if (done) meta += " \u00b7 \u2713";
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
@@ -233,31 +242,53 @@ function saveSearch(q) {
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
function renderSearchResults(q, results, announce = true) {
document.title = q + " \u2014 ani-web";
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) {
const p = el("p", "muted", "No results.");
main.append(p);
recentChips(p);
focusMain();
return;
} else {
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
card.append(poster(r.poster, r.name), el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
const img = el("img", null, r.name);
img.src = r.poster || "";
card.append(img, el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
focusMain();
if (announce) focusMain(); // focus-steal breaks Enter-to-submit mid-keystroke
}
async function showSearch(q) {
saveSearch(q);
skel(8);
renderSearchResults(q, await api(`/api/search?q=${encodeURIComponent(q)}`));
}
// search-as-you-type: debounced /api/search rendered in place (Enter still
// commits to ?q= so results are shareable/reloadable)
let liveTok = 0;
function initLive() {
let t = null;
const q = document.getElementById("q");
q.addEventListener("input", () => {
const v = q.value.trim();
clearTimeout(t);
if (v.length < 2) return;
t = setTimeout(async () => {
const tok = ++liveTok;
try {
const results = await api(`/api/search?q=${encodeURIComponent(v)}`);
if (tok !== liveTok) return; // superseded by a newer keystroke
saveSearch(v);
renderSearchResults(v, results, false); // keep focus in #q so Enter still submits
} catch { /* failed or stale — Enter still works */ }
}, 300);
});
}
// ---------- recent searches dropdown ----------
@@ -349,6 +380,7 @@ async function showAnime(slug_id, opts = {}) {
const jump = el("input", "spd");
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
jump.placeholder = "E#"; jump.title = "jump to episode";
jump.ariaLabel = "jump to episode";
jump.style.width = "4.5rem";
jump.onchange = () => {
const t = epsList.find(e => e.number == +jump.value);
@@ -450,10 +482,7 @@ function histCard(x) {
const hold = histClass(x) === "hold";
const c = el("div", "card hist" + (hold ? " hold" : ""));
c.onclick = () => location.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
c.append(poster(x.poster, x.title));
const nm = el("a", "card-name", x.title);
nm.href = histHref(x);
const info = el("div", "mini-info");
@@ -529,10 +558,7 @@ function topAnime(d, n = 0) {
ta.append(el("h2", null, "Most watched"));
for (const a of list) {
const row = el("div", "stat-anime");
if (a.poster) {
const i = el("img"); i.src = a.poster; i.alt = "";
row.append(i);
} else row.append(el("div", "sa-ph"));
row.append(poster(a.poster, a.title));
const t1 = el("div", "sa-top");
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
row.append(t1,
@@ -792,16 +818,44 @@ function bufferGauge() {
bufTimer = setInterval(tick, 250);
}
function cdnChip() {
// corner badge: live CDN throughput from /api/cdn (dot: green=OK, amber=degraded)
const p = document.getElementById("player");
let c = p.querySelector(".pl-cdn");
if (!c) { c = el("div", "pl-cdn"); c.append(el("i")); p.append(c); }
function statusPill() {
// header badge: upstream site + CDN status from /api/cdn. Lives in the
// (sticky) header so it's visible on the home screen — if the upstream is
// down you never reach the player, so that's where the status must live.
const p = document.getElementById("pill");
if (!p) return;
api("/api/cdn").then(d => {
c.className = "pl-cdn " + (d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "");
c.lastChild.textContent = d.kbps + " KB/s";
c.title = "CDN " + d.state + " — " + d.kbps + " KB/s";
}).catch(() => { c.className = "pl-cdn"; c.lastChild.textContent = "CDN ?"; });
const o = d.origin.state === "OK" ? "ok" : d.origin.state === "DOWN" ? "down" : "";
const c = d.state === "OK" ? "ok" : d.state === "DEGRADED" ? "degraded" : "";
p.className = "pill" + (o === "down" ? " odown" : "");
const od = p.querySelector(".o"), cd = p.querySelector(".c");
od.className = "o " + o;
cd.className = "c " + c;
p.querySelector("span").textContent =
o === "down" ? "upstream down" : c === "degraded" ? "cdn slow" : "up";
// per-dot hover tooltips (desktop) + full aria label (a11y / screen readers)
od.title = "upstream site (anidb.app pages + API): " + d.origin.state
+ (d.origin.msg ? " — " + d.origin.msg : "");
cd.title = "video CDN (hls.anidb.app) throughput: " + d.state
+ " — " + d.kbps + " KB/s";
p.title = ""; // dot-level titles carry the detail; pill-level would shadow them
p.setAttribute("aria-label", "upstream site " + d.origin.state
+ (d.origin.msg ? ": " + d.origin.msg : "")
+ "; CDN " + d.state + ", " + d.kbps + " KB/s");
// tap/click = tooltip for touch screens (mobile has no hover);
// read p._data at click time so the toast reflects the LATEST status,
// not the one captured when the handler was first bound
if (!p._bound) {
p._bound = 1;
p.onclick = e => {
e.stopPropagation();
const d = p._data;
toast("upstream site: " + d.origin.state + (d.origin.msg ? " (" + d.origin.msg + ")" : "")
+ " · CDN: " + d.state + " (" + d.kbps + " KB/s)");
};
}
p._data = d;
}).catch(() => { p.querySelector("span").textContent = "?"; });
}
// ---------- auto-next on episode end (5s countdown) ----------
@@ -876,7 +930,7 @@ async function play(ep_id, ep_no) {
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026", null, null, true);
bufferGauge();
cdnChip();
statusPill();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities.
try {
@@ -966,7 +1020,7 @@ async function play(ep_id, ep_no) {
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) {
playerOverlay("Buffering\u2026", null, null, true);
cdnChip(); // refresh CDN readout exactly when it matters
statusPill(); // refresh status readout exactly when it matters
}
});
v.addEventListener("ended", onEnded);
@@ -1061,6 +1115,7 @@ window.addEventListener("pagehide", savePos);
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
initLive();
const lo = document.getElementById("logout");
lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
@@ -1082,6 +1137,7 @@ window.addEventListener("pagehide", savePos);
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
};
} catch { /* not logged in: api() already redirected to /login */ }
statusPill();
await refreshHistory();
const p = new URLSearchParams(location.search);
try {