Files
ani-web/static/app.js
T
jozamudi 32f93c0f65 autoplay first episode by default, resume watched
- no ?ep= param: resume last watched ep of the season from history,
  else start at ep 1 (verified in headless Chromium, no click needed)
- if the browser blocks autoplay (no user gesture yet), show a
  'click to play' overlay instead of a silent black screen
2026-08-19 15:21:15 +00:00

203 lines
6.6 KiB
JavaScript

// app.js — ani-web frontend: search -> detail -> play, vanilla JS + hls.js
const main = document.getElementById("main");
let hls = null, detail = null, lang = "eng", seasonId = null;
async function api(path) {
const r = await fetch(path);
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 ----------
const HKEY = "aniweb.history";
const hist = () => JSON.parse(localStorage.getItem(HKEY) || "[]");
function pushHistory(entry) {
const h = hist().filter(x => !(x.slug_id === entry.slug_id && x.ep_id === entry.ep_id));
h.unshift(entry);
localStorage.setItem(HKEY, JSON.stringify(h.slice(0, 20)));
}
function showHome() {
main.innerHTML = "";
const h = hist();
if (!h.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
const sec = el("section");
sec.append(el("h2", null, "Recently played"));
for (const x of h) {
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"})`);
b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
sec.append(b);
}
main.append(sec);
}
// ---------- 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 = hist().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; } }
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);
v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
} catch (e) {
showStreamError(e.message);
}
pushHistory({title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no});
document.title = `${detail.name} E${ep_no} — ani-web`;
}
function showPlayOverlay(v) {
const o = el("button", "play-overlay", "▶ click to play");
o.onclick = () => { v.play(); o.remove(); };
v.parentElement.append(o);
}
// ---------- boot ----------
(async () => {
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); }
})();