Files
ani-web/static/app.js
T
jozamudi 3f8c387a48 fix: only surface tap-to-play on NotAllowedError (autoplay block)
The first version reacted to any play() rejection, which broke the
stream-failure path: with a dead stream play() rejects with AbortError
(no data), clobbering the hls.js 'Playback failed / Retry' overlay and
stealing the user's one click. Scope the swap to the autoplay policy
rejection; everything else stays with the fatal-error path.

test_stream_failure_retry caught it; test_autoplay_blocked_shows_tap_to_play
still passes (simulates NotAllowedError on the first gesture-less play).
2026-08-24 02:54:40 +00:00

547 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
// 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(() => {});
}
const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; };
const timeago = t => {
if (!t) return "";
const d = Math.max(0, Date.now() / 1000 - t);
if (d < 90) return "just now";
if (d < 3600) return `${Math.round(d / 60)}m ago`;
if (d < 86400) return `${Math.round(d / 3600)}h ago`;
return `${Math.round(d / 86400)}d ago`;
};
const prog = x => x.dur > 0 ? Math.min(100, Math.round(100 * x.pos / x.dur)) : null;
const bar = (x, cls) => {
const p = prog(x);
if (p == null) return null;
const b = el("div", "bar" + (cls ? " " + cls : ""));
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b;
};
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
function showHome() {
main.innerHTML = "";
if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const hero = history[0];
// 1. continue-watching hero
const sec = el("section", "hero-sec");
const card = el("a", "hero");
card.href = histHref(hero);
if (hero.poster) {
const img = el("img"); img.src = hero.poster; img.alt = "";
card.append(img);
}
const right = el("div", "right");
const body = el("div", "hero-body");
body.append(el("div", "hero-kicker", "continue watching"),
el("h2", null, hero.title),
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in · ${timeago(hero.t)}`));
const p = bar(hero);
if (p) body.append(p);
const go = el("button", "btn resume", "▶ Resume");
go.onclick = () => location.href = histHref(hero);
right.append(body, go);
sec.append(card, right);
main.append(sec);
// 2-4. the rest as mini poster cards with progress + time-ago
if (history.length > 1) {
const head = el("div", "sec-head");
head.append(el("h2", null, "Recently played"));
if (history.length >= 5) {
const va = el("a", null, "view all \u2192");
va.href = BASE + "?history";
head.append(va);
}
main.append(head);
const grid = el("div", "grid mini");
for (const x of history.slice(1, 6)) {
const c = el("a", "card");
c.href = histHref(x);
if (x.poster) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
const info = el("div", "mini-info");
info.append(el("div", "card-name", x.title));
const meta = el("div", "mini-meta");
meta.textContent = `E${x.ep} · ${x.lang === "eng" ? "dub" : "sub"} · ${timeago(x.t)}`;
info.append(meta);
const pb = bar(x, "small");
if (pb) info.append(pb);
c.append(info);
grid.append(c);
}
main.append(grid);
}
}
// ---------- search ----------
function saveSearch(q) {
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
const grid = el("div", "grid");
for (const r of results) {
const card = el("a", "card");
card.href = `?anime=${r.slug_id}`;
const img = el("img", null, r.name);
img.src = r.poster || "";
card.append(img, el("span", "card-name", r.name));
grid.append(card);
}
main.append(grid);
}
// ---------- recent searches dropdown ----------
function initRecent() {
const q = document.getElementById("q");
const box = el("div", "recent");
q.closest("form").append(box);
let items = [];
const render = () => {
const v = q.value.trim().toLowerCase();
const list = items.filter(s => !v || s.toLowerCase().includes(v)).slice(0, 8);
box.replaceChildren();
if (!list.length) { box.classList.remove("open"); return; }
for (const s of list) {
const b = el("button", null, s);
b.onclick = () => { q.value = s; location.href = "?q=" + encodeURIComponent(s); };
box.append(b);
}
box.classList.add("open");
};
const hide = () => box.classList.remove("open");
q.addEventListener("focus", () => api("/api/searches").then(r => { items = r; render(); }).catch(hide));
q.addEventListener("input", render);
document.addEventListener("click", e => { if (!e.target.closest("header form")) hide(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") hide(); });
}
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
skel();
detail = await api(`/api/anime/${slug_id}`);
lang = opts.lang || "jpn";
seasonId = opts.season || detail.id;
detail.slug_id = slug_id;
main.innerHTML = "";
const head = el("section", "detail-head");
const crumb = el("a", "crumb", "\u2190 home");
crumb.href = BASE;
head.append(crumb, el("h2", null, detail.name));
main.append(head);
const player = el("div", "player");
player.id = "player";
player.append(videoEl());
main.append(player);
const controls = el("div", "controls");
const langBtn = el("button", null, langLabel());
langBtn.onclick = () => {
lang = lang === "eng" ? "jpn" : "eng";
langBtn.textContent = langLabel();
const cur = main.querySelector(".ep.active");
if (cur) play(cur.dataset.ep, cur.dataset.no);
};
const spd = el("select", "spd");
for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r));
spd.value = localStorage.getItem("aniweb_spd") || "1";
spd.title = "playback speed";
spd.onchange = () => {
localStorage.setItem("aniweb_spd", spd.value);
const v = document.querySelector("#player video");
if (v) v.playbackRate = +spd.value;
};
const prevB = el("button", "tiny", "◀ prev");
const nextB = el("button", "tiny", "next ▶");
prevB.onclick = () => stepEp(-1);
nextB.onclick = () => stepEp(1);
const fillB = el("button", "tiny", "hide fillers");
fillB.onclick = () => {
hideFiller = !hideFiller;
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
fillB.classList.toggle("on", hideFiller);
renderEps();
};
controls.append(langBtn, spd, prevB, nextB, fillB,
el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
for (const s of detail.seasons) {
const t = el("button", "tab" + (s.id === seasonId ? " active" : ""), s.name);
t.dataset.id = s.id;
t.onclick = () => {
for (const x of tabs.children) x.classList.toggle("active", +x.dataset.id === s.id);
location.href = `?anime=${s.slug_id}&season=${s.id}&lang=${lang}`;
};
tabs.append(t);
}
main.append(tabs);
const eps = el("div", "eps");
eps.id = "eps";
eps.append(el("p", "muted", "loading episodes\u2026"));
main.append(eps);
// watched state: dim completed eps, highlight the first unwatched one
// (ponytail: "first unwatched in list order" is wrong if you skip around;
// good enough for sequential viewing)
epsList = await api(`/api/anime/${seasonId}/episodes`);
watchDone = new Set();
let started = false;
try {
for (const x of await api("/api/history?all=1")) {
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
started = true;
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
}
} catch {}
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
renderEps();
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id;
if (start) {
const cur = [...eps.children].find(b => +b.dataset.ep === start);
if (cur) { markActive(cur); play(start, cur.dataset.no); }
}
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
}
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete) ----------
async function showHistory() {
skel(10);
const items = await api("/api/history?all=1");
main.innerHTML = "";
main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; }
const grid = el("div", "grid");
for (const x of items) grid.append(histCard(x));
main.append(grid);
}
function histCard(x) {
const c = el("div", "card hist");
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)}`));
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;
}
function markActive(b) {
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
b.classList.add("active");
}
let epsList = [], hideFiller = false;
let watchDone = new Set(), watchNext = null;
function renderEps() {
const eps = document.getElementById("eps");
if (!eps) return;
eps.innerHTML = "";
for (const e of epsList) {
if (hideFiller && e.filler) continue;
const b = el("button", "ep" + (e.filler ? " filler" : ""));
const done = watchDone.has(String(e.id));
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
if (done) b.classList.add("done");
if (watchNext != null && String(watchNext) === String(e.id)) b.classList.add("nextup");
if (e.filler) b.append(el("span", "badge", " filler"));
b.dataset.ep = e.id; b.dataset.no = e.number;
b.onclick = () => { markActive(b); play(e.id, e.number); };
eps.append(b);
}
}
function stepEp(d) {
const vis = epsList.filter(e => !(hideFiller && e.filler));
if (!vis.length || !currentEp) return;
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const n = vis[i + d];
if (!n) return;
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === n.id);
if (b) { markActive(b); play(n.id, n.number); }
}
function videoEl() {
const v = document.createElement("video");
v.controls = true; v.autoplay = true;
return v;
}
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0, playerBroken = false;
function playerOverlay(text, onAction, actionLabel = "Retry") {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
o.replaceChildren(el("span", null, text));
if (onAction) {
const b = el("button", "btn", actionLabel);
b.onclick = onAction;
o.append(b);
}
}
function playerHideOverlay() {
const o = document.querySelector(".pl-overlay");
if (o) o.remove();
}
function playerFail(msg) {
if (!currentEp) return;
playerBroken = true;
const { ep_id, ep_no } = currentEp;
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
// ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null;
function onEnded() {
if (!currentEp || playerBroken) return;
const vis = epsList.filter(e => !(hideFiller && e.filler));
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const nxt = vis[i + 1];
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; }
nextCountdown(nxt);
}
function nextCountdown(nxt) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
let n = 5;
const done = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
if (b) markActive(b);
play(nxt.id, nxt.number);
};
const cancel = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
playerHideOverlay();
};
const draw = () => {
o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`));
const s = el("button", "btn", "Skip");
s.onclick = done;
const x = el("button", "cnt-x", "\u2715");
x.title = "cancel auto-next";
x.onclick = cancel;
o.append(s, x);
if (n <= 0) { done(); return; }
n--;
nextTimer = setTimeout(draw, 1000);
};
draw();
}
async function play(ep_id, ep_no) {
playerBroken = false;
try {
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
stopHls();
const v = videoEl();
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026");
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
hls = new Hls();
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
hls.loadSource("/stream/" + s.token);
hls.attachMedia(v);
currentEp = {ep_id, ep_no, v};
lastSavedPos = 0;
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
if (saved?.pos > 10) {
v.addEventListener("loadedmetadata", () => {
if (saved.pos < v.duration * 0.95) v.currentTime = saved.pos;
}, {once: true});
}
v.onerror = () => playerFail("media error");
v.addEventListener("playing", playerHideOverlay);
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
});
v.addEventListener("ended", onEnded);
// NotAllowedError = the browser blocked autoplay: 'playing' will never
// fire, so the Loading… overlay (which only clears on 'playing') would
// sit forever over the unreachable native controls. Other rejection
// reasons (AbortError when the stream has no data yet, …) belong to the
// hls.js fatal-error path — leave its overlay alone.
v.play().catch(err => {
if (!err || err.name !== "NotAllowedError") return;
playerOverlay("Tap to play", () => {
v.play().catch(() => playerFail("playback blocked by the browser"));
}, "Play");
});
watchPos(v);
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
} catch (e) {
showStreamError(e.message);
}
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// extra fields to persist with every history entry (progress + poster)
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
poster: detail ? (detail.poster || null) : null});
// one savePos covers all video elements: it reads currentEp, so stale
// closures from earlier plays can't write the wrong episode's position
function savePos() {
if (!currentEp || currentEp.v.currentTime <= 3) return;
lastSavedPos = currentEp.v.currentTime;
postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime});
}
function watchPos(v) {
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); });
v.addEventListener("pause", savePos);
// pagehide is bound once at boot (per-play listeners accumulated and fired
// with stale video elements)
}
window.addEventListener("pagehide", savePos);
// ---------- boot ----------
(async () => {
document.querySelector(".logo").href = BASE;
try {
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
const lo = document.getElementById("logout");
lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1";
};
const mb = document.getElementById("menubox");
mb.style.display = "";
document.getElementById("menu").onclick = e => {
e.stopPropagation(); mb.classList.toggle("open");
};
document.addEventListener("click", () => mb.classList.remove("open"));
document.addEventListener("keydown", e => { if (e.key === "Escape") mb.classList.remove("open"); });
const da = document.getElementById("delacct");
da.onclick = async () => {
if (!confirm("Delete your account and watch history?")) return;
await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1";
};
} catch { /* not logged in: api() already redirected to /login */ }
await refreshHistory();
const p = new URLSearchParams(location.search);
try {
if (p.get("q")) await showSearch(p.get("q"));
else if (p.get("anime")) await showAnime(p.get("anime"), {
season: p.get("season") ? +p.get("season") : null,
ep: p.get("ep") ? +p.get("ep") : null,
lang: p.get("lang") || "jpn",
});
else if (p.has("history")) await showHistory();
else showHome();
} catch (e) { fail(e); }
})();