Files
ani-web/static/app.js
T
jozamudi 0315356f89 iOS root cause: start playback on BUFFER_APPENDED, not canplay
The live timeline caught it: iPhone buffered 513s, readyState stuck at
1, canplay NEVER fired -> play() never called -> eternal Loading.
Now: first BUFFER_APPENDED (hls.js's own 'data is in the pipe' signal)
triggers play, canplay still works, plus a 3s safety retry if still
paused with a non-empty buffer. play() calls are guarded + idempotent.
Desktop + mobile emu regression: playing. assets v=14.
2026-08-31 21:01:26 +00:00

1099 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// BASE = this page's directory, so the app works at / or behind a
// prefixing reverse proxy (e.g. https://host/ani/)
const BASE = (() => {
const p = location.pathname;
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
})();
const main = document.getElementById("main");
let hls = null, detail = null, lang = "jpn", seasonId = null;
async function api(path) {
const r = await fetch(BASE + path.replace(/^\//, ""), {headers: {"X-Aniweb": "1"}});
if (r.status === 401) {
location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("not authenticated");
}
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).detail || msg; } catch {}
throw new Error(msg);
}
return r.json();
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
}
function fail(err) {
main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message));
const row = el("div", "chips");
const home = el("button", "tiny", "home");
home.onclick = () => location.href = BASE;
const retry = el("button", "tiny", "retry");
retry.onclick = () => location.reload();
row.append(home, retry);
main.append(row);
focusMain();
}
function focusMain() {
const t = main.querySelector("h2") || main;
t.tabIndex = -1;
t.focus({preventScroll: true});
}
function skel(n = 8) {
main.innerHTML = "";
const g = el("div", "grid");
for (let i = 0; i < n; i++) g.append(el("div", "card skel"));
main.append(g);
}
function toast(msg, action) {
const t = el("div", "toast");
t.append(el("span", null, msg));
if (action) {
const b = el("button", "tiny", action.label);
b.onclick = () => { t.remove(); action.fn(); };
t.append(b);
}
document.getElementById("toasts").append(t);
setTimeout(() => t.remove(), action ? 6000 : 2600);
}
function showStreamError(msg, retry) {
// visible overlay + Retry — never leave a silent spinner (this is what
// an unplayable browser used to hit: 'HLS not supported' under the overlay)
// + report the client's capabilities so a failing browser is diagnosable
// from server.log instead of by guesswork
try {
postEvent({type: "client-fail", jsv: JSV, msg,
ua: navigator.userAgent,
hls_lib: typeof window.Hls,
mse: (window.Hls && Hls.isSupported) ? Hls.isSupported() : null,
native_hls: document.createElement("video").canPlayType("application/vnd.apple.mpegurl") || "none"});
} catch {}
playerOverlay("Stream error: " + msg, retry || (() => { if (currentEp) play(currentEp.ep_id, currentEp.ep_no); }), "Retry");
}
// ---------- history (server-persisted, incl. watch position) ----------
let history = [];
async function refreshHistory() {
// ?all=1: resume positions live beyond the top-5 cap
try { history = await api("/api/history?all=1"); } catch { /* home just shows empty */ }
}
function postHistory(entry) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry)}).catch(() => {});
}
function postEvent(ev) {
fetch(BASE + "api/events", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(ev)}).catch(() => {});
}
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const timeago = t => {
if (!t) return "";
const d = Math.max(0, Date.now() / 1000 - t);
if (d < 90) return "just now";
if (d < 3600) return `${Math.round(d / 60)}m ago`;
if (d < 86400) return `${Math.round(d / 3600)}h ago`;
return `${Math.round(d / 86400)}d ago`;
};
const prog = x => x.dur > 0 ? Math.min(100, Math.round(100 * x.pos / x.dur)) : null;
const bar = (x, cls) => {
const p = prog(x);
if (p == null) return null;
const b = el("div", "bar" + (cls ? " " + cls : ""));
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b;
};
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
// one entry per anime (latest row wins), newest first
const byShow = () => {
const m = new Map();
for (const x of history) {
const k = x.slug_id + ":" + x.season_id;
if (!m.has(k) || x.t > m.get(k).t) m.set(k, x);
}
return [...m.values()].sort((a, b) => b.t - a.t);
};
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);
}
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`;
else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
return c;
};
const showSec = (title, items, extra) => {
if (!items.length) return;
const head = el("div", "sec-head");
head.append(el("h2", null, title));
for (const a of extra) head.append(a);
const grid = el("div", "grid mini");
for (const x of items) grid.append(homeCard(x));
main.append(head, grid);
};
const link = (text, href) => Object.assign(el("a", null, text), {href: BASE + href});
// recent-search chips under a "no results" / empty placeholder (shared by home + search)
function recentChips(placeholder) {
const p = el("p", "muted", "loading…");
placeholder.after(p);
api("/api/searches").then(rs => {
if (!rs.length) { p.remove(); return; }
const d = el("div", null);
d.append(el("div", "muted", "recent searches"));
const chips = el("div", "chips");
for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q)));
d.append(chips);
p.replaceWith(d);
}).catch(() => p.remove());
}
function showHome() {
document.title = "ani-web";
main.innerHTML = "";
if (!history.length) {
const p = el("p", "muted empty", "Search for an anime to get started.");
main.append(p);
recentChips(p);
focusMain();
return;
}
const hero = history[0];
// 1. continue-watching hero
const sec = el("section", "hero-sec");
const card = el("a", "hero");
card.href = histHref(hero);
if (hero.poster) {
const img = el("img"); img.src = hero.poster; img.alt = "";
card.append(img);
}
const right = el("div", "right");
const body = el("div", "hero-body");
body.append(el("div", "hero-kicker", "continue watching"),
el("h2", null, hero.title),
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
(hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
` · ${timeago(hero.t)}`));
const p = bar(hero);
if (p) body.append(p);
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * 0.95;
const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
go.onclick = () => location.href = histHref(hero);
right.append(body, go);
sec.append(card, right);
main.append(sec);
// 2-4. status board: one card per anime, grouped by state
const shows = byShow().filter(x => x !== hero);
const cont = shows.filter(x => histClass(x) === "watching").slice(0, 6);
const hold = shows.filter(x => histClass(x) === "hold").slice(0, 4);
const done = shows.filter(x => histClass(x) === "done").slice(0, 6);
showSec("Continue watching", cont, [link("history \u2192", "?history")]);
showSec("On hold", hold, hold.length ? [link("history \u2192", "?history")] : []);
// watch-time heatmap (past year, shared with ?stats)
main.append(heatSection());
showSec("Recently finished", done, done.length ? [link("history \u2192", "?history")] : []);
// 5. stats line (compact; full breakdown at ?stats)
const sh = el("div", "sec-head");
sh.append(el("h2", null, "Stats \u00b7 30d"), link("see all \u2192", "?stats"),
link("history \u2192", "?history"));
const sl = el("div", "muted", "loading\u2026");
main.append(sh, sl);
focusMain();
api("/api/stats?days=30").then(d => {
sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` +
` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` +
` best streak ${d.best_streak}d \u00b7 ${d.on_hold} on hold`;
}).catch(() => sl.remove());
}
// ---------- search ----------
function saveSearch(q) {
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
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;
}
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();
}
// ---------- recent searches dropdown ----------
function initRecent() {
const q = document.getElementById("q");
const box = el("div", "recent");
q.closest("form").append(box);
let items = [];
const render = () => {
const v = q.value.trim().toLowerCase();
const list = items.filter(s => !v || s.toLowerCase().includes(v)).slice(0, 8);
box.replaceChildren();
if (!list.length) { box.classList.remove("open"); return; }
for (const s of list) {
const b = el("button", null, s);
b.onclick = () => { q.value = s; location.href = "?q=" + encodeURIComponent(s); };
box.append(b);
}
box.classList.add("open");
};
const hide = () => box.classList.remove("open");
q.addEventListener("focus", () => api("/api/searches").then(r => { items = r; render(); }).catch(hide));
q.addEventListener("input", render);
document.addEventListener("click", e => { if (!e.target.closest("header form")) hide(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") hide(); });
}
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
skel();
detail = await api(`/api/anime/${slug_id}`);
lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn";
seasonId = opts.season || detail.id;
detail.slug_id = slug_id;
document.title = detail.name + " \u2014 ani-web";
main.innerHTML = "";
const head = el("section", "detail-head");
const crumb = el("a", "crumb", "\u2190 home");
crumb.href = BASE;
head.append(crumb, el("h2", null, detail.name));
const progLine = el("div", "muted detail-prog");
head.append(progLine);
api(`/api/stats?days=30&slug=${encodeURIComponent(slug_id)}`)
.then(d => { if (d.top_anime[0]) progLine.textContent = animeLine(d.top_anime[0]); })
.catch(() => {});
main.append(head);
const player = el("div", "player");
player.id = "player";
player.append(videoEl());
main.append(player);
// "now playing" line: E-number + MAL episode title (name arrives async
// from Jikan, so setNowEp() refreshes it whenever the name lands)
const nowEp = el("div", "now-ep");
nowEp.id = "now-ep";
main.append(nowEp);
const controls = el("div", "controls");
const langBtn = el("button", null, langLabel());
langBtn.onclick = () => {
lang = lang === "eng" ? "jpn" : "eng";
langBtn.textContent = langLabel();
const cur = main.querySelector(".ep.active");
if (cur) play(cur.dataset.ep, cur.dataset.no);
};
const spd = el("select", "spd");
for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r));
spd.value = localStorage.getItem("aniweb_spd") || "1";
spd.title = "playback speed";
spd.onchange = () => {
localStorage.setItem("aniweb_spd", spd.value);
const v = document.querySelector("#player video");
if (v) v.playbackRate = +spd.value;
};
const prevB = el("button", "tiny", "◀ prev");
const nextB = el("button", "tiny", "next ▶");
prevB.onclick = () => stepEp(-1);
nextB.onclick = () => stepEp(1);
const fillB = el("button", "tiny" + (hideFiller ? " on" : ""), "hide fillers");
fillB.onclick = () => {
hideFiller = !hideFiller;
localStorage.setItem("aniweb_ff", hideFiller ? "1" : "0");
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
fillB.classList.toggle("on", hideFiller);
renderEps();
};
const jump = el("input", "spd");
jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
jump.placeholder = "E#"; jump.title = "jump to episode";
jump.style.width = "4.5rem";
jump.onchange = () => {
const t = epsList.find(e => e.number == +jump.value);
if (!t) { jump.value = ""; return; }
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === t.id);
if (b) markActive(b);
play(t.id, t.number);
jump.value = "";
};
const anB = el("button", "tiny" + (autoNext ? " on" : ""), "auto-next");
anB.title = "auto-play the next episode when one ends";
anB.onclick = () => {
autoNext = !autoNext;
localStorage.setItem("aniweb_auto", autoNext ? "1" : "0");
anB.classList.toggle("on", autoNext);
};
const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB];
if (document.pictureInPictureEnabled) {
const pipB = el("button", "tiny", "\u29c9 PiP");
pipB.title = "picture-in-picture";
pipB.onclick = () => {
const v = document.querySelector("#player video");
if (v && v.requestPictureInPicture) v.requestPictureInPicture().catch(() => {});
};
ctl.push(pipB);
}
controls.append(...ctl, el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
for (const s of detail.seasons) {
const t = el("button", "tab" + (s.id === seasonId ? " active" : ""), s.name);
t.dataset.id = s.id;
t.onclick = () => {
for (const x of tabs.children) x.classList.toggle("active", +x.dataset.id === s.id);
location.href = `?anime=${s.slug_id}&season=${s.id}&lang=${lang}`;
};
tabs.append(t);
}
main.append(tabs);
const eps = el("div", "eps");
eps.id = "eps";
eps.append(el("p", "muted", "loading episodes\u2026"));
main.append(eps);
// watched state: dim completed eps, highlight the first unwatched one
// (ponytail: "first unwatched in list order" is wrong if you skip around;
// good enough for sequential viewing)
epsList = await api(`/api/anime/${seasonId}/episodes`);
attachEpNames();
watchDone = new Set();
epProg = new Map();
let started = false;
try {
for (const x of await api("/api/history?all=1")) {
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
started = true;
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
}
} catch {}
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
renderEps();
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id;
if (start) {
const cur = [...eps.children].find(b => +b.dataset.ep === start);
if (cur) { markActive(cur); play(start, cur.dataset.no); }
}
// explicit CTA when the resumed episode is already finished
if (start && watchDone.has(String(start)) && watchNext != null && !nextCta) {
nextCta = el("button", "btn", "\u25b6 next episode");
nextCta.onclick = () => stepEp(1);
controls.append(nextCta);
}
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
focusMain();
}
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete + client-side filter) ----------
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
async function showHistory() {
document.title = "History \u2014 ani-web";
skel(10);
const items = await api("/api/history?all=1");
main.innerHTML = "";
main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
main.append(...histBoard(items));
focusMain();
}
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);
}
const nm = el("a", "card-name", x.title);
nm.href = histHref(x);
const info = el("div", "mini-info");
info.append(nm,
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
(hold ? " \u00b7 on hold" : "")));
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
const d = el("button", "del", "\u00d7");
d.title = "remove from history";
d.onclick = async e => {
e.preventDefault();
e.stopPropagation();
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id),
{method: "DELETE", headers: {"X-Aniweb": "1"}});
c.remove();
const i = history.indexOf(x);
if (i >= 0) history.splice(i, 1);
// undo = re-POST the same entry (the endpoint upserts on (user, slug, ep))
toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
};
c.append(d);
return c;
}
// ---------- stats (last 30 days, server-computed) ----------
const fmtMin = m => { m = Math.round(m || 0);
return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`; };
const statCard = (label, value) => {
const c = el("div", "card stat");
c.append(el("div", "stat-value", value), el("div", "muted", label));
return c;
};
const holdDays = a => Math.max(1, Math.round((Date.now() / 1000 - a.last_seen) / 86400));
const statusLabel = a => a.status === "finished" ? "\u2713 finished"
: a.status === "ongoing" ? "\u25b6 ongoing" : `\u23f8 on hold \u00b7 ${holdDays(a)}d`;
const animeMeta = a => {
const bits = [`E${a.cur_ep}${a.total ? "/" + a.total : ""}`, fmtMin(a.minutes)];
if (a.pace > 0) bits.push(`${a.pace} ep/day`);
const tot = (a.sub || 0) + (a.dub || 0);
if (tot) {
const pct = Math.round(100 * (a.sub || 0) / tot);
bits.push(pct >= 90 ? "sub" : pct <= 10 ? "dub" : `sub ${pct}%`);
}
if (a.rewatches > 0) bits.push(`\u21ba ${a.rewatches} rewatch${a.rewatches > 1 ? "s" : ""}`);
return bits.join(" \u00b7 ");
};
const animeLine = a => `${animeMeta(a)} \u00b7 ${statusLabel(a)}`;
// ---------- stats + history renderers (shared by pages and home widgets) ----------
function statsCards(d) {
const out = el("div", null);
const cards = el("div", "grid stats");
cards.append(
statCard("watch time", fmtMin(d.watch_minutes)),
statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`),
statCard("days active", String(d.days_active)),
statCard("on hold", `${d.on_hold} anime`),
statCard("best streak", `${d.best_streak} days`),
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`));
out.append(cards);
if (d.errors > 0) out.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`));
return out;
}
function topAnime(d, n = 0) {
const order = {ongoing: 0, hold: 1, finished: 2};
const list = [...d.top_anime].sort((x, y) =>
order[x.status] - order[y.status] || y.minutes - x.minutes);
if (n) list.length = Math.min(n, list.length);
if (!list.length) return null;
const ta = el("section", null);
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"));
const t1 = el("div", "sa-top");
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
row.append(t1,
el("span", "chip" + (a.status === "finished" ? " fin" : a.status === "ongoing" ? " on" : ""),
statusLabel(a)));
const b = el("div", "bar small");
const f = el("div", "bar-fill");
const pct = a.total ? Math.min(100, Math.round(100 * a.completed / a.total))
: (a.started ? Math.round(100 * a.completed / a.started) : 0);
f.style.width = pct + "%";
b.append(f);
row.append(b);
ta.append(row);
}
return ta;
}
function histBoard(items, limit = 0) {
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
let active = "all";
const chips = el("div", "chips");
const grid = el("div", "grid");
const draw = () => {
let f = items.filter(x => active === "all" || histClass(x) === active);
if (limit) f = f.slice(0, limit);
grid.replaceChildren(...f.map(histCard));
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
};
for (const f of Object.keys(labels)) {
const b = el("button", "chip", labels[f]);
b.dataset.f = f;
b.onclick = () => { active = f; draw(); };
chips.append(b);
}
draw();
return [chips, grid];
}
function heatMap(byDay, days) {
const today = new Date(); today.setHours(12, 0, 0, 0);
const win = new Date(today); win.setDate(win.getDate() - (days - 1));
const start = new Date(win);
start.setDate(start.getDate() - (start.getDay() + 6) % 7); // back to Monday
const max = Math.max(1, ...Object.values(byDay || {}));
const MN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const grid = el("div", "heat");
const lbl = el("div", "heat-lbl");
const dcol = el("div", "heat-days");
for (const t of ["Mon", "", "Wed", "", "Fri", "", ""]) dcol.append(el("span", null, t));
const row = el("div", "heat-row");
row.append(dcol, grid);
let lastCol = -10, colIdx = 0; // month label: week containing the 1st
for (let d = new Date(start); d <= today; ) {
const col = el("div", "heat-col");
const lab = el("span", "heat-lab");
for (let i = 0; i < 7; i++) {
const dd = new Date(d); dd.setDate(dd.getDate() + i);
if (dd.getDate() === 1 && dd >= win && dd <= today && colIdx - lastCol >= 2) {
lab.textContent = MN[dd.getMonth()];
lastCol = colIdx;
break; // one label per column, skip if it would crowd the previous
}
}
lbl.append(lab);
for (let i = 0; i < 7; i++) {
const c = el("i");
if (d > today) c.className = "none"; // pad the final column
else {
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const v = (byDay || {})[k] || 0;
if (d < win) c.className = "none";
else if (v) {
c.className = "l" + Math.min(4, Math.ceil(v / max * 4));
c.title = k + " \u00b7 " + fmtMin(v / 60);
}
d.setDate(d.getDate() + 1);
}
col.append(c);
}
grid.append(col);
colIdx++;
}
const lg = el("div", "heat-lg");
lg.append(el("span", "muted", "less"));
for (const cls of ["", "l1", "l2", "l3", "l4"]) {
const s = el("i");
if (cls) s.className = cls;
lg.append(s);
}
lg.append(el("span", "muted", "more"));
return [lbl, row, lg];
}
function heatSection() {
const sec = el("section", null);
const h = el("div", "sec-head");
h.append(el("h2", null, "Watch time by day"), el("span", "muted", "past year"));
const load = el("p", "muted", "loading\u2026");
sec.append(h, load);
api("/api/stats?days=365").then(d2 => {
load.remove();
const [l, g, leg] = heatMap(d2.by_day, 365);
const w = el("div", "heat-wrap");
w.append(l, g);
sec.append(w, leg);
}).catch(() => load.remove());
return sec;
}
async function renderStats(days) {
document.title = "Stats \u2014 ani-web";
skel(5);
const d = await api(`/api/stats?days=${days}`);
main.innerHTML = "";
main.append(el("h2", null, `Stats \u2014 last ${days === 365 ? "year" : days + " days"}`));
const chips = el("div", "chips");
for (const dd of [30, 90, 365]) {
const b = el("button", "chip" + (dd === days ? " on" : ""), dd === 365 ? "1y" : dd + "d");
b.onclick = () => renderStats(dd);
chips.append(b);
}
main.append(chips, statsCards(d));
main.append(heatSection());
const ta = topAnime(d);
if (ta) main.append(ta);
if (d.top_searches.length) {
main.append(el("section", null).append(
el("h2", null, "Top searches"),
el("div", "muted", d.top_searches.join(" \u00b7 "))));
}
focusMain();
}
function showStats() {
const p = +new URLSearchParams(location.search).get("days") || 0;
return renderStats([30, 90, 365].includes(p) ? p : 30);
}
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
}
// episode names (and intro timings) come from the same Jikan call the
// skip-intro button uses; server caches it 30d and the failure is silent
function attachEpNames() {
const mid = detail && detail.mal_id;
if (!mid) return;
let p = introCache.get(mid);
if (!p) { p = api(`/api/intros/${mid}`).then(t => t || []).catch(() => []); introCache.set(mid, p); }
p.then(list => {
let changed = false;
list.forEach((t, i) => { if (t && t.title && epsList[i]) { epsList[i].name = t.title; changed = true; } });
if (changed) { renderEps(); if (currentEp) setNowEp(currentEp.ep_id, currentEp.ep_no); }
});
}
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
let autoNext = localStorage.getItem("aniweb_auto") !== "0";
let watchDone = new Set(), watchNext = null, epProg = new Map();
function renderEps() {
const eps = document.getElementById("eps");
if (!eps) return;
eps.innerHTML = "";
for (const e of epsList) {
if (hideFiller && e.filler) continue;
const b = el("button", "ep" + (e.filler ? " filler" : ""));
const done = watchDone.has(String(e.id));
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
b.title = `E${e.number}${e.name ? " — " + e.name : ""}`;
if (done) b.classList.add("done");
const pct = epProg.get(String(e.id));
if (!done && pct != null && pct > 5) {
const pb = el("div", "bar");
pb.append(Object.assign(el("div", "bar-fill"), {style: `width:${pct}%`}));
b.append(pb);
}
if (watchNext != null && String(watchNext) === String(e.id)) b.classList.add("nextup");
if (e.filler) b.append(el("span", "badge", " filler"));
b.dataset.ep = e.id; b.dataset.no = e.number;
b.onclick = () => { markActive(b); play(e.id, e.number); };
eps.append(b);
}
}
function stepEp(d) {
const vis = epsList.filter(e => !(hideFiller && e.filler));
if (!vis.length || !currentEp) return;
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const n = vis[i + d];
if (!n) return;
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === n.id);
if (b) { markActive(b); play(n.id, n.number); }
}
function videoEl() {
const v = document.createElement("video");
v.controls = true; v.autoplay = true; v.playsinline = true;
return v;
}
function stopHls() { if (hls) { hls.destroy(); hls = null; } if (bufTimer) { clearInterval(bufTimer); bufTimer = null; } }
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
function playerOverlay(text, onAction, actionLabel = "Retry", spin = false) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
o.replaceChildren();
if (spin) o.append(el("span", "pl-spin")); // rotating ring: buffering shown, not told
o.append(el("span", null, text));
if (onAction) {
const b = el("button", "btn", actionLabel);
b.onclick = onAction;
o.append(b);
}
}
function playerHideOverlay() {
const o = document.querySelector(".pl-overlay");
if (o) o.remove();
}
function playerFail(msg) {
if (!currentEp) return;
playerBroken = true;
// a fatal error stops the periodic saves (timeupdate/pause never come
// again); persist the position now or the user loses everything since
// the last 10s save when they leave
savePos();
const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent});
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
let bufTimer = null;
let wdTimer = null; // play() watchdog (stuck-snapshot interval)
function bufferGauge() {
// thin bar under the video: how much is buffered AHEAD of playhead
// (0-30s scale). Fills while loading, drains as it plays — you can
// watch a stall build before it hits.
const p = document.getElementById("player");
let g = p.querySelector(".pl-buffer");
if (!g) { g = el("div", "pl-buffer"); g.append(el("i")); p.append(g); }
const bar = g.querySelector("i");
const tick = () => {
const m = p.querySelector("video");
if (!m || !m.duration) { bar.style.width = "0%"; return; }
let ahead = 0;
for (let i = 0; i < m.buffered.length; i++)
if (m.buffered.end(i) > m.currentTime) { ahead = m.buffered.end(i) - m.currentTime; break; }
bar.style.width = (Math.min(ahead / 30, 1) * 100).toFixed(1) + "%";
g.title = "buffered " + ahead.toFixed(0) + "s ahead (full = 30s)";
};
tick();
if (bufTimer) clearInterval(bufTimer);
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); }
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 ?"; });
}
// ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null;
function onEnded() {
if (!currentEp || playerBroken) return;
const vis = epsList.filter(e => !(hideFiller && e.filler));
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const nxt = vis[i + 1];
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; }
if (!autoNext) {
playerHideOverlay();
toast("Next: E" + nxt.number, {label: "\u25b6 Play", fn: () => {
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
if (b) markActive(b);
play(nxt.id, nxt.number);
}});
return;
}
nextCountdown(nxt);
}
function nextCountdown(nxt) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
let n = 5;
const done = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
if (b) markActive(b);
play(nxt.id, nxt.number);
};
const cancel = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
playerHideOverlay();
};
const draw = () => {
o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`));
const s = el("button", "btn", "Skip");
s.onclick = done;
const x = el("button", "cnt-x", "\u2715");
x.title = "cancel auto-next";
x.onclick = cancel;
o.append(s, x);
if (n <= 0) { done(); return; }
n--;
nextTimer = setTimeout(draw, 1000);
};
draw();
}
function setNowEp(ep_id, ep_no) {
const n = document.getElementById("now-ep");
if (!n) return;
const e = epsList.find(x => String(x.id) === String(ep_id));
n.textContent = "E" + ep_no + (e && e.name ? " — " + e.name : "");
}
async function play(ep_id, ep_no) {
playerBroken = false;
setNowEp(ep_id, ep_no);
localStorage.setItem("aniweb_lang_" + detail.slug_id, lang);
const t0 = performance.now();
const dbg = (step, extra) => { try { postEvent({type: "play-probe", jsv: JSV, dt: Math.round(performance.now() - t0), step, ...(extra || {})}); } catch {} };
if (wdTimer) { clearInterval(wdTimer); wdTimer = null; }
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
const v = videoEl();
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026", null, null, true);
bufferGauge();
cdnChip();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities.
try {
const nativeOK = (() => { try { return document.createElement("video").canPlayType("application/vnd.apple.mpegurl") || "none"; } catch { return "err"; } })();
const mse = (() => { try { return (window.Hls && Hls.isSupported) ? Hls.isSupported() : null; } catch { return "err"; } })();
dbg("branch-decision", {
branch: (!window.Hls || !Hls.isSupported()) ? (nativeOK !== "none" && nativeOK !== "" ? "native" : "none-throw") : "hlsjs",
hls_lib: typeof window.Hls, mse, native_hls: nativeOK, ua: navigator.userAgent});
} catch {}
if (!window.Hls || !Hls.isSupported()) {
// No MSE (e.g. iOS < 17): fall back to the browser's NATIVE HLS player.
// iOS Safari plays standard m3u8 in <video> directly — same proxied URL,
// same-origin, so no CORS needed.
if (!v.canPlayType("application/vnd.apple.mpegurl"))
throw new Error("this browser can't play HLS (no MSE support) — use a recent Chrome, Safari or Firefox");
v.src = "/stream/" + s.token;
} else {
// the upstream CDN occasionally stalls on segment bytes (0 bytes for
// 10s+). hls.js's default 5s time-to-first-byte abort would drop the XHR
// before our proxy's 12s upstream attempt (+300ms retry) can answer with
// its 502 — keep TTFT past the proxy timeout so slow-but-alive segments
// finish, and the proxy's retry path gets a chance
// hls.js 1.5.x reads the retry budget from the errorRetry / timeoutRetry
// sub-objects (a top-level maxNumRetry is ignored — that was the old bug).
// maxTimeToFirstByteMs first byte within 20s
// maxLoadTimeMs total load within 30s — covers SLOW CLIENT LINKS
// (a 700KB segment on a ~35KB/s phone link takes
// 20s+; the old 10s cap made the iPhone FragLoadTime
// out through all 6 retries even though every
// request 200'd server-side)
// timeoutRetry retries for a load *timeout* (was default 1!)
// errorRetry retries for HTTP errors like our proxy's 502
// Together: a slow/stalled CDN or slow phone link gets retried with backoff
// instead of one shot -> fatal fragLoadTimeOut.
hls = new Hls({ fragLoadPolicy: { default: {
maxTimeToFirstByteMs: 20000,
maxLoadTimeMs: 30000,
timeoutRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
errorRetry: { maxNumRetry: 8, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
} } });
// iPhone debugging: log EVERY hls.js error (fatal and not) + the
// success milestones, so the client-side timeline is visible server-side
hls.on(Hls.Events.ERROR, (_, data) => {
try { postEvent({type: "hls-err", jsv: JSV, dt: Math.round(performance.now() - t0),
fatal: data.fatal, details: data.details,
buffer: data.buffer || null, retry: data.retry ?? null,
http: data.response ? data.response.code : null}); } catch {}
if (data.fatal) {
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
hls.on(Hls.Events.MANIFEST_LOADED, () => dbg("manifest-loaded"));
hls.on(Hls.Events.LEVEL_LOADED, () => dbg("level-loaded"));
let _fn = 0; hls.on(Hls.Events.FRAG_LOADED, () => { if (++_fn <= 2) dbg("frag-loaded", {n: _fn}); });
let _ba = false; hls.on(Hls.Events.BUFFER_APPENDED, () => { if (!_ba) { _ba = true; dbg("buffer-appended"); doPlay(); } });
dbg("new-hls-ok");
hls.loadSource("/stream/" + s.token);
dbg("loadsource-called");
hls.attachMedia(v);
dbg("attach-ok");
// self-diagnosing watchdog: if not playing within 15s, snapshot the full
// client state every 15s (max 6) — catches any silent death, anywhere
let _wd = 0;
wdTimer = setInterval(() => {
const m = document.getElementById("player")?.querySelector("video");
if (!m || m.paused === false) { clearInterval(wdTimer); wdTimer = null; return; }
if (++_wd >= 6) { clearInterval(wdTimer); wdTimer = null; return; }
dbg("stuck-snapshot", {
rs: m.readyState, ns: m.networkState, paused: m.paused,
bufEnd: m.buffered.length ? m.buffered.end(m.buffered.length - 1) : 0,
ct: m.currentTime, hls_state: hls ? hls.state : null,
ov: (document.querySelector(".pl-overlay")?.textContent || "").slice(0, 60)});
}, 15000);
}
currentEp = {ep_id, ep_no, v};
lastSavedPos = 0;
if (nextCta) { nextCta.remove(); nextCta = null; }
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
if (saved?.pos > 10) {
v.addEventListener("loadedmetadata", () => {
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
}, {once: true});
}
v.onerror = () => playerFail("media error");
v.addEventListener("playing", playerHideOverlay);
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) {
playerOverlay("Buffering\u2026", null, null, true);
cdnChip(); // refresh CDN readout exactly when it matters
}
});
v.addEventListener("ended", onEnded);
// ponytail: play() before first data can reject with NotAllowedError in
// Chrome — the same error a real autoplay block produces — so it used to
// show a scary "browser blocked" overlay on every initial play. Start as
// soon as data exists (first BUFFER_APPENDED; canplay too) — but NEVER
// wait on canplay alone: the iPhone (iOS 18.7) once sat with 513s
// buffered, readyState stuck at 1, and canplay never fired — so the
// video never started. 3s safety net covers any remaining variant.
let started = false;
const tap = () => v.play().catch(() => blocked(true));
const blocked = (again) => playerOverlay(
again ? "Playback still blocked — allow autoplay for this site in " +
"the browser's site settings" : "Tap to play", tap, "\u25b6 Play");
const doPlay = () => {
if (started) return;
dbg("do-play", {rs: v.readyState, buf: v.buffered.length});
v.play().then(() => { started = true; dbg("play-resolved"); })
.catch(err => { dbg("play-rejected", {name: err && err.name});
if (err && err.name === "NotAllowedError") blocked(false); });
};
if (v.readyState >= 2) doPlay();
else v.addEventListener("canplay", doPlay, {once: true});
setTimeout(() => { if (v.paused && v.buffered.length) doPlay(); }, 3000);
watchPos(v);
setupIntro(v, ep_no);
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
} catch (e) {
showStreamError(e.message, () => play(ep_id, ep_no));
}
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// ---------- intro skip (MAL timings via keyless Jikan, cached server-side) ----------
const introCache = new Map(); // mal_id -> Promise<timings>
function setupIntro(v, epNo) {
const mid = detail && detail.mal_id;
if (!mid) return;
let p = introCache.get(mid);
if (!p) { p = api(`/api/intros/${mid}`).then(t => t || []).catch(() => []); introCache.set(mid, p); }
p.then(list => {
if (!currentEp || currentEp.v !== v) return; // superseded by a newer play()
// ponytail: ep 1 intros are often missing in MAL data; fall back to the
// show's first known timing (intros are usually identical across eps)
let t = list[epNo - 1];
if (!t) for (const x of list) if (x) { t = x; break; }
if (!t || !(t.length > 0)) return;
const s = t.start, e = t.start + t.length;
let skipped = false;
const b = el("button", "skip-intro", "skip intro \u23ed");
b.hidden = true;
b.onclick = () => { v.currentTime = e + 0.5; skipped = true; b.hidden = true; };
document.getElementById("player").append(b);
v.addEventListener("timeupdate", () => {
if (skipped && v.currentTime < s) skipped = false; // user rewound back to the window
if (skipped) return;
b.hidden = !(v.currentTime >= s && v.currentTime <= e + 8);
});
});
}
// extra fields to persist with every history entry (progress + poster)
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
poster: detail ? (detail.poster || null) : null});
// one savePos covers all video elements: it reads currentEp, so stale
// closures from earlier plays can't write the wrong episode's position
function savePos() {
if (!currentEp || currentEp.v.currentTime <= 3) return;
lastSavedPos = currentEp.v.currentTime;
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime,
...(epsList.length ? {total: epsList.length} : {})});
}
function watchPos(v) {
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); });
v.addEventListener("pause", savePos);
// pagehide is bound once at boot (per-play listeners accumulated and fired
// with stale video elements)
}
window.addEventListener("pagehide", savePos);
// ---------- boot ----------
(async () => {
document.querySelector(".logo").href = BASE;
document.getElementById("stats").href = BASE + "?stats";
try {
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
const lo = document.getElementById("logout");
lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
};
const mb = document.getElementById("menubox");
mb.style.display = "";
// #me is hidden on narrow screens: surface the username in the menu there
if (matchMedia("(max-width: 640px)").matches) {
document.getElementById("menu-items").prepend(el("div", "menu-user", "hi, " + me.user));
}
document.getElementById("menu").onclick = e => {
e.stopPropagation(); mb.classList.toggle("open");
};
document.addEventListener("click", () => mb.classList.remove("open"));
document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); });
const da = document.getElementById("delacct");
da.onclick = async () => {
if (!confirm("Delete your account and watch history?")) return;
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 */ }
await refreshHistory();
const p = new URLSearchParams(location.search);
try {
if (p.get("q")) await showSearch(p.get("q"));
else if (p.get("anime")) await showAnime(p.get("anime"), {
season: p.get("season") ? +p.get("season") : null,
ep: p.get("ep") ? +p.get("ep") : null,
lang: p.get("lang") || "jpn",
});
else if (p.has("history")) await showHistory();
else if (p.has("stats")) await showStats();
else showHome();
} catch (e) { fail(e); }
})();