diff --git a/static/app.js b/static/app.js index 5f292cf..a6ef705 100644 --- a/static/app.js +++ b/static/app.js @@ -102,7 +102,9 @@ function showHome() { 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)}`)); + el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` + + (hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") + + ` · ${timeago(hero.t)}`)); const p = bar(hero); if (p) body.append(p); const go = el("button", "btn resume", "▶ Resume"); @@ -157,6 +159,7 @@ 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; } + main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`)); const grid = el("div", "grid"); for (const r of results) { const card = el("a", "card"); @@ -198,7 +201,7 @@ function initRecent() { async function showAnime(slug_id, opts = {}) { skel(); detail = await api(`/api/anime/${slug_id}`); - lang = opts.lang || "jpn"; + lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn"; seasonId = opts.season || detail.id; detail.slug_id = slug_id; main.innerHTML = ""; @@ -240,9 +243,10 @@ async function showAnime(slug_id, opts = {}) { const nextB = el("button", "tiny", "next ▶"); prevB.onclick = () => stepEp(-1); nextB.onclick = () => stepEp(1); - const fillB = el("button", "tiny", "hide fillers"); + const fillB = el("button", "tiny" + (hideFiller ? " on" : ""), "hide fillers"); fillB.onclick = () => { hideFiller = !hideFiller; + localStorage.setItem("aniweb_ff", hideFiller ? "1" : "0"); fillB.textContent = hideFiller ? "show fillers" : "hide fillers"; fillB.classList.toggle("on", hideFiller); renderEps(); @@ -273,12 +277,14 @@ async function showAnime(slug_id, opts = {}) { // good enough for sequential viewing) epsList = await api(`/api/anime/${seasonId}/episodes`); watchDone = new Set(); + epProg = new Map(); 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)); + else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur))); } } catch {} watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null; @@ -289,26 +295,48 @@ async function showAnime(slug_id, opts = {}) { const cur = [...eps.children].find(b => +b.dataset.ep === start); if (cur) { markActive(cur); play(start, cur.dataset.no); } } + // explicit CTA when the resumed episode is already finished + if (start && watchDone.has(String(start)) && watchNext != null && !nextCta) { + nextCta = el("button", "btn", "\u25b6 next episode"); + nextCta.onclick = () => stepEp(1); + controls.append(nextCta); + } if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis)); } function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; } -// ---------- history page (full list + delete) ---------- +// ---------- history page (full list + delete + client-side filter) ---------- +const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done" + : (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching"); 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 labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"}; + let active = "all"; + const chips = el("div", "chips"); const grid = el("div", "grid"); - for (const x of items) grid.append(histCard(x)); + const draw = () => { + grid.replaceChildren(...items.filter(x => active === "all" || histClass(x) === active).map(histCard)); + for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); + }; + for (const f of Object.keys(labels)) { + const b = el("button", "chip", labels[f]); + b.dataset.f = f; + b.onclick = () => { active = f; draw(); }; + chips.append(b); + } + main.append(chips); + draw(); main.append(grid); } function histCard(x) { - const hold = x.t && Date.now() / 1000 - x.t > 7 * 86400; + const hold = histClass(x) === "hold"; const c = el("div", "card hist" + (hold ? " hold" : "")); c.onclick = () => location.href = histHref(x); if (x.poster) { @@ -429,8 +457,8 @@ function markActive(b) { b.classList.add("active"); } -let epsList = [], hideFiller = false; -let watchDone = new Set(), watchNext = null; +let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1"; +let watchDone = new Set(), watchNext = null, epProg = new Map(); function renderEps() { const eps = document.getElementById("eps"); @@ -442,6 +470,12 @@ function renderEps() { const done = watchDone.has(String(e.id)); b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number; if (done) b.classList.add("done"); + const pct = epProg.get(String(e.id)); + if (!done && pct != null && pct > 5) { + const pb = el("div", "bar"); + pb.append(Object.assign(el("div", "bar-fill"), {style: `width:${pct}%`})); + b.append(pb); + } 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; @@ -468,7 +502,7 @@ function videoEl() { function stopHls() { if (hls) { hls.destroy(); hls = null; } } -let currentEp = null, lastSavedPos = 0, playerBroken = false; +let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null; function playerOverlay(text, onAction, actionLabel = "Retry") { const p = document.getElementById("player"); @@ -536,6 +570,7 @@ function nextCountdown(nxt) { async function play(ep_id, ep_no) { playerBroken = false; + localStorage.setItem("aniweb_lang_" + detail.slug_id, lang); try { const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`); stopHls(); @@ -555,6 +590,7 @@ async function play(ep_id, ep_no) { hls.attachMedia(v); currentEp = {ep_id, ep_no, v}; lastSavedPos = 0; + if (nextCta) { nextCta.remove(); nextCta = null; } const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id); if (saved?.pos > 10) { v.addEventListener("loadedmetadata", () => { @@ -613,6 +649,7 @@ window.addEventListener("pagehide", savePos); // ---------- boot ---------- (async () => { document.querySelector(".logo").href = BASE; + document.getElementById("stats").href = BASE + "?stats"; try { const me = await api("/api/me"); document.getElementById("me").textContent = "hi, " + me.user; diff --git a/static/index.html b/static/index.html index 0e26a6d..9dd38e9 100644 --- a/static/index.html +++ b/static/index.html @@ -18,6 +18,7 @@