// 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 = "eng", seasonId = null; async function api(path) { const r = await fetch(BASE + path.replace(/^\//, "")); 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 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() { try { history = await api("/api/history"); } catch { /* home just shows empty */ } } function postHistory(entry) { fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(entry)}).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) { main.append(el("h2", null, "Recently played")); 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 ---------- async function showSearch(q) { 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); } // ---------- detail + player ---------- async function showAnime(slug_id, opts = {}) { detail = await api(`/api/anime/${slug_id}`); lang = opts.lang || "eng"; seasonId = opts.season || detail.id; detail.slug_id = slug_id; main.innerHTML = ""; const head = el("section", "detail-head"); head.append(el("h2", null, detail.name)); 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()); const setLang = l => { lang = l; langBtn.textContent = langLabel(); }; langBtn.onclick = () => { setLang(lang === "eng" ? "jpn" : "eng"); const cur = main.querySelector(".ep.active"); if (cur) play(cur.dataset.ep, cur.dataset.no); }; controls.append(langBtn, 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"; main.append(eps); const list = await api(`/api/anime/${seasonId}/episodes`); for (const e of list) { const b = el("button", "ep" + (e.filler ? " filler" : "")); b.textContent = "E" + e.number; 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); } if (opts.ep) { const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep); if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); } } else { // default: resume last watched ep of this season, else start at ep 1 const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId); const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep; if (auto) { const cur = [...eps.children].find(b => +b.dataset.ep === auto); if (cur) { markActive(cur); play(auto, cur.dataset.no); } } } if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis)); } function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } function markActive(b) { for (const x of document.querySelectorAll(".ep")) x.classList.remove("active"); b.classList.add("active"); } 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; async function play(ep_id, ep_no) { try { const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`); stopHls(); const v = videoEl(); document.getElementById("player").replaceChildren(v); 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) { showStreamError(`${data.type}: ${data.details}` + (data.response ? ` (HTTP ${data.response.code})` : "")); stopHls(); } }); 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 = () => showStreamError("media error: " + (v.error?.message || v.error?.code)); v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt 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}); function watchPos(v) { const save = () => { if (!currentEp || v.currentTime <= 3) return; lastSavedPos = v.currentTime; postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime}); }; v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); }); v.addEventListener("pause", save); window.addEventListener("pagehide", save); } function showPlayOverlay(v) { const o = el("button", "play-overlay", "▶ click to play"); o.onclick = () => { v.play(); o.remove(); }; v.parentElement.append(o); } // ---------- boot ---------- (async () => { try { const me = await api("/api/me"); document.getElementById("me").textContent = "hi, " + me.user; const lo = document.getElementById("logout"); lo.onclick = async () => { await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login"; }; 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")); const da = document.getElementById("delacct"); da.onclick = async () => { if (!confirm("Delete your account and watch history?")) return; await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login"; }; } 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") || "eng", }); else showHome(); } catch (e) { fail(e); } })();