// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js const JSV = 12; // 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; 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); 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. One retry on the // phone tells us where play() dies without any guessing. 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"; } })(); postEvent({type: "play-probe", jsv: JSV, 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