// 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("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); 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); 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; } main.append(...histBoard(items)); } 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)}`; // ---------- 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 showStats() { skel(5); const d = await api("/api/stats?days=30"); main.innerHTML = ""; main.append(el("h2", null, "Stats \u2014 last 30 days")); main.append(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 ")))); } } 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); } })();