diff --git a/static/app.js b/static/app.js index e783f64..d51c96b 100644 --- a/static/app.js +++ b/static/app.js @@ -255,8 +255,20 @@ async function showAnime(slug_id, opts = {}) { eps.append(el("p", "muted", "loading episodes\u2026")); main.append(eps); - // default: resume last watched ep of this season, else start at ep 1 + // watched state: dim completed eps, highlight the first unwatched one + // (ponytail: "first unwatched in list order" is wrong if you skip around; + // good enough for sequential viewing) epsList = await api(`/api/anime/${seasonId}/episodes`); + watchDone = new Set(); + 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)); + } + } catch {} + watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null; renderEps(); const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId); const start = opts.ep || (h ? h.ep_id : null) || epsList[0]?.id; @@ -315,6 +327,7 @@ function markActive(b) { } let epsList = [], hideFiller = false; +let watchDone = new Set(), watchNext = null; function renderEps() { const eps = document.getElementById("eps"); @@ -323,7 +336,10 @@ function renderEps() { for (const e of epsList) { if (hideFiller && e.filler) continue; const b = el("button", "ep" + (e.filler ? " filler" : "")); - b.textContent = "E" + e.number; + const done = watchDone.has(String(e.id)); + b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number; + if (done) b.classList.add("done"); + 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; b.onclick = () => { markActive(b); play(e.id, e.number); }; diff --git a/static/index.html b/static/index.html index 2e332d5..0e26a6d 100644 --- a/static/index.html +++ b/static/index.html @@ -4,6 +4,8 @@ ani-web + + diff --git a/static/style.css b/static/style.css index dfcb2dd..e821e64 100644 --- a/static/style.css +++ b/static/style.css @@ -115,5 +115,7 @@ button.tiny.on { border-color: var(--acc); color: var(--acc); } .ep { position: relative; font-size: 0.9rem; } .ep.active { border-color: var(--acc); color: var(--acc); } .ep.filler { opacity: 0.55; } +.ep.done { opacity: 0.5; } +.ep.nextup { border-color: #4b5568; background: #1f2130; } .badge { font-size: 0.6rem; color: var(--muted); } diff --git a/tests/run.py b/tests/run.py index c070705..daf38b4 100644 --- a/tests/run.py +++ b/tests/run.py @@ -78,6 +78,7 @@ def main(): asyncio.run(test_browser.test_stream_failure_retry()) asyncio.run(test_browser.test_recent_searches()) asyncio.run(test_browser.test_auto_next_countdown()) + asyncio.run(test_browser.test_episode_watch_state()) print("ALL TESTS PASSED") finally: diff --git a/tests/test_browser.py b/tests/test_browser.py index 20aca78..0bfe467 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -61,6 +61,7 @@ async def test_login_ui(): await page.keyboard.press("Enter") await _login_settled(page) assert await page.inner_text("#me") == "hi, admin" + assert await page.query_selector('link[rel="icon"]'), "favicon missing" assert "Seed Anime" in await page.inner_text("#main"), "seeded history must show" assert not errors, errors await browser.close() @@ -225,6 +226,35 @@ async def test_auto_next_countdown(): print("ok test_auto_next_countdown") +async def test_episode_watch_state(): + # completed episodes dimmed with a check, first unwatched highlighted. + # seed the first rendered ep as finished (id read off the list), + # reload, and check the classes. + async with async_playwright() as p: + browser = await p.chromium.launch(args=LAUNCH_ARGS) + page = await (await browser.new_context()).new_page() + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + + await _login(page, "admin", "adminpw12345") + await page.goto(BASE + "/?anime=chainsaw-man-922") + await page.wait_for_selector("#eps .ep", timeout=30000) + e1_id = await page.evaluate("document.querySelector('#eps .ep').dataset.ep") + await page.evaluate( + "(id) => fetch('api/history', {method: 'POST'," + " headers: {'Content-Type': 'application/json'}," + " body: JSON.stringify({title: 'Chainsaw Man', slug_id: 'chainsaw-man-922'," + " season_id: 922, ep_id: +id, lang: 'eng', ep: 1, pos: 2350, dur: 2400})})", + e1_id) + await page.reload() + await page.wait_for_selector("#eps .ep.done", timeout=30000) + assert "\u2713" in await page.inner_text("#eps .ep.done") + await page.wait_for_selector("#eps .ep.nextup", timeout=10000) + assert not errors, errors + await browser.close() + print("ok test_episode_watch_state") + + if __name__ == "__main__": if len(sys.argv) > 1: BASE = sys.argv[1] @@ -234,3 +264,4 @@ if __name__ == "__main__": asyncio.run(test_stream_failure_retry()) asyncio.run(test_recent_searches()) asyncio.run(test_auto_next_countdown()) + asyncio.run(test_episode_watch_state())