// 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}`; function showHome() { main.innerHTML = ""; if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); 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 · ${timeago(hero.t)}`)); const p = bar(hero); if (p) body.append(p); const go = el("button", "btn resume", "▶ Resume"); go.onclick = () => location.href = histHref(hero); right.append(body, go); sec.append(card, right); main.append(sec); // 2-4. the rest as mini poster cards with progress + time-ago if (history.length > 1) { const head = el("div", "sec-head"); head.append(el("h2", null, "Recently played")); const st = el("a", null, "stats \u2192"); st.href = BASE + "?stats"; head.append(st); if (history.length >= 5) { const va = el("a", null, "view all \u2192"); va.href = BASE + "?history"; head.append(va); } main.append(head); const grid = el("div", "grid mini"); for (const x of history.slice(1, 6)) { const c = el("a", "card"); c.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)); const meta = el("div", "mini-meta"); meta.textContent = `E${x.ep} · ${x.lang === "eng" ? "dub" : "sub"} · ${timeago(x.t)}`; info.append(meta); const pb = bar(x, "small"); if (pb) info.append(pb); c.append(info); grid.append(c); } main.append(grid); } } // ---------- 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; } 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 || "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", "hide fillers"); fillB.onclick = () => { hideFiller = !hideFiller; 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(); 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)); } } 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); } } if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis)); } function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } // ---------- history page (full list + delete) ---------- 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 grid = el("div", "grid"); for (const x of items) grid.append(histCard(x)); main.append(grid); } function histCard(x) { const hold = x.t && Date.now() / 1000 - x.t > 7 * 86400; 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 = false; let watchDone = new Set(), watchNext = null; 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"); 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; 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; 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; 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; 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); } })();