- labels now sit on the week CONTAINING the 1st of the month (old logic labelled the first column starting in that month, so a month beginning mid-week was labelled a whole column late — hovering the cells under 'Aug' showed Jul dates); skip labels that would crowd the previous one - label + grid rows are inline-flex so flexbox shrink can't drift them apart at narrow widths - cell scale back to the orange accent (color-mix of --acc)
801 lines
30 KiB
JavaScript
801 lines
30 KiB
JavaScript
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
|
||
// BASE = this page's directory, so the app works at / or behind a
|
||
// prefixing reverse proxy (e.g. https://host/ani/)
|
||
const BASE = (() => {
|
||
const p = location.pathname;
|
||
return p.endsWith("/") ? p : p.slice(0, p.lastIndexOf("/") + 1);
|
||
})();
|
||
const main = document.getElementById("main");
|
||
let hls = null, detail = null, lang = "jpn", seasonId = null;
|
||
|
||
async function api(path) {
|
||
const r = await fetch(BASE + path.replace(/^\//, ""), {headers: {"X-Aniweb": "1"}});
|
||
if (r.status === 401) {
|
||
location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search);
|
||
throw new Error("not authenticated");
|
||
}
|
||
if (!r.ok) {
|
||
let msg = r.statusText;
|
||
try { msg = (await r.json()).detail || msg; } catch {}
|
||
throw new Error(msg);
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
function el(tag, cls, text) {
|
||
const e = document.createElement(tag);
|
||
if (cls) e.className = cls;
|
||
if (text != null) e.textContent = text;
|
||
return e;
|
||
}
|
||
|
||
function fail(err) {
|
||
main.innerHTML = "";
|
||
main.append(el("p", "error", "Error: " + err.message));
|
||
}
|
||
|
||
function skel(n = 8) {
|
||
main.innerHTML = "";
|
||
const g = el("div", "grid");
|
||
for (let i = 0; i < n; i++) g.append(el("div", "card skel"));
|
||
main.append(g);
|
||
}
|
||
|
||
function toast(msg) {
|
||
const t = el("div", "toast", msg);
|
||
document.getElementById("toasts").append(t);
|
||
setTimeout(() => t.remove(), 2600);
|
||
}
|
||
|
||
function showStreamError(msg) {
|
||
const p = el("p", "error", "Stream error: " + msg);
|
||
const player = document.getElementById("player");
|
||
if (player) player.append(p); else fail(new Error(msg));
|
||
}
|
||
|
||
// ---------- history (server-persisted, incl. watch position) ----------
|
||
let history = [];
|
||
async function refreshHistory() {
|
||
// ?all=1: resume positions live beyond the top-5 cap
|
||
try { history = await api("/api/history?all=1"); } catch { /* home just shows empty */ }
|
||
}
|
||
function postHistory(entry) {
|
||
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
|
||
body: JSON.stringify(entry)}).catch(() => {});
|
||
}
|
||
function postEvent(ev) {
|
||
fetch(BASE + "api/events", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
|
||
body: JSON.stringify(ev)}).catch(() => {});
|
||
}
|
||
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
|
||
const timeago = t => {
|
||
if (!t) return "";
|
||
const d = Math.max(0, Date.now() / 1000 - t);
|
||
if (d < 90) return "just now";
|
||
if (d < 3600) return `${Math.round(d / 60)}m ago`;
|
||
if (d < 86400) return `${Math.round(d / 3600)}h ago`;
|
||
return `${Math.round(d / 86400)}d ago`;
|
||
};
|
||
const prog = x => x.dur > 0 ? Math.min(100, Math.round(100 * x.pos / x.dur)) : null;
|
||
const bar = (x, cls) => {
|
||
const p = prog(x);
|
||
if (p == null) return null;
|
||
const b = el("div", "bar" + (cls ? " " + cls : ""));
|
||
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
|
||
return b;
|
||
};
|
||
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
|
||
// one entry per anime (latest row wins), newest first
|
||
const byShow = () => {
|
||
const m = new Map();
|
||
for (const x of history) {
|
||
const k = x.slug_id + ":" + x.season_id;
|
||
if (!m.has(k) || x.t > m.get(k).t) m.set(k, x);
|
||
}
|
||
return [...m.values()].sort((a, b) => b.t - a.t);
|
||
};
|
||
const homeCard = x => {
|
||
const done = histClass(x) === "done", hold = histClass(x) === "hold";
|
||
const c = el("a", "card" + (hold ? " hold" : ""));
|
||
c.href = histHref(x);
|
||
if (x.poster) {
|
||
const i = el("img"); i.src = x.poster; i.alt = "";
|
||
c.append(i);
|
||
}
|
||
let meta = `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"}`;
|
||
if (done) meta += " \u00b7 \u2713";
|
||
else if (x.dur > 0 && x.pos < x.dur * 0.95) meta += ` \u00b7 ${fmtMin((x.dur - x.pos) / 60)} left`;
|
||
else if (hold) meta += ` \u00b7 ${Math.round(Date.now() / 1000 / 86400 - x.t / 86400)}d ago`;
|
||
const info = el("div", "mini-info");
|
||
info.append(el("div", "card-name", x.title), el("div", "mini-meta", meta));
|
||
const pb = bar(x, "small");
|
||
if (pb) info.append(pb);
|
||
c.append(info);
|
||
return c;
|
||
};
|
||
const showSec = (title, items, extra) => {
|
||
if (!items.length) return;
|
||
const head = el("div", "sec-head");
|
||
head.append(el("h2", null, title));
|
||
for (const a of extra) head.append(a);
|
||
const grid = el("div", "grid mini");
|
||
for (const x of items) grid.append(homeCard(x));
|
||
main.append(head, grid);
|
||
};
|
||
const link = (text, href) => Object.assign(el("a", null, text), {href: BASE + href});
|
||
|
||
function showHome() {
|
||
main.innerHTML = "";
|
||
if (!history.length) {
|
||
main.append(el("p", "muted empty", "Search for an anime to get started."));
|
||
api("/api/searches").then(rs => {
|
||
if (!rs.length || !main.querySelector("p.empty")) 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);
|
||
main.append(d);
|
||
}).catch(() => {});
|
||
return;
|
||
}
|
||
const hero = history[0];
|
||
// 1. continue-watching hero
|
||
const sec = el("section", "hero-sec");
|
||
const card = el("a", "hero");
|
||
card.href = histHref(hero);
|
||
if (hero.poster) {
|
||
const img = el("img"); img.src = hero.poster; img.alt = "";
|
||
card.append(img);
|
||
}
|
||
const right = el("div", "right");
|
||
const body = el("div", "hero-body");
|
||
body.append(el("div", "hero-kicker", "continue watching"),
|
||
el("h2", null, hero.title),
|
||
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
|
||
(hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
|
||
` · ${timeago(hero.t)}`));
|
||
const p = bar(hero);
|
||
if (p) body.append(p);
|
||
const heroDone = hero.dur > 0 && hero.pos >= hero.dur * 0.95;
|
||
const go = el("button", "btn resume", heroDone ? "\u25b6 Next ep" : "\u25b6 Resume");
|
||
go.onclick = () => location.href = histHref(hero);
|
||
right.append(body, go);
|
||
sec.append(card, right);
|
||
main.append(sec);
|
||
// 2-4. status board: one card per anime, grouped by state
|
||
const shows = byShow().filter(x => x !== hero);
|
||
const cont = shows.filter(x => histClass(x) === "watching").slice(0, 6);
|
||
const hold = shows.filter(x => histClass(x) === "hold").slice(0, 4);
|
||
const done = shows.filter(x => histClass(x) === "done").slice(0, 6);
|
||
showSec("Continue watching", cont, [link("history \u2192", "?history")]);
|
||
showSec("On hold", hold, hold.length ? [link("history \u2192", "?history")] : []);
|
||
// watch-time heatmap (past year, shared with ?stats)
|
||
main.append(heatSection());
|
||
showSec("Recently finished", done, done.length ? [link("history \u2192", "?history")] : []);
|
||
// 5. stats line (compact; full breakdown at ?stats)
|
||
const sh = el("div", "sec-head");
|
||
sh.append(el("h2", null, "Stats \u00b7 30d"), link("see all \u2192", "?stats"),
|
||
link("history \u2192", "?history"));
|
||
const sl = el("div", "muted", "loading\u2026");
|
||
main.append(sh, sl);
|
||
api("/api/stats?days=30").then(d => {
|
||
sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` +
|
||
` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` +
|
||
` best streak ${d.best_streak}d \u00b7 ${d.on_hold} on hold`;
|
||
}).catch(() => sl.remove());
|
||
}
|
||
|
||
// ---------- search ----------
|
||
function saveSearch(q) {
|
||
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
|
||
body: JSON.stringify({q})}).catch(() => {});
|
||
}
|
||
|
||
async function showSearch(q) {
|
||
saveSearch(q);
|
||
skel(8);
|
||
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
||
main.innerHTML = "";
|
||
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
|
||
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
|
||
const grid = el("div", "grid");
|
||
for (const r of results) {
|
||
const card = el("a", "card");
|
||
card.href = `?anime=${r.slug_id}`;
|
||
const img = el("img", null, r.name);
|
||
img.src = r.poster || "";
|
||
card.append(img, el("span", "card-name", r.name));
|
||
grid.append(card);
|
||
}
|
||
main.append(grid);
|
||
}
|
||
|
||
// ---------- 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;
|
||
main.innerHTML = "";
|
||
|
||
const head = el("section", "detail-head");
|
||
const crumb = el("a", "crumb", "\u2190 home");
|
||
crumb.href = BASE;
|
||
head.append(crumb, el("h2", null, detail.name));
|
||
const progLine = el("div", "muted detail-prog");
|
||
head.append(progLine);
|
||
api(`/api/stats?days=30&slug=${encodeURIComponent(slug_id)}`)
|
||
.then(d => { if (d.top_anime[0]) progLine.textContent = animeLine(d.top_anime[0]); })
|
||
.catch(() => {});
|
||
main.append(head);
|
||
|
||
const player = el("div", "player");
|
||
player.id = "player";
|
||
player.append(videoEl());
|
||
main.append(player);
|
||
|
||
const controls = el("div", "controls");
|
||
const langBtn = el("button", null, langLabel());
|
||
langBtn.onclick = () => {
|
||
lang = lang === "eng" ? "jpn" : "eng";
|
||
langBtn.textContent = langLabel();
|
||
const cur = main.querySelector(".ep.active");
|
||
if (cur) play(cur.dataset.ep, cur.dataset.no);
|
||
};
|
||
const spd = el("select", "spd");
|
||
for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r));
|
||
spd.value = localStorage.getItem("aniweb_spd") || "1";
|
||
spd.title = "playback speed";
|
||
spd.onchange = () => {
|
||
localStorage.setItem("aniweb_spd", spd.value);
|
||
const v = document.querySelector("#player video");
|
||
if (v) v.playbackRate = +spd.value;
|
||
};
|
||
const prevB = el("button", "tiny", "◀ prev");
|
||
const nextB = el("button", "tiny", "next ▶");
|
||
prevB.onclick = () => stepEp(-1);
|
||
nextB.onclick = () => stepEp(1);
|
||
const fillB = el("button", "tiny" + (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();
|
||
};
|
||
controls.append(langBtn, spd, prevB, nextB, fillB,
|
||
el("span", "muted", " — click an episode to play"));
|
||
main.append(controls);
|
||
|
||
const tabs = el("div", "tabs");
|
||
for (const s of detail.seasons) {
|
||
const t = el("button", "tab" + (s.id === seasonId ? " active" : ""), s.name);
|
||
t.dataset.id = s.id;
|
||
t.onclick = () => {
|
||
for (const x of tabs.children) x.classList.toggle("active", +x.dataset.id === s.id);
|
||
location.href = `?anime=${s.slug_id}&season=${s.id}&lang=${lang}`;
|
||
};
|
||
tabs.append(t);
|
||
}
|
||
main.append(tabs);
|
||
|
||
const eps = el("div", "eps");
|
||
eps.id = "eps";
|
||
eps.append(el("p", "muted", "loading episodes\u2026"));
|
||
main.append(eps);
|
||
|
||
// watched state: dim completed eps, highlight the first unwatched one
|
||
// (ponytail: "first unwatched in list order" is wrong if you skip around;
|
||
// good enough for sequential viewing)
|
||
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
||
watchDone = new Set();
|
||
epProg = new Map();
|
||
let started = false;
|
||
try {
|
||
for (const x of await api("/api/history?all=1")) {
|
||
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
||
started = true;
|
||
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
|
||
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
|
||
}
|
||
} catch {}
|
||
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
|
||
renderEps();
|
||
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
|
||
const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id;
|
||
if (start) {
|
||
const cur = [...eps.children].find(b => +b.dataset.ep === start);
|
||
if (cur) { markActive(cur); play(start, cur.dataset.no); }
|
||
}
|
||
// explicit CTA when the resumed episode is already finished
|
||
if (start && watchDone.has(String(start)) && watchNext != null && !nextCta) {
|
||
nextCta = el("button", "btn", "\u25b6 next episode");
|
||
nextCta.onclick = () => stepEp(1);
|
||
controls.append(nextCta);
|
||
}
|
||
|
||
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
||
}
|
||
|
||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||
|
||
// ---------- history page (full list + delete + client-side filter) ----------
|
||
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
|
||
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
|
||
async function showHistory() {
|
||
skel(10);
|
||
const items = await api("/api/history?all=1");
|
||
main.innerHTML = "";
|
||
main.append(el("h2", null, "History"));
|
||
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; }
|
||
main.append(...histBoard(items));
|
||
}
|
||
|
||
function histCard(x) {
|
||
const hold = histClass(x) === "hold";
|
||
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
||
c.onclick = () => location.href = histHref(x);
|
||
if (x.poster) {
|
||
const i = el("img"); i.src = x.poster; i.alt = "";
|
||
c.append(i);
|
||
}
|
||
const info = el("div", "mini-info");
|
||
info.append(el("div", "card-name", x.title),
|
||
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
|
||
(hold ? " \u00b7 on hold" : "")));
|
||
const pb = bar(x, "small");
|
||
if (pb) info.append(pb);
|
||
c.append(info);
|
||
const d = el("button", "del", "\u00d7");
|
||
d.title = "remove from history";
|
||
d.onclick = async e => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
|
||
"&ep_id=" + encodeURIComponent(x.ep_id),
|
||
{method: "DELETE", headers: {"X-Aniweb": "1"}});
|
||
toast("Removed from history");
|
||
await showHistory();
|
||
};
|
||
c.append(d);
|
||
return c;
|
||
}
|
||
|
||
// ---------- stats (last 30 days, server-computed) ----------
|
||
const fmtMin = m => { m = Math.round(m || 0);
|
||
return m >= 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${m}m`; };
|
||
const statCard = (label, value) => {
|
||
const c = el("div", "card stat");
|
||
c.append(el("div", "stat-value", value), el("div", "muted", label));
|
||
return c;
|
||
};
|
||
const holdDays = a => Math.max(1, Math.round((Date.now() / 1000 - a.last_seen) / 86400));
|
||
const statusLabel = a => a.status === "finished" ? "\u2713 finished"
|
||
: a.status === "ongoing" ? "\u25b6 ongoing" : `\u23f8 on hold \u00b7 ${holdDays(a)}d`;
|
||
const animeMeta = a => {
|
||
const bits = [`E${a.cur_ep}${a.total ? "/" + a.total : ""}`, fmtMin(a.minutes)];
|
||
if (a.pace > 0) bits.push(`${a.pace} ep/day`);
|
||
const tot = (a.sub || 0) + (a.dub || 0);
|
||
if (tot) {
|
||
const pct = Math.round(100 * (a.sub || 0) / tot);
|
||
bits.push(pct >= 90 ? "sub" : pct <= 10 ? "dub" : `sub ${pct}%`);
|
||
}
|
||
if (a.rewatches > 0) bits.push(`\u21ba ${a.rewatches} rewatch${a.rewatches > 1 ? "s" : ""}`);
|
||
return bits.join(" \u00b7 ");
|
||
};
|
||
const animeLine = a => `${animeMeta(a)} \u00b7 ${statusLabel(a)}`;
|
||
// ---------- stats + history renderers (shared by pages and home widgets) ----------
|
||
function statsCards(d) {
|
||
const out = el("div", null);
|
||
const cards = el("div", "grid stats");
|
||
cards.append(
|
||
statCard("watch time", fmtMin(d.watch_minutes)),
|
||
statCard("episodes finished / started", `${d.episodes.completed} / ${d.episodes.started}`),
|
||
statCard("days active", String(d.days_active)),
|
||
statCard("on hold", `${d.on_hold} anime`),
|
||
statCard("best streak", `${d.best_streak} days`),
|
||
statCard("sub \u00b7 dub plays", `${d.sub_dub.jpn} \u00b7 ${d.sub_dub.eng}`));
|
||
out.append(cards);
|
||
if (d.errors > 0) out.append(el("p", "muted", `${d.errors} playback error${d.errors > 1 ? "s" : ""} in the last ${d.days} days`));
|
||
return out;
|
||
}
|
||
function topAnime(d, n = 0) {
|
||
const order = {ongoing: 0, hold: 1, finished: 2};
|
||
const list = [...d.top_anime].sort((x, y) =>
|
||
order[x.status] - order[y.status] || y.minutes - x.minutes);
|
||
if (n) list.length = Math.min(n, list.length);
|
||
if (!list.length) return null;
|
||
const ta = el("section", null);
|
||
ta.append(el("h2", null, "Most watched"));
|
||
for (const a of list) {
|
||
const row = el("div", "stat-anime");
|
||
if (a.poster) {
|
||
const i = el("img"); i.src = a.poster; i.alt = "";
|
||
row.append(i);
|
||
} else row.append(el("div", "sa-ph"));
|
||
const t1 = el("div", "sa-top");
|
||
t1.append(el("span", "sa-title", a.title), el("span", "muted", animeMeta(a)));
|
||
row.append(t1,
|
||
el("span", "chip" + (a.status === "finished" ? " fin" : a.status === "ongoing" ? " on" : ""),
|
||
statusLabel(a)));
|
||
const b = el("div", "bar small");
|
||
const f = el("div", "bar-fill");
|
||
const pct = a.total ? Math.min(100, Math.round(100 * a.completed / a.total))
|
||
: (a.started ? Math.round(100 * a.completed / a.started) : 0);
|
||
f.style.width = pct + "%";
|
||
b.append(f);
|
||
row.append(b);
|
||
ta.append(row);
|
||
}
|
||
return ta;
|
||
}
|
||
function histBoard(items, limit = 0) {
|
||
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
|
||
let active = "all";
|
||
const chips = el("div", "chips");
|
||
const grid = el("div", "grid");
|
||
const draw = () => {
|
||
let f = items.filter(x => active === "all" || histClass(x) === active);
|
||
if (limit) f = f.slice(0, limit);
|
||
grid.replaceChildren(...f.map(histCard));
|
||
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
|
||
};
|
||
for (const f of Object.keys(labels)) {
|
||
const b = el("button", "chip", labels[f]);
|
||
b.dataset.f = f;
|
||
b.onclick = () => { active = f; draw(); };
|
||
chips.append(b);
|
||
}
|
||
draw();
|
||
return [chips, grid];
|
||
}
|
||
|
||
function heatMap(byDay, days) {
|
||
const today = new Date(); today.setHours(12, 0, 0, 0);
|
||
const win = new Date(today); win.setDate(win.getDate() - (days - 1));
|
||
const start = new Date(win);
|
||
start.setDate(start.getDate() - (start.getDay() + 6) % 7); // back to Monday
|
||
const max = Math.max(1, ...Object.values(byDay || {}));
|
||
const MN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||
const grid = el("div", "heat");
|
||
const lbl = el("div", "heat-lbl");
|
||
const dcol = el("div", "heat-days");
|
||
for (const t of ["Mon", "", "Wed", "", "Fri", "", ""]) dcol.append(el("span", null, t));
|
||
const row = el("div", "heat-row");
|
||
row.append(dcol, grid);
|
||
let lastCol = -10, colIdx = 0; // month label: week containing the 1st
|
||
for (let d = new Date(start); d <= today; ) {
|
||
const col = el("div", "heat-col");
|
||
const lab = el("span", "heat-lab");
|
||
for (let i = 0; i < 7; i++) {
|
||
const dd = new Date(d); dd.setDate(dd.getDate() + i);
|
||
if (dd.getDate() === 1 && dd >= win && dd <= today && colIdx - lastCol >= 2) {
|
||
lab.textContent = MN[dd.getMonth()];
|
||
lastCol = colIdx;
|
||
break; // one label per column, skip if it would crowd the previous
|
||
}
|
||
}
|
||
lbl.append(lab);
|
||
for (let i = 0; i < 7; i++) {
|
||
const c = el("i");
|
||
if (d > today) c.className = "none"; // pad the final column
|
||
else {
|
||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
const v = (byDay || {})[k] || 0;
|
||
if (d < win) c.className = "none";
|
||
else if (v) {
|
||
c.className = "l" + Math.min(4, Math.ceil(v / max * 4));
|
||
c.title = k + " \u00b7 " + fmtMin(v / 60);
|
||
}
|
||
d.setDate(d.getDate() + 1);
|
||
}
|
||
col.append(c);
|
||
}
|
||
grid.append(col);
|
||
colIdx++;
|
||
}
|
||
const lg = el("div", "heat-lg");
|
||
lg.append(el("span", "muted", "less"));
|
||
for (const cls of ["", "l1", "l2", "l3", "l4"]) {
|
||
const s = el("i");
|
||
if (cls) s.className = cls;
|
||
lg.append(s);
|
||
}
|
||
lg.append(el("span", "muted", "more"));
|
||
return [lbl, row, lg];
|
||
}
|
||
|
||
function heatSection() {
|
||
const sec = el("section", null);
|
||
const h = el("div", "sec-head");
|
||
h.append(el("h2", null, "Watch time by day"), el("span", "muted", "past year"));
|
||
const load = el("p", "muted", "loading\u2026");
|
||
sec.append(h, load);
|
||
api("/api/stats?days=365").then(d2 => {
|
||
load.remove();
|
||
const [l, g, leg] = heatMap(d2.by_day, 365);
|
||
const w = el("div", "heat-wrap");
|
||
w.append(l, g);
|
||
sec.append(w, leg);
|
||
}).catch(() => load.remove());
|
||
return sec;
|
||
}
|
||
|
||
async function showStats() {
|
||
skel(5);
|
||
const d = await api("/api/stats?days=30");
|
||
main.innerHTML = "";
|
||
main.append(el("h2", null, "Stats \u2014 last 30 days"));
|
||
main.append(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 "))));
|
||
}
|
||
}
|
||
|
||
function markActive(b) {
|
||
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
|
||
b.classList.add("active");
|
||
}
|
||
|
||
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
|
||
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;
|
||
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;
|
||
return v;
|
||
}
|
||
|
||
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
||
|
||
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
|
||
|
||
function playerOverlay(text, onAction, actionLabel = "Retry") {
|
||
const p = document.getElementById("player");
|
||
let o = p.querySelector(".pl-overlay");
|
||
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
|
||
o.replaceChildren(el("span", null, text));
|
||
if (onAction) {
|
||
const b = el("button", "btn", actionLabel);
|
||
b.onclick = onAction;
|
||
o.append(b);
|
||
}
|
||
}
|
||
function playerHideOverlay() {
|
||
const o = document.querySelector(".pl-overlay");
|
||
if (o) o.remove();
|
||
}
|
||
function playerFail(msg) {
|
||
if (!currentEp) return;
|
||
playerBroken = true;
|
||
const { ep_id, ep_no } = currentEp;
|
||
postEvent({type: "error", detail: msg, ep_id, ep: ep_no, lang});
|
||
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
|
||
}
|
||
|
||
// ---------- auto-next on episode end (5s countdown) ----------
|
||
let nextTimer = null;
|
||
function onEnded() {
|
||
if (!currentEp || playerBroken) return;
|
||
const vis = epsList.filter(e => !(hideFiller && e.filler));
|
||
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
|
||
const nxt = vis[i + 1];
|
||
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; }
|
||
nextCountdown(nxt);
|
||
}
|
||
|
||
function nextCountdown(nxt) {
|
||
const p = document.getElementById("player");
|
||
let o = p.querySelector(".pl-overlay");
|
||
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
|
||
let n = 5;
|
||
const done = () => {
|
||
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
|
||
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
|
||
if (b) markActive(b);
|
||
play(nxt.id, nxt.number);
|
||
};
|
||
const cancel = () => {
|
||
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
|
||
playerHideOverlay();
|
||
};
|
||
const draw = () => {
|
||
o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`));
|
||
const s = el("button", "btn", "Skip");
|
||
s.onclick = done;
|
||
const x = el("button", "cnt-x", "\u2715");
|
||
x.title = "cancel auto-next";
|
||
x.onclick = cancel;
|
||
o.append(s, x);
|
||
if (n <= 0) { done(); return; }
|
||
n--;
|
||
nextTimer = setTimeout(draw, 1000);
|
||
};
|
||
draw();
|
||
}
|
||
|
||
async function play(ep_id, ep_no) {
|
||
playerBroken = false;
|
||
localStorage.setItem("aniweb_lang_" + detail.slug_id, lang);
|
||
try {
|
||
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
||
stopHls();
|
||
const v = videoEl();
|
||
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
|
||
document.getElementById("player").replaceChildren(v);
|
||
playerOverlay("Loading\u2026");
|
||
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
|
||
hls = new Hls();
|
||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||
if (data.fatal) {
|
||
stopHls();
|
||
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
|
||
}
|
||
});
|
||
hls.loadSource("/stream/" + s.token);
|
||
hls.attachMedia(v);
|
||
currentEp = {ep_id, ep_no, v};
|
||
lastSavedPos = 0;
|
||
if (nextCta) { nextCta.remove(); nextCta = null; }
|
||
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
|
||
if (saved?.pos > 10) {
|
||
v.addEventListener("loadedmetadata", () => {
|
||
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
|
||
}, {once: true});
|
||
}
|
||
v.onerror = () => playerFail("media error");
|
||
v.addEventListener("playing", playerHideOverlay);
|
||
v.addEventListener("waiting", () => {
|
||
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
|
||
});
|
||
v.addEventListener("ended", onEnded);
|
||
// NotAllowedError = the browser blocked autoplay: 'playing' will never
|
||
// fire, so the Loading… overlay (which only clears on 'playing') would
|
||
// sit forever over the unreachable native controls. Other rejection
|
||
// reasons (AbortError when the stream has no data yet, …) belong to the
|
||
// hls.js fatal-error path — leave its overlay alone.
|
||
v.play().catch(err => {
|
||
if (!err || err.name !== "NotAllowedError") return;
|
||
playerOverlay("Tap to play", () => {
|
||
v.play().catch(() => playerFail("playback blocked by the browser"));
|
||
}, "Play");
|
||
});
|
||
watchPos(v);
|
||
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
|
||
} catch (e) {
|
||
showStreamError(e.message);
|
||
}
|
||
document.title = `${detail.name} E${ep_no} — ani-web`;
|
||
}
|
||
|
||
// extra fields to persist with every history entry (progress + poster)
|
||
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
|
||
poster: detail ? (detail.poster || null) : null});
|
||
|
||
// one savePos covers all video elements: it reads currentEp, so stale
|
||
// closures from earlier plays can't write the wrong episode's position
|
||
function savePos() {
|
||
if (!currentEp || currentEp.v.currentTime <= 3) return;
|
||
lastSavedPos = currentEp.v.currentTime;
|
||
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
||
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime,
|
||
...(epsList.length ? {total: epsList.length} : {})});
|
||
}
|
||
|
||
function watchPos(v) {
|
||
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); });
|
||
v.addEventListener("pause", savePos);
|
||
// pagehide is bound once at boot (per-play listeners accumulated and fired
|
||
// with stale video elements)
|
||
}
|
||
|
||
window.addEventListener("pagehide", savePos);
|
||
|
||
// ---------- boot ----------
|
||
(async () => {
|
||
document.querySelector(".logo").href = BASE;
|
||
document.getElementById("stats").href = BASE + "?stats";
|
||
try {
|
||
const me = await api("/api/me");
|
||
document.getElementById("me").textContent = "hi, " + me.user;
|
||
initRecent();
|
||
const lo = document.getElementById("logout");
|
||
lo.onclick = async () => {
|
||
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
|
||
};
|
||
const mb = document.getElementById("menubox");
|
||
mb.style.display = "";
|
||
document.getElementById("menu").onclick = e => {
|
||
e.stopPropagation(); mb.classList.toggle("open");
|
||
};
|
||
document.addEventListener("click", () => mb.classList.remove("open"));
|
||
document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); });
|
||
const da = document.getElementById("delacct");
|
||
da.onclick = async () => {
|
||
if (!confirm("Delete your account and watch history?")) return;
|
||
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
|
||
};
|
||
} catch { /* not logged in: api() already redirected to /login */ }
|
||
await refreshHistory();
|
||
const p = new URLSearchParams(location.search);
|
||
try {
|
||
if (p.get("q")) await showSearch(p.get("q"));
|
||
else if (p.get("anime")) await showAnime(p.get("anime"), {
|
||
season: p.get("season") ? +p.get("season") : null,
|
||
ep: p.get("ep") ? +p.get("ep") : null,
|
||
lang: p.get("lang") || "jpn",
|
||
});
|
||
else if (p.has("history")) await showHistory();
|
||
else if (p.has("stats")) await showStats();
|
||
else showHome();
|
||
} catch (e) { fail(e); }
|
||
})();
|