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
This commit is contained in:
1 parent
e0f5427002
commit
2efb537edc
4 files changed
+89
-12
No files matched your search
+3
-1
@@ -42,11 +42,12 @@ def search(q):
|
|||||||
|
|
||||||
|
|
||||||
def details(slug_id):
|
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
|
p = _get(f"{BASE}/anime/{slug_id}").text
|
||||||
mal = re.search(r'myanimelist\.net/anime/(\d+)', p)
|
mal = re.search(r'myanimelist\.net/anime/(\d+)', p)
|
||||||
syn = re.search(r'>Synopsis</h2>.*?<p[^>]*>(.*?)</p>', p, re.S)
|
syn = re.search(r'>Synopsis</h2>.*?<p[^>]*>(.*?)</p>', p, re.S)
|
||||||
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', p, re.S)
|
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', p, re.S)
|
||||||
|
post = re.search(r'src="(https://cdn\.xlsbox\.com/poster/[^"\']+)"', p)
|
||||||
seasons, seen = [], set()
|
seasons, seen = [], set()
|
||||||
i = p.find(">Seasons<")
|
i = p.find(">Seasons<")
|
||||||
if i > 0:
|
if i > 0:
|
||||||
@@ -64,6 +65,7 @@ def details(slug_id):
|
|||||||
"name": h1.group(1).strip() if h1 else None,
|
"name": h1.group(1).strip() if h1 else None,
|
||||||
"synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None,
|
"synopsis": re.sub(r"<[^>]+>", "", syn.group(1)).strip() if syn else None,
|
||||||
"mal_id": int(mal.group(1)) if mal else None,
|
"mal_id": int(mal.group(1)) if mal else None,
|
||||||
|
"poster": post.group(1) if post else None,
|
||||||
"seasons": seasons}
|
"seasons": seasons}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+61
-9
@@ -50,18 +50,66 @@ function postHistory(entry) {
|
|||||||
body: JSON.stringify(entry)}).catch(() => {});
|
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 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() {
|
function showHome() {
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
|
if (!history.length) { main.append(el("p", "muted", "Search for an anime to get started.")); return; }
|
||||||
const sec = el("section");
|
const hero = history[0];
|
||||||
sec.append(el("h2", null, "Recently played"));
|
// 1. continue-watching hero
|
||||||
for (const x of history.slice(0, 5)) {
|
const sec = el("section", "hero-sec");
|
||||||
const b = el("button", "hist-item", `${x.title} — Ep ${x.ep} (${x.lang === "eng" ? "dub" : "sub"}) · ${fmtPos(x.pos)}`);
|
const card = el("a", "hero");
|
||||||
b.onclick = () => location.href = `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`;
|
card.href = histHref(hero);
|
||||||
sec.append(b);
|
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);
|
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 ----------
|
// ---------- search ----------
|
||||||
@@ -195,7 +243,7 @@ async function play(ep_id, ep_no) {
|
|||||||
v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
|
v.onerror = () => showStreamError("media error: " + (v.error?.message || v.error?.code));
|
||||||
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
v.play().catch(() => showPlayOverlay(v)); // autoplay blocked (no user gesture) -> visible prompt
|
||||||
watchPos(v);
|
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});
|
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showStreamError(e.message);
|
showStreamError(e.message);
|
||||||
@@ -203,11 +251,15 @@ async function play(ep_id, ep_no) {
|
|||||||
document.title = `${detail.name} E${ep_no} — ani-web`;
|
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) {
|
function watchPos(v) {
|
||||||
const save = () => {
|
const save = () => {
|
||||||
if (!currentEp || v.currentTime <= 3) return;
|
if (!currentEp || v.currentTime <= 3) return;
|
||||||
lastSavedPos = v.currentTime;
|
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});
|
ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime});
|
||||||
};
|
};
|
||||||
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
|
v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); });
|
||||||
|
|||||||
+24
-1
@@ -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 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); }
|
.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; }
|
.detail-head { margin-bottom: 1rem; }
|
||||||
.synopsis { color: var(--muted); max-width: 70ch; margin: 0.3rem 0; }
|
.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; }
|
.ep.filler { opacity: 0.55; }
|
||||||
.badge { font-size: 0.6rem; color: var(--muted); }
|
.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; }
|
|
||||||
@@ -50,7 +50,7 @@ async def test_login_ui():
|
|||||||
await page.click("#go")
|
await page.click("#go")
|
||||||
await _login_settled(page)
|
await _login_settled(page)
|
||||||
assert await page.inner_text("#me") == "hi, admin"
|
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
|
assert not errors, errors
|
||||||
await browser.close()
|
await browser.close()
|
||||||
print("ok test_login_ui")
|
print("ok test_login_ui")
|
||||||
|
|||||||
Reference in new issue
Block a user