ui: loading states, player failure overlay + retry, toasts, back nav, full history page with delete

Bundle 1-4+7 from PLAN.md: skeleton loaders, player Loading/Buffering/fatal overlay with Retry, action toasts, back-to-home crumb + logo fix, ?history full-history view with per-entry delete (GET /api/history?all=1, DELETE /api/history). 14/14 tests green.
This commit is contained in:
jozamudi committed 2026-08-19 18:20:42 +00:00
1 parent 19e878800c
commit 2ec0f8a034
10 files changed
+414 -20

No files matched your search

+105 -8
View File
@@ -34,6 +34,19 @@ function fail(err) {
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");
@@ -94,7 +107,14 @@ function showHome() {
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 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");
@@ -119,6 +139,7 @@ function showHome() {
// ---------- search ----------
async function showSearch(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; }
@@ -136,6 +157,7 @@ async function showSearch(q) {
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
skel();
detail = await api(`/api/anime/${slug_id}`);
lang = opts.lang || "eng";
seasonId = opts.season || detail.id;
@@ -143,7 +165,9 @@ async function showAnime(slug_id, opts = {}) {
main.innerHTML = "";
const head = el("section", "detail-head");
head.append(el("h2", null, detail.name));
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");
@@ -176,9 +200,11 @@ async function showAnime(slug_id, opts = {}) {
const eps = el("div", "eps");
eps.id = "eps";
eps.append(el("p", "muted", "loading episodes\u2026"));
main.append(eps);
const list = await api(`/api/anime/${seasonId}/episodes`);
eps.innerHTML = "";
for (const e of list) {
const b = el("button", "ep" + (e.filler ? " filler" : ""));
b.textContent = "E" + e.number;
@@ -205,6 +231,45 @@ async function showAnime(slug_id, opts = {}) {
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"});
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");
@@ -218,21 +283,44 @@ function videoEl() {
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
let currentEp = null, lastSavedPos = 0;
let currentEp = null, lastSavedPos = 0, playerBroken = false;
function playerOverlay(text, onRetry) {
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 (onRetry) {
const b = el("button", "btn", "Retry");
b.onclick = onRetry;
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));
}
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();
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) {
showStreamError(`${data.type}: ${data.details}` +
(data.response ? ` (HTTP ${data.response.code})` : ""));
stopHls();
playerFail(data.details + (data.response ? ` (HTTP ${data.response.code})` : ""));
}
});
hls.loadSource("/stream/" + s.token);
@@ -245,7 +333,11 @@ async function play(ep_id, ep_no) {
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.onerror = () => playerFail("media error");
v.addEventListener("playing", playerHideOverlay);
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
});
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,
@@ -273,6 +365,8 @@ function watchPos(v) {
}
function showPlayOverlay(v) {
if (playerBroken) return; // failure overlay (with Retry) takes precedence
playerHideOverlay();
const o = el("button", "play-overlay", "▶ click to play");
o.onclick = () => { v.play(); o.remove(); };
v.parentElement.append(o);
@@ -280,12 +374,13 @@ function showPlayOverlay(v) {
// ---------- boot ----------
(async () => {
document.querySelector(".logo").href = BASE;
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";
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1";
};
const mb = document.getElementById("menubox");
mb.style.display = "";
@@ -293,10 +388,11 @@ function showPlayOverlay(v) {
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"}); location.href = BASE + "login";
await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login?deleted=1";
};
} catch { /* not logged in: api() already redirected to /login */ }
await refreshHistory();
@@ -308,6 +404,7 @@ function showPlayOverlay(v) {
ep: p.get("ep") ? +p.get("ep") : null,
lang: p.get("lang") || "eng",
});
else if (p.has("history")) await showHistory();
else showHome();
} catch (e) { fail(e); }
})();