Files
ani-web/static/app.js
T
jozamudi db715be54b fix: blocked autoplay left 'Loading…' parked over unreachable controls
The overlay only clears on 'playing', which never fires when the browser
blocks autoplay — and the overlay covers the native controls, so the
'swap to the native play button' assumption from the click-to-play
removal never held. A rejected v.play() is the signal: swap the dead
Loading… overlay for a tappable one (Play), and a second rejection
escalates to the Playback failed overlay.

Browser test fakes the policy (reject first gesture-less play()) since
headless Chromium allows autoplay. Logs showed the shape of it: six
loads of the same ep, 38-40 segments each, all 200, player never started.
2026-08-24 02:51:05 +00:00

542 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);
// The overlay only clears on 'playing', so a blocked autoplay would leave
// "Loading…" parked over the (unreachable) native controls forever — the
// rejected play() is the signal to swap it for a tappable one.
v.play().catch(() => 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); }
})();