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).
1376 lines
57 KiB
JavaScript
1376 lines
57 KiB
JavaScript
// 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); }
|
||
})();
|