Files
jozamudi d12ff79d29 Add animekai source with 1anime direct-MP4 backend (independent CDN)
animekai.be fronted two server backends per episode: megaplay.buzz
(same player/CDN family as hianime — 403s our egress IP) and
my.1anime.site (direct progressive MP4, independent CDN — verified
serving our IP at ~2.4MB/s). This is the rescue path around the
MegaPlay Cloudflare block, per the ani-cli #1945/#1918 work.

- providers/animekai.py: standard interface, per-episode backend
  dispatch (1anime first, then megaplay), same listed-but-dead ->
  raise semantics as hianime. All endpoints ride the shared TTL cache
  (no re-fetching on repeat UI hits).
- upstream.py: megaplay_master() moved out of hianime — the MegaPlay
  chain (embed -> data-id -> getSourcesNew -> enc decrypt -> CDN proof)
  is now shared by every provider fronting that player.
- app.py: /stream forwards Range for progressive files (206 +
  Content-Range passthrough, partial bodies never LRU-cached, 8MB cap
  on the parallel path so a 250MB MP4 can't pin RAM). api_stream
  reports hls=true/false from the URL — the player kind is a property
  of the URL, so future stream types slot in without app changes.
- app.js: hls===false -> native <video> (existing Range-seeking),
  hls.js path untouched.
- run.sh: ANI_SOURCE=animekai,hianime,anidb,anikoto (revert to the old
  chain when the MegaPlay block lifts).
- tests: test_animekai.py (offline: interface, enc decrypt, /stream
  Range via monkeypatched network) + test_animekai_live.py (opt-in
  ANI_LIVE=1 end-to-end, skipped by default so the suite never touches
  animekai.be).
2026-09-29 18:35:22 +00:00

1376 lines
57 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
// JSV = this file's cache-bust version (the server injects the git SHA into
// its ?v= URL), reported by play-probes so a stale-cache phone is
// detectable server-side. No hand-bumping.
const JSV = ((document.currentScript && document.currentScript.src).match(/[?&]v=([^&]+)/) || [])[1] || "unknown";
// 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");
// Watched threshold (fraction of duration) — injected inline by the server
// from DONE_RATIO in server/app.py; fallback matches that default.
const DONE_RATIO = Number(window.DONE_RATIO ?? 0.95);
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;
}
// lazy poster img with a broken-image fallback (a dead upstream poster URL
// must not leave a broken-image icon in an otherwise fine card)
function poster(src, alt) {
const i = document.createElement("img");
i.alt = alt || "";
if (src) { i.src = src; i.loading = "lazy"; i.decoding = "async"; }
i.onerror = () => i.replaceWith(el("div", "ph-img"));
return i;
}
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, keepalive = false) {
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify(entry), keepalive}).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;
};
function histHref(x) { return `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; }
// ---------- stale-entry remap (provider switch) ----------
// History rows written while anidb was the source carry anidb slugs/ids. Under
// hianime-first dispatch, /watch/{anidb-slug} often 200s as a DIFFERENT anime
// (id 2552 = Burning Kabaddi) while the anidb ep id silently dies (not in that
// anime's list) — the row then links to the wrong show. Validate every row
// against the details endpoint; mismatch or dead slug -> remap board.
const _norm = s => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const _match = (a, b) => {
const x = _norm(a), y = _norm(b);
return !!(x && y && (x === y || x.includes(y) || y.includes(x)));
};
const _remap = {};
async function checkEntry(x) {
if (x._remapped) return true;
if (_remap[x.slug_id] !== undefined) { x._remapped = _remap[x.slug_id]; return !_remap[x.slug_id]; }
let d;
try { d = await api(`/api/anime/${x.slug_id}`); } catch { d = null; }
const ok = !!(d && d.name && _match(d.name, x.title));
_remap[x.slug_id] = !ok;
x._remapped = !ok;
return ok;
}
function remapCard(x) {
const c = el("a", "card hist remap");
c.append(poster(x.poster, x.title),
el("div", "card-name", `"${x.title}" \u2014 not found on the current source`));
const row = el("div", "chips");
const go = el("button", "tiny", "find it \u2192");
go.onclick = e => { e.preventDefault(); e.stopPropagation(); location.href = "?q=" + encodeURIComponent(x.title || ""); };
const del = el("button", "del", "\u00d7");
del.title = "remove from history";
del.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);
toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
};
row.append(go, del);
c.append(row);
return c;
}
// 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);
c.append(poster(x.poster, x.title));
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 * DONE_RATIO) 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];
main.append(remapBoard());
if (hero && hero._remapped) return;
// 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 * DONE_RATIO ? ` · ${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 * DONE_RATIO;
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(() => {});
}
function renderSearchResults(q, results, announce = true) {
document.title = q + " \u2014 ani-web";
main.innerHTML = "";
if (!results.length) {
const p = el("p", "muted", "No results.");
main.append(p);
recentChips(p);
} else {
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}`;
card.append(poster(r.poster, r.name), el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
if (announce) focusMain(); // focus-steal breaks Enter-to-submit mid-keystroke
}
async function showSearch(q) {
saveSearch(q);
skel(8);
renderSearchResults(q, await api(`/api/search?q=${encodeURIComponent(q)}`));
}
// search-as-you-type: debounced /api/search rendered in place (Enter still
// commits to ?q= so results are shareable/reloadable)
let liveTok = 0;
function initLive() {
let t = null;
const q = document.getElementById("q");
q.addEventListener("input", () => {
const v = q.value.trim();
clearTimeout(t);
if (v.length < 2) return;
t = setTimeout(async () => {
const tok = ++liveTok;
try {
const results = await api(`/api/search?q=${encodeURIComponent(v)}`);
if (tok !== liveTok) return; // superseded by a newer keystroke
saveSearch(v);
renderSearchResults(v, results, false); // keep focus in #q so Enter still submits
} catch { /* failed or stale — Enter still works */ }
}, 300);
});
}
// ---------- 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.ariaLabel = "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];
const fsB = el("button", "tiny", "\u26f6 full");
fsB.title = "fullscreen";
fsB.onclick = () => {
const v = document.querySelector("#player video");
if (!v) return;
if (document.fullscreenElement || document.webkitFullscreenElement) {
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
return;
}
// native Fullscreen API: the video fills the window (and scales with it);
// iOS Safari has no element fullscreen, so fall back to its in-player one
if (v.requestFullscreen) v.requestFullscreen();
else if (v.webkitEnterFullscreen) v.webkitEnterFullscreen();
else if (v.webkitRequestFullscreen) v.webkitRequestFullscreen();
};
ctl.push(fsB);
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 * DONE_RATIO) 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 * DONE_RATIO ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
function remapBoard() {
// home: one stale card per stale anime (latest row), above the hero, so a
// switched-source user sees the remap CTA before the wrong-show hero
const m = new Map();
for (const x of history) if (x._remapped && (!m.has(x.slug_id) || x.t > m.get(x.slug_id).t)) m.set(x.slug_id, x);
const out = [];
if (m.size) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one"),
link("history \u2192", "?history")));
const grid = el("div", "grid mini");
for (const x of m.values()) grid.append(remapCard(x));
out.push(grid);
}
return out;
}
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; }
await Promise.all(items.map(checkEntry));
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);
c.append(poster(x.poster, x.title));
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");
row.append(poster(a.poster, a.title));
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 good = items.filter(x => !x._remapped);
const stale = items.filter(x => x._remapped);
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 = good.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();
const out = [chips, grid];
if (stale.length) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one")));
const sgrid = el("div", "grid mini");
for (const x of stale) sgrid.append(remapCard(x));
out.push(sgrid);
}
return out;
}
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, lastSeenPos = 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. hls.js may have already reset
// currentTime to 0, so floor at the furthest point reached this episode
savePos(lastSeenPos);
const { ep_id, ep_no } = currentEp;
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang, ua: navigator.userAgent});
playerOverlay("Playback failed: " + msg + cdnContext(), () => play(ep_id, ep_no));
}
function cdnContext() {
// one-line "why" on the failure overlay: playback failing while the CDN
// or upstream is bad is probably the network, not the browser
if (!_status) return "";
const bits = [];
if (_status.state !== "OK") bits.push("CDN " + _status.state + " (" + _status.kbps + " KB/s)");
if (_status.origin.state !== "OK") bits.push("upstream " + _status.origin.state);
return bits.length ? " · " + bits.join(", ") : "";
}
// ---------- buffering visuals: buffer-ahead gauge + CDN chip ----------
let bufTimer = null;
let wdTimer = null; // play() watchdog (stuck-snapshot interval)
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);
}
// ---------- status pill + panel: the ONE CDN/upstream status surface ----------
// The header pill (dots + short label) is visible on every screen — if the
// upstream is down you never reach the player, so status lives in the
// (sticky) header. Tap expands a panel with detail + the ~1h trend from
// /api/cdn's hist. The buffer gauge under the video is CLIENT buffer state,
// deliberately not mixed in here.
let _status = null;
function statusPill() {
api("/api/cdn").then(d => {
_status = d;
renderPill(d);
if (document.getElementById("status-panel")) renderStatusPanel(d);
}).catch(() => {
const p = document.getElementById("pill");
if (p) p.querySelector("span").textContent = "?";
});
}
function renderPill(d) {
// ONE dot + one word: the at-a-glance question is "can I watch?", which is
// one state — down (origin dead) > slow (CDN degraded) > up. Per-host
// detail belongs in the tap panel, not here.
const p = document.getElementById("pill");
if (!p) return;
const known = (d.origin.state === "OK" || d.origin.state === "DOWN")
&& (d.state === "OK" || d.state === "DEGRADED");
const down = d.origin.state === "DOWN";
const slow = !down && d.state === "DEGRADED";
const st = !known ? "" : down ? "down" : slow ? "slow" : "ok";
p.className = "pill" + (st === "down" ? " down" : "");
p.querySelector(".d").className = "d" + (st ? " " + st : "");
p.querySelector("span").textContent =
!st ? "…" : st === "down" ? "down"
: st === "slow" ? "slow" + (d.kbps ? " · " + d.kbps + " KB/s" : "") : "up";
p.title = "upstream site: " + d.origin.state + (d.origin.msg ? " — " + d.origin.msg : "")
+ " · video CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "");
p.setAttribute("aria-label", "status: " + (!st ? "checking"
: st === "down" ? "down — upstream " + d.origin.state
: st === "slow" ? "slow — CDN " + d.state + (d.kbps ? ", " + d.kbps + " KB/s" : "")
: "up"));
}
function toggleStatusPanel() {
const open = document.getElementById("status-panel");
if (open) { open.remove(); return; }
const w = el("div", "status-panel");
w.id = "status-panel";
document.getElementById("pill").append(w);
if (_status) renderStatusPanel(_status);
else { w.append(el("div", "sp-row", "fetching…")); statusPill(); }
}
function renderStatusPanel(d) {
const w = document.getElementById("status-panel");
if (!w) return;
w.replaceChildren();
w.append(el("div", "sp-row", "upstream: " + d.origin.state
+ (d.origin.msg ? " (" + d.origin.msg + ")" : "")));
w.append(el("div", "sp-row", "CDN: " + d.state + (d.kbps ? " — " + d.kbps + " KB/s" : "")));
const ch = d.hist || [];
if (ch.length > 1) {
w.append(sparkline(ch.map(s => s.kbps), d.kbps ? d.threshold : null));
w.append(el("div", "sp-cap", "CDN, last " + _mins(ch) + " min"
+ (d.kbps ? " (dashed = slow threshold " + Math.round(d.threshold) + " KB/s)" : "")));
}
const oh = d.origin.hist || [];
if (oh.length > 1) {
const up = oh[oh.length - 1].state === "OK";
w.append(sparkline(oh.map(s => (s.state === "OK" ? 1 : 0)), null, true, up));
w.append(el("div", "sp-cap", "upstream, last " + _mins(oh) + " min (flat bottom = down)"));
}
const age = Math.max(0, Math.round((Date.now() / 1000 - d.ts) / 60));
w.append(el("div", "sp-cap", "updated " + age + " min ago"));
}
function _mins(h) { return Math.round((h[h.length - 1].t - h[0].t) / 60); }
function sparkline(values, threshold, binary = false, healthy = true) {
// ~20 samples (3-min spacing) -> one inline-SVG polyline, scaled so a
// dashed threshold line (when given) always fits. binary = up(1)/down(0)
// step line for the origin probe. No lib: it's a single polyline.
const W = 120, H = 28, PAD = 2;
const max = Math.max(Math.max(...values), threshold || 0, binary ? 1 : 0) || 1;
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
svg.setAttribute("class", "sp-svg" + (binary && !healthy ? " bad" : ""));
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", binary ? "upstream up/down, last hour"
: "CDN throughput trend, last hour");
if (threshold) {
const ty = Math.max(PAD, H - PAD - Math.min(threshold / max, 1) * (H - 2 * PAD));
const l = document.createElementNS(NS, "line");
l.setAttribute("x1", 0); l.setAttribute("x2", W);
l.setAttribute("y1", ty.toFixed(1)); l.setAttribute("y2", ty.toFixed(1));
l.setAttribute("class", "sp-thr");
svg.append(l);
}
const pts = [];
values.forEach((v, i) => {
const x0 = (i / (values.length - 1)) * W;
const y = H - PAD - Math.min(v / max, 1) * (H - 2 * PAD);
pts.push(x0.toFixed(1) + "," + y.toFixed(1));
if (binary) pts.push(((i + 1) / (values.length - 1) * W).toFixed(1) + "," + y.toFixed(1));
});
const pl = document.createElementNS(NS, "polyline");
pl.setAttribute("points", pts.join(" "));
svg.append(pl);
return svg;
}
// pill = tap-to-toggle (mobile has no hover); re-tap or click-away closes.
// 60s poll while visible: the probe runs every 3 min and this endpoint is
// in-memory + local, so a fresh render is free.
document.getElementById("pill").onclick = e => { e.stopPropagation(); toggleStatusPanel(); };
document.addEventListener("click", () => {
const w = document.getElementById("status-panel");
if (w) w.remove();
});
setInterval(() => { if (document.visibilityState === "visible") statusPill(); }, 60000);
// ---------- 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);
const t0 = performance.now();
const dbg = (step, extra) => { try { postEvent({type: "play-probe", jsv: JSV, dt: Math.round(performance.now() - t0), step, ...(extra || {})}); } catch {} };
if (wdTimer) { clearInterval(wdTimer); wdTimer = null; }
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
const v = videoEl();
let subTrack = null;
if (s.sub) {
// external subtitle track (hianime serves a .vtt; anidb bakes subs in).
// native <track> works on MSE video (same as dash.js); served via /stream
// so the referer-hungry CDN is behind our proxy. Appended AFTER the
// branch below: hls.js's attachMedia runs _cleanTracks, which disables
// every track present at attach time
subTrack = document.createElement("track");
subTrack.kind = "subtitles"; subTrack.srclang = "en"; subTrack.label = "English"; subTrack.default = true;
subTrack.src = "/stream/" + s.sub;
}
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026", null, null, true);
bufferGauge();
statusPill();
// DECISIVE PROBE (iPhone debugging): report exactly which branch we're
// about to take + the browser's real capabilities.
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"; } })();
dbg("branch-decision", {
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 (s.hls === false) {
// progressive file (1anime's direct MP4): native <video>; the /stream
// proxy forwards the browser's Range requests, so seeking works
v.src = "/stream/" + s.token;
} else 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 <video> directly — same proxied URL,
// same-origin, so no CORS needed.
if (!v.canPlayType("application/vnd.apple.mpegurl"))
throw new Error("this browser can't play HLS (no MSE support) — use a recent Chrome, Safari or Firefox");
v.src = "/stream/" + s.token;
} else {
// the upstream CDN occasionally stalls on segment bytes (0 bytes for
// 10s+). hls.js's default 5s time-to-first-byte abort would drop the XHR
// before our proxy's 12s upstream attempt (+300ms retry) can answer with
// its 502 — keep TTFT past the proxy timeout so slow-but-alive segments
// finish, and the proxy's retry path gets a chance
// hls.js 1.5.x reads the retry budget from the errorRetry / timeoutRetry
// sub-objects (a top-level maxNumRetry is ignored — that was the old bug).
// maxTimeToFirstByteMs first byte within 20s
// maxLoadTimeMs total load within 30s — covers SLOW CLIENT LINKS
// (a 700KB segment on a ~35KB/s phone link takes
// 20s+; the old 10s cap made the iPhone FragLoadTime
// out through all 6 retries even though every
// request 200'd server-side)
// timeoutRetry retries for a load *timeout* (was default 1!)
// errorRetry retries for HTTP errors like our proxy's 502
// Together: a slow/stalled CDN or slow phone link gets retried with backoff
// instead of one shot -> fatal fragLoadTimeOut.
hls = new Hls({ fragLoadPolicy: { default: {
maxTimeToFirstByteMs: 20000,
maxLoadTimeMs: 30000,
timeoutRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
errorRetry: { maxNumRetry: 8, retryDelayMs: 1000, maxRetryDelayMs: 20000, backoff: "linear" },
} } });
// iPhone debugging: log EVERY hls.js error (fatal and not) + the
// success milestones, so the client-side timeline is visible server-side
hls.on(Hls.Events.ERROR, (_, data) => {
try { postEvent({type: "hls-err", jsv: JSV, dt: Math.round(performance.now() - t0),
fatal: data.fatal, details: data.details,
buffer: data.buffer || null, retry: data.retry ?? null,
http: data.response ? data.response.code : null}); } catch {}
if (data.fatal) {
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
hls.on(Hls.Events.MANIFEST_LOADED, () => dbg("manifest-loaded"));
hls.on(Hls.Events.LEVEL_LOADED, () => dbg("level-loaded"));
let _fn = 0; hls.on(Hls.Events.FRAG_LOADED, () => { if (++_fn <= 2) dbg("frag-loaded", {n: _fn}); });
let _ba = false; hls.on(Hls.Events.BUFFER_APPENDED, () => { if (!_ba) { _ba = true; dbg("buffer-appended"); doPlay(); } });
dbg("new-hls-ok");
hls.loadSource("/stream/" + s.token);
dbg("loadsource-called");
hls.attachMedia(v);
dbg("attach-ok");
// self-diagnosing watchdog: if not playing within 15s, snapshot the full
// client state every 15s (max 6) — catches any silent death, anywhere
let _wd = 0;
wdTimer = setInterval(() => {
const m = document.getElementById("player")?.querySelector("video");
if (!m || m.paused === false) { clearInterval(wdTimer); wdTimer = null; return; }
if (++_wd >= 6) { clearInterval(wdTimer); wdTimer = null; return; }
dbg("stuck-snapshot", {
rs: m.readyState, ns: m.networkState, paused: m.paused,
bufEnd: m.buffered.length ? m.buffered.end(m.buffered.length - 1) : 0,
ct: m.currentTime, hls_state: hls ? hls.state : null,
ov: (document.querySelector(".pl-overlay")?.textContent || "").slice(0, 60)});
}, 15000);
}
if (subTrack) { v.append(subTrack); subTrack.mode = "showing"; }
currentEp = {ep_id, ep_no, v};
lastSavedPos = 0;
lastSeenPos = 0;
if (nextCta) { nextCta.remove(); nextCta = null; }
let saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
if (!saved) {
// the global may be empty (boot fetch failed — it swallows errors):
// re-fetch so the write below can't clobber a real saved position
await refreshHistory();
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 * DONE_RATIO) 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", null, null, true);
statusPill(); // refresh status readout exactly when it matters
}
});
v.addEventListener("ended", onEnded);
// ponytail: play() before first data can reject with NotAllowedError in
// Chrome — the same error a real autoplay block produces — so it used to
// show a scary "browser blocked" overlay on every initial play. Start as
// soon as data exists (first BUFFER_APPENDED; canplay too) — but NEVER
// wait on canplay alone: the iPhone (iOS 18.7) once sat with 513s
// buffered, readyState stuck at 1, and canplay never fired — so the
// video never started. 3s safety net covers any remaining variant.
let started = false;
const tap = () => v.play().catch(() => blocked(true));
const blocked = (again) => playerOverlay(
again ? "Playback still blocked — allow autoplay for this site in " +
"the browser's site settings" : "Tap to play", tap, "\u25b6 Play");
const doPlay = () => {
if (started) return;
dbg("do-play", {rs: v.readyState, buf: v.buffered.length});
v.play().then(() => { started = true; dbg("play-resolved"); })
.catch(err => { dbg("play-rejected", {name: err && err.name});
if (err && err.name === "NotAllowedError") blocked(false); });
};
if (v.readyState >= 2) doPlay();
else v.addEventListener("canplay", doPlay, {once: true});
setTimeout(() => { if (v.paused && v.buffered.length) doPlay(); }, 3000);
watchPos(v);
setupIntro(v, ep_no);
// only bump the existing row to the top; with nothing saved a pos-0
// write would clobber the server's position if the re-fetch also failed
if (saved) postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no, pos: saved.pos});
} catch (e) {
showStreamError(e.message, () => play(ep_id, ep_no));
}
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// ---------- intro skip (MAL timings via keyless Jikan, cached server-side) ----------
const introCache = new Map(); // mal_id -> Promise<timings>
function setupIntro(v, epNo) {
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 => {
if (!currentEp || currentEp.v !== v) return; // superseded by a newer play()
// ponytail: ep 1 intros are often missing in MAL data; fall back to the
// show's first known timing (intros are usually identical across eps)
let t = list[epNo - 1];
if (!t) for (const x of list) if (x) { t = x; break; }
if (!t || !(t.length > 0)) return;
const s = t.start, e = t.start + t.length;
let skipped = false;
const b = el("button", "skip-intro", "skip intro \u23ed");
b.hidden = true;
b.onclick = () => { v.currentTime = e + 0.5; skipped = true; b.hidden = true; };
document.getElementById("player").append(b);
v.addEventListener("timeupdate", () => {
if (skipped && v.currentTime < s) skipped = false; // user rewound back to the window
if (skipped) return;
b.hidden = !(v.currentTime >= s && v.currentTime <= e + 8);
});
});
}
// 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(floor = 0, keep = false) {
if (!currentEp) return;
// a fatal frag error can reset video.currentTime to 0 before this runs
// (hls.js recovery); playerFail passes the furthest point reached as floor
const pos = Math.max(currentEp.v.currentTime, floor);
if (pos <= 3) return;
lastSavedPos = pos;
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,
...(epsList.length ? {total: epsList.length} : {})}, keep);
}
function watchPos(v) {
// 'seeking' fires when currentTime is set, even before the target segment
// buffers — so a seek made just before a stream death is still captured
const seen = () => { lastSeenPos = Math.max(lastSeenPos, v.currentTime); };
v.addEventListener("timeupdate", () => { seen(); if (v.currentTime - lastSavedPos >= 10) savePos(); });
v.addEventListener("seeking", seen);
v.addEventListener("pause", () => savePos()); // not savePos: the event would become `floor` -> NaN pos
// pagehide is bound once at boot (per-play listeners accumulated and fired
// with stale video elements)
}
window.addEventListener("pagehide", () => savePos(0, true)); // keepalive: a plain fetch dies with the page
// ---------- 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();
initLive();
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 = "";
// #me is hidden on narrow screens: surface the username in the menu there
if (matchMedia("(max-width: 640px)").matches) {
document.getElementById("menu-items").prepend(el("div", "menu-user", "hi, " + me.user));
}
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 */ }
statusPill();
await refreshHistory();
await Promise.all(history.map(checkEntry));
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); }
})();