Files
ani-web/static/app.js
T
jozamudi bb825a3acb Home: status board instead of a feed
- replace 'Recently played' with one card per anime grouped by state:
  Continue watching (<=6, progress + ~Xm left), On hold (<=4, dimmed,
  days ago), Recently finished (<=6, check)
- hero button says 'Next ep' when the last ep is >=95% watched
- empty home shows recent searches as clickable chips (race-guarded:
  only appended while the empty state is still on screen)
- helpers: byShow (latest row per anime), homeCard, showSec, link
2026-08-24 20:09:39 +00:00

715 lines
27 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
// 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));
}
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) {
const t = el("div", "toast", msg);
document.getElementById("toasts").append(t);
setTimeout(() => t.remove(), 2600);
}
function showStreamError(msg) {
const p = el("p", "error", "Stream error: " + msg);
const player = document.getElementById("player");
if (player) player.append(p); else fail(new Error(msg));
}
// ---------- 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});
function showHome() {
main.innerHTML = "";
if (!history.length) {
main.append(el("p", "muted empty", "Search for an anime to get started."));
api("/api/searches").then(rs => {
if (!rs.length || !main.querySelector("p.empty")) 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);
main.append(d);
}).catch(() => {});
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("stats \u2192", "?stats"),
link("history \u2192", "?history")]);
showSec("On hold", hold, hold.length ? [link("history \u2192", "?history")] : []);
showSec("Recently finished", done, done.length ? [link("history \u2192", "?history")] : []);
}
// ---------- 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);
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) { main.append(el("p", "muted", "No results.")); 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);
}
// ---------- 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;
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);
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();
};
controls.append(langBtn, spd, prevB, nextB, fillB,
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`);
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));
}
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() {
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.")); return; }
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 = () => {
grid.replaceChildren(...items.filter(x => active === "all" || histClass(x) === active).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);
}
main.append(chips);
draw();
main.append(grid);
}
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 info = el("div", "mini-info");
info.append(el("div", "card-name", x.title),
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"}});
toast("Removed from history");
await showHistory();
};
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)}`;
async function showStats() {
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
skel(5);
const d = await api("/api/stats?days=30");
main.innerHTML = "";
main.append(el("h2", null, "Stats \u2014 last 30 days"));
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}`));
main.append(cards);
if (d.errors > 0) main.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`));
const WD = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const mx = Math.max(1, ...d.by_weekday);
const wk = el("div", "wk");
d.by_weekday.forEach((s, i) => {
const col = el("div", "wk-col");
const b = el("div", "wk-bar");
b.style.height = Math.max(3, Math.round(s / mx * 50)) + "px";
b.title = fmtMin(s / 60);
col.append(b, el("div", "wk-day", WD[i]));
wk.append(col);
});
main.append(wk);
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 (list.length) {
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);
}
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 "))));
}
}
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
}
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
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;
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;
return v;
}
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
function playerOverlay(text, onAction, actionLabel = "Retry") {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
o.replaceChildren(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;
const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang});
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
// ---------- 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; }
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();
}
async function play(ep_id, ep_no) {
playerBroken = false;
localStorage.setItem("aniweb_lang_" + detail.slug_id, lang);
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");
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
hls = new Hls();
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
hls.loadSource("/stream/" + s.token);
hls.attachMedia(v);
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");
});
v.addEventListener("ended", onEnded);
// NotAllowedError = the browser blocked autoplay: 'playing' will never
// fire, so the Loading… overlay (which only clears on 'playing') would
// sit forever over the unreachable native controls. Other rejection
// reasons (AbortError when the stream has no data yet, …) belong to the
// hls.js fatal-error path — leave its overlay alone.
v.play().catch(err => {
if (!err || err.name !== "NotAllowedError") return;
playerOverlay("Tap to play", () => {
v.play().catch(() => playerFail("playback blocked by the browser"));
}, "Play");
});
watchPos(v);
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);
}
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// 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 = "";
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); }
})();