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:
1 parent
19e878800c
commit
2ec0f8a034
10 files changed
+414
-20
No files matched your search
+105
-8
@@ -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); }
|
||||
})();
|
||||
Reference in new issue
Block a user