From 2efb537edc4d795a6399f034fc11e90543cce879 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 19 Aug 2026 17:01:04 +0000 Subject: [PATCH] home polish: continue-watching hero, progress bars, posters, time-ago - showHome(): most recent entry renders as a hero card (poster, episode/lang/position, progress bar, Resume); the rest become mini poster cards with progress + relative timestamp - history entries now persist dur (video.duration) and poster; /api/anime returns poster (parsed from the details page) - old entries without dur render without a bar (backward compatible) - browser test asserts the seeded entry, not a section header that only existed via a cross-test side effect --- server/upstream.py | 4 ++- static/app.js | 70 +++++++++++++++++++++++++++++++++++++------ static/style.css | 25 +++++++++++++++- tests/test_browser.py | 2 +- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/server/upstream.py b/server/upstream.py index f8e6999..b70abef 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -42,11 +42,12 @@ def search(q): def details(slug_id): - """Step 2: details page -> {name, synopsis, mal_id, seasons}""" + """Step 2: details page -> {name, synopsis, mal_id, poster, seasons}""" p = _get(f"{BASE}/anime/{slug_id}").text mal = re.search(r'myanimelist\.net/anime/(\d+)', p) syn = re.search(r'>Synopsis.*?]*>(.*?)

', p, re.S) h1 = re.search(r']*>(.*?)', p, re.S) + post = re.search(r'src="(https://cdn\.xlsbox\.com/poster/[^"\']+)"', p) seasons, seen = [], set() i = p.find(">Seasons<") if i > 0: @@ -64,6 +65,7 @@ def details(slug_id): "name": h1.group(1).strip() if h1 else None, "synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None, "mal_id": int(mal.group(1)) if mal else None, + "poster": post.group(1) if post else None, "seasons": seasons} diff --git a/static/app.js b/static/app.js index 8f699ad..648077b 100644 --- a/static/app.js +++ b/static/app.js @@ -50,18 +50,66 @@ function postHistory(entry) { 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 sec = el("section"); - sec.append(el("h2", null, "Recently played")); - for (const x of history.slice(0, 5)) { - const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"}) · ${fmtPos(x.pos)}`); - b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; - sec.append(b); - } + const hero = history[0]; + // 1. continue-watching hero + const sec = el("section", "hero-sec"); + const card = el("a", "hero"); + card.href = histHref(hero); + 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); + const i = el("img"); i.src = x.poster || ""; i.alt = ""; + 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(i, info); + grid.append(c); + } + main.append(grid); + } } // ---------- search ---------- @@ -195,7 +243,7 @@ async function play(ep_id, ep_no) { 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({title: detail.name, slug_id: detail.slug_id, season_id: seasonId, + 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); @@ -203,11 +251,15 @@ async function play(ep_id, ep_no) { 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({title: detail.name, slug_id: detail.slug_id, season_id: seasonId, + 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(); }); diff --git a/static/style.css b/static/style.css index ee57931..631e121 100644 --- a/static/style.css +++ b/static/style.css @@ -30,6 +30,30 @@ h2 { margin: 0.4rem 0; } .card img { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #000; display: block; } .card-name { padding: 0.5rem; font-size: 0.85rem; color: var(--muted); } +/* home: continue-watching hero + mini history cards */ +.hero-sec { display: grid; grid-template-columns: 170px 1fr; + grid-template-areas: "img right"; gap: 1rem; margin-bottom: 1.4rem; + background: var(--card); border: 1px solid var(--bd); border-radius: 12px; padding: 1rem; } +.hero { display: contents; } +.hero img { grid-area: img; width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: 8px; background: #000; } +.right { grid-area: right; display: flex; flex-direction: column; justify-content: space-between; + gap: .45rem; min-width: 0; } +.hero-body { display: flex; flex-direction: column; gap: .45rem; min-width: 0; } +.hero-kicker { font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; color: var(--acc); } +.hero-body h2 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.btn { align-self: start; padding: .45rem 1.1rem; border-radius: 8px; + border: 1px solid var(--acc); background: var(--acc); color: #140b02; font-weight: 600; cursor: pointer; } +.bar { height: 5px; border-radius: 3px; background: var(--bd); overflow: hidden; } +.bar-fill { height: 100%; background: var(--acc); } +.bar.small { height: 4px; margin-top: .4rem; } +.grid.mini { grid-template-columns: repeat(auto-fill, minmax(140px, 170px)); } +.grid.mini .card { cursor: pointer; } +.mini-info { display: flex; flex-direction: column; padding: .5rem; } +.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; } +@media (max-width: 640px) { + .hero-sec { grid-template-columns: 1fr; grid-template-areas: "img" "right"; } +} + .detail-head { margin-bottom: 1rem; } .synopsis { color: var(--muted); max-width: 70ch; margin: 0.3rem 0; } @@ -51,4 +75,3 @@ button:hover { border-color: var(--acc); } .ep.filler { opacity: 0.55; } .badge { font-size: 0.6rem; color: var(--muted); } -.hist-item { display: block; width: 100%; text-align: left; margin: 0.3rem 0; padding: 0.6rem 0.8rem; } diff --git a/tests/test_browser.py b/tests/test_browser.py index 3d89987..6e8c159 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -50,7 +50,7 @@ async def test_login_ui(): await page.click("#go") await _login_settled(page) assert await page.inner_text("#me") == "hi, admin" - assert "Recently played" in await page.inner_text("#main"), "seeded history must show" + assert "Seed Anime" in await page.inner_text("#main"), "seeded history must show" assert not errors, errors await browser.close() print("ok test_login_ui")