- backfill script deduped by slug_id, so a second entry for the same anime kept no poster -> rendered as a broken <img src=""> - re-backfilled from the already-fetched poster per slug - showHome now only emits an <img> when the entry has a poster
314 lines
11 KiB
JavaScript
314 lines
11 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 = "eng", seasonId = null;
|
|
|
|
async function api(path) {
|
|
const r = await fetch(BASE + path.replace(/^\//, ""));
|
|
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 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() {
|
|
try { history = await api("/api/history"); } catch { /* home just shows empty */ }
|
|
}
|
|
function postHistory(entry) {
|
|
fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json"},
|
|
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) {
|
|
main.append(el("h2", null, "Recently played"));
|
|
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 ----------
|
|
async function showSearch(q) {
|
|
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);
|
|
}
|
|
|
|
// ---------- detail + player ----------
|
|
async function showAnime(slug_id, opts = {}) {
|
|
detail = await api(`/api/anime/${slug_id}`);
|
|
lang = opts.lang || "eng";
|
|
seasonId = opts.season || detail.id;
|
|
detail.slug_id = slug_id;
|
|
main.innerHTML = "";
|
|
|
|
const head = el("section", "detail-head");
|
|
head.append(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());
|
|
const setLang = l => { lang = l; langBtn.textContent = langLabel(); };
|
|
langBtn.onclick = () => {
|
|
setLang(lang === "eng" ? "jpn" : "eng");
|
|
const cur = main.querySelector(".ep.active");
|
|
if (cur) play(cur.dataset.ep, cur.dataset.no);
|
|
};
|
|
controls.append(langBtn, 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";
|
|
main.append(eps);
|
|
|
|
const list = await api(`/api/anime/${seasonId}/episodes`);
|
|
for (const e of list) {
|
|
const b = el("button", "ep" + (e.filler ? " filler" : ""));
|
|
b.textContent = "E" + e.number;
|
|
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);
|
|
}
|
|
if (opts.ep) {
|
|
const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep);
|
|
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
|
|
} else {
|
|
// default: resume last watched ep of this season, else start at ep 1
|
|
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
|
|
const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep;
|
|
if (auto) {
|
|
const cur = [...eps.children].find(b => +b.dataset.ep === auto);
|
|
if (cur) { markActive(cur); play(auto, cur.dataset.no); }
|
|
}
|
|
}
|
|
|
|
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
|
}
|
|
|
|
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
|
|
|
function markActive(b) {
|
|
for (const x of document.querySelectorAll(".ep")) x.classList.remove("active");
|
|
b.classList.add("active");
|
|
}
|
|
|
|
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;
|
|
|
|
async function play(ep_id, ep_no) {
|
|
try {
|
|
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
|
stopHls();
|
|
const v = videoEl();
|
|
document.getElementById("player").replaceChildren(v);
|
|
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) {
|
|
showStreamError(`${data.type}: ${data.details}` +
|
|
(data.response ? ` (HTTP ${data.response.code})` : ""));
|
|
stopHls();
|
|
}
|
|
});
|
|
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 = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
|
|
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
|
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});
|
|
|
|
function watchPos(v) {
|
|
const save = () => {
|
|
if (!currentEp || v.currentTime <= 3) return;
|
|
lastSavedPos = v.currentTime;
|
|
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
|
|
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime});
|
|
};
|
|
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
|
|
v.addEventListener("pause", save);
|
|
window.addEventListener("pagehide", save);
|
|
}
|
|
|
|
function showPlayOverlay(v) {
|
|
const o = el("button", "play-overlay", "▶ click to play");
|
|
o.onclick = () => { v.play(); o.remove(); };
|
|
v.parentElement.append(o);
|
|
}
|
|
|
|
// ---------- boot ----------
|
|
(async () => {
|
|
try {
|
|
const me = await api("/api/me");
|
|
document.getElementById("me").textContent = "hi, " + me.user;
|
|
const lo = document.getElementById("logout");
|
|
lo.onclick = async () => {
|
|
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login";
|
|
};
|
|
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"));
|
|
const da = document.getElementById("delacct");
|
|
da.onclick = async () => {
|
|
if (!confirm("Delete your account and watch history?")) return;
|
|
await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login";
|
|
};
|
|
} 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") || "eng",
|
|
});
|
|
else showHome();
|
|
} catch (e) { fail(e); }
|
|
})();
|