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:
1 parent
3f715434f0
commit
ff8311c1a8
4 files changed
+178
-59
No files matched your search
+99
-43
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user