features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches

This commit is contained in:
jozamudi committed 2026-08-19 18:41:13 +00:00
1 parent 9fdf3944fc
commit 98fb4e5461
6 files changed
+269 -25

No files matched your search

+34
View File
@@ -175,6 +175,40 @@ def api_history_post(entry: dict = Body(...), request: Request = None):
return entry
SEARCH_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "searches"
def _load_searches(user: str):
try:
return json.loads((SEARCH_DIR / f"{user}.json").read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_searches(user: str, items):
SEARCH_DIR.mkdir(exist_ok=True)
(SEARCH_DIR / f"{user}.json").write_text(json.dumps(items, indent=1))
@app.get("/api/searches")
def api_searches(request: Request):
return JSONResponse(_load_searches(request.state.user),
headers={"Cache-Control": "no-store"})
@app.post("/api/searches")
def api_searches_post(item: dict = Body(...), request: Request = None):
q = str(item.get("q", "")).strip()[:60]
if not q:
raise HTTPException(422, "q required")
u = request.state.user
# dedupe case-insensitively, newest first, cap 8
items = [x for x in _load_searches(u) if x.lower() != q.lower()]
items.insert(0, q)
_save_searches(u, items[:8])
return {"ok": True}
def _b64(url: str) -> str:
return base64.urlsafe_b64encode(url.encode()).decode()
+134 -24
View File
@@ -138,7 +138,13 @@ function showHome() {
}
// ---------- search ----------
function saveSearch(q) {
fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({q})}).catch(() => {});
}
async function showSearch(q) {
saveSearch(q);
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
@@ -155,6 +161,31 @@ async function showSearch(q) {
main.append(grid);
}
// ---------- recent searches dropdown ----------
function initRecent() {
const q = document.getElementById("q");
const box = el("div", "recent");
q.closest("form").append(box);
let items = [];
const render = () => {
const v = q.value.trim().toLowerCase();
const list = items.filter(s => !v || s.toLowerCase().includes(v)).slice(0, 8);
box.replaceChildren();
if (!list.length) { box.classList.remove("open"); return; }
for (const s of list) {
const b = el("button", null, s);
b.onclick = () => { q.value = s; location.href = "?q=" + encodeURIComponent(s); };
box.append(b);
}
box.classList.add("open");
};
const hide = () => box.classList.remove("open");
q.addEventListener("focus", () => api("/api/searches").then(r => { items = r; render(); }).catch(hide));
q.addEventListener("input", render);
document.addEventListener("click", e => { if (!e.target.closest("header form")) hide(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") hide(); });
}
// ---------- detail + player ----------
async function showAnime(slug_id, opts = {}) {
skel();
@@ -177,13 +208,34 @@ async function showAnime(slug_id, opts = {}) {
const controls = el("div", "controls");
const langBtn = el("button", null, langLabel());
const setLang = l => { lang = l; langBtn.textContent = langLabel(); };
langBtn.onclick = () => {
setLang(lang === "eng" ? "jpn" : "eng");
lang = lang === "eng" ? "jpn" : "eng";
langBtn.textContent = langLabel();
const cur = main.querySelector(".ep.active");
if (cur) play(cur.dataset.ep, cur.dataset.no);
};
controls.append(langBtn, el("span", "muted", " — click an episode to play"));
const spd = el("select", "spd");
for (const r of [0.5, 0.75, 1, 1.25, 1.5, 2]) spd.append(new Option(r + "×", r));
spd.value = localStorage.getItem("aniweb_spd") || "1";
spd.title = "playback speed";
spd.onchange = () => {
localStorage.setItem("aniweb_spd", spd.value);
const v = document.querySelector("#player video");
if (v) v.playbackRate = +spd.value;
};
const prevB = el("button", "tiny", "◀ prev");
const nextB = el("button", "tiny", "next ▶");
prevB.onclick = () => stepEp(-1);
nextB.onclick = () => stepEp(1);
const fillB = el("button", "tiny", "hide fillers");
fillB.onclick = () => {
hideFiller = !hideFiller;
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
fillB.classList.toggle("on", hideFiller);
renderEps();
};
controls.append(langBtn, spd, prevB, nextB, fillB,
el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
@@ -203,27 +255,14 @@ async function showAnime(slug_id, opts = {}) {
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;
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); };
eps.append(b);
}
if (opts.ep) {
const cur = [...eps.children].find(b => +b.dataset.ep === opts.ep);
if (cur) { markActive(cur); play(opts.ep, cur.dataset.no); }
} else {
// default: resume last watched ep of this season, else start at ep 1
const h = history.find(x => x.slug_id === slug_id && x.season_id === seasonId);
const auto = h ? h.ep_id : +eps.querySelector(".ep")?.dataset.ep;
if (auto) {
const cur = [...eps.children].find(b => +b.dataset.ep === auto);
if (cur) { markActive(cur); play(auto, cur.dataset.no); }
}
// default: resume last watched ep of this season, else start at ep 1
epsList = await api(`/api/anime/${seasonId}/episodes`);
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;
if (start) {
const cur = [...eps.children].find(b => +b.dataset.ep === start);
if (cur) { markActive(cur); play(start, cur.dataset.no); }
}
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
@@ -275,6 +314,33 @@ function markActive(b) {
b.classList.add("active");
}
let epsList = [], hideFiller = false;
function renderEps() {
const eps = document.getElementById("eps");
if (!eps) return;
eps.innerHTML = "";
for (const e of epsList) {
if (hideFiller && e.filler) continue;
const b = el("button", "ep" + (e.filler ? " filler" : ""));
b.textContent = "E" + e.number;
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); };
eps.append(b);
}
}
function stepEp(d) {
const vis = epsList.filter(e => !(hideFiller && e.filler));
if (!vis.length || !currentEp) return;
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const n = vis[i + d];
if (!n) return;
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === n.id);
if (b) { markActive(b); play(n.id, n.number); }
}
function videoEl() {
const v = document.createElement("video");
v.controls = true; v.autoplay = true;
@@ -307,12 +373,54 @@ function playerFail(msg) {
playerOverlay("Playback failed: " + msg, () => play(ep_id, ep_no));
}
// ---------- auto-next on episode end (5s countdown) ----------
let nextTimer = null;
function onEnded() {
if (!currentEp || playerBroken) return;
const vis = epsList.filter(e => !(hideFiller && e.filler));
const i = vis.findIndex(e => String(e.id) === String(currentEp.ep_id));
const nxt = vis[i + 1];
if (!nxt) { playerHideOverlay(); toast("No more episodes"); return; }
nextCountdown(nxt);
}
function nextCountdown(nxt) {
const p = document.getElementById("player");
let o = p.querySelector(".pl-overlay");
if (!o) { o = el("div", "pl-overlay"); p.append(o); }
let n = 5;
const done = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
if (b) markActive(b);
play(nxt.id, nxt.number);
};
const cancel = () => {
if (nextTimer) { clearTimeout(nextTimer); nextTimer = null; }
playerHideOverlay();
};
const draw = () => {
o.replaceChildren(el("span", null, `Next: E${nxt.number} in ${n}\u2026`));
const s = el("button", "btn", "Skip");
s.onclick = done;
const x = el("button", "cnt-x", "\u2715");
x.title = "cancel auto-next";
x.onclick = cancel;
o.append(s, x);
if (n <= 0) { done(); return; }
n--;
nextTimer = setTimeout(draw, 1000);
};
draw();
}
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();
v.playbackRate = +(localStorage.getItem("aniweb_spd") || 1);
document.getElementById("player").replaceChildren(v);
playerOverlay("Loading\u2026");
if (!window.Hls || !Hls.isSupported()) throw new Error("HLS not supported in this browser");
@@ -338,6 +446,7 @@ async function play(ep_id, ep_no) {
v.addEventListener("waiting", () => {
if (currentEp && !playerBroken && !v.ended) playerOverlay("Buffering\u2026");
});
v.addEventListener("ended", onEnded);
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,
@@ -378,6 +487,7 @@ function showPlayOverlay(v) {
try {
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
initRecent();
const lo = document.getElementById("logout");
lo.onclick = async () => {
await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1";
+16 -1
View File
@@ -65,13 +65,28 @@ h2 { margin: 0.4rem 0; }
.player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; }
.play-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
font-size: 1.1rem; padding: 0.8rem 1.4rem; background: rgba(0, 0, 0, 0.75); }
.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; }
.controls { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0; flex-wrap: wrap; }
button { background: var(--card); color: var(--fg); border: 1px solid var(--bd);
border-radius: 8px; padding: 0.4rem 0.7rem; cursor: pointer; }
button:hover { border-color: var(--acc); }
.tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0; }
.tab { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* recent searches dropdown + player extras */
header form { position: relative; }
.recent { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: var(--card); border: 1px solid var(--bd); border-radius: 8px;
margin-top: .3rem; display: none; max-height: 240px; overflow: auto; }
.recent.open { display: block; }
.recent button { display: block; width: 100%; text-align: left; padding: .5rem .8rem;
background: none; border: 0; color: var(--fg); cursor: pointer; font-size: .9rem; }
.recent button:hover { background: rgba(255, 255, 255, 0.06); }
.spd { padding: .2rem .3rem; background: var(--card); color: var(--fg);
border: 1px solid var(--bd); border-radius: 6px; font-size: .8rem; }
.cnt-x { background: none; border: 0; color: var(--muted); font-size: 1.1rem;
cursor: pointer; padding: .2rem .5rem; }
button.tiny.on { border-color: var(--acc); color: var(--acc); }
.tab.active { border-color: var(--acc); color: var(--acc); }
/* loading + feedback */
+2
View File
@@ -76,6 +76,8 @@ def main():
asyncio.run(test_browser.test_video_plays_behind_auth())
asyncio.run(test_browser.test_history_page_and_delete())
asyncio.run(test_browser.test_stream_failure_retry())
asyncio.run(test_browser.test_recent_searches())
asyncio.run(test_browser.test_auto_next_countdown())
print("ALL TESTS PASSED")
finally:
+15
View File
@@ -138,6 +138,21 @@ def test_history_all_and_delete(base=BASE):
jar=jar, base=base)["removed"] == 0
def test_recent_searches(base=BASE):
a = login("alice", "alicepw12345", base)
assert req("POST", "/api/searches", {"q": "naruto"}, jar=a, base=base) == 200
assert req("POST", "/api/searches", {"q": "frieren"}, jar=a, base=base) == 200
# case-insensitive dedupe, newest first
assert req("POST", "/api/searches", {"q": "NARUTO"}, jar=a, base=base) == 200
assert _json("GET", "/api/searches", jar=a, base=base) == ["NARUTO", "frieren"]
# per-user isolation (browser tests write admin's searches, but only after
# the API phase, so admin is clean here)
b = login("admin", "adminpw12345", base)
assert _json("GET", "/api/searches", jar=b, base=base) == []
# blank q rejected
assert req("POST", "/api/searches", {"q": " "}, jar=a, base=base) == 422
def test_self_delete(base=BASE):
jar = login("dave", "davepw12345", base)
assert req("POST", "/account/delete", jar=jar, base=base) == 200
+68
View File
@@ -159,6 +159,72 @@ async def test_stream_failure_retry():
print("ok test_stream_failure_retry")
async def test_recent_searches():
# UI: focus the (empty) search box -> dropdown of saved searches;
# typing filters; clicking one runs the search.
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(),
headers={"Content-Type": "application/json"}, method="POST"))
op.open(urllib.request.Request(BASE + "/api/searches",
data=json.dumps({"q": "chainsaw"}).encode(),
headers={"Content-Type": "application/json"}, method="POST"))
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, "alice", "alicepw12345")
await page.focus("#q")
await page.wait_for_selector(".recent.open", timeout=10000)
assert "chainsaw" in await page.inner_text(".recent")
# typing narrows the list to the matching entry
await page.fill("#q", "cha")
await page.wait_for_function(
"document.querySelectorAll('.recent button').length === 1",
timeout=5000, polling=POLL)
await page.click(".recent button", force=True)
await page.wait_for_selector("a.card", timeout=30000)
assert "q=chainsaw" in page.url, page.url
assert not errors, errors
await browser.close()
print("ok test_recent_searches")
async def test_auto_next_countdown():
# playing a full episode to its natural end is too slow; simulate the
# `ended` event and verify the countdown overlay + cancel path.
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")
# straight to the 12-ep series (the first search card is the 1-ep
# movie — no next episode there)
await page.goto(BASE + "/?anime=chainsaw-man-922")
await page.wait_for_selector("video", timeout=30000)
await page.wait_for_function(
"document.querySelector('video').readyState >= 4",
timeout=120000, polling=250) # cold CDN/upstream starts can be slow
await page.evaluate(
"document.querySelector('video').dispatchEvent(new Event('ended'))")
await page.wait_for_selector(".pl-overlay .btn", timeout=10000)
assert "Next: E2" in await page.inner_text(".pl-overlay")
# cancel (x): overlay gone, still on the same episode
await page.click(".pl-overlay .cnt-x", force=True)
await page.wait_for_function(
"!document.querySelector('.pl-overlay')", timeout=5000, polling=POLL)
assert "E1" in await page.inner_text(".ep.active")
assert not errors, errors
await browser.close()
print("ok test_auto_next_countdown")
if __name__ == "__main__":
if len(sys.argv) > 1:
BASE = sys.argv[1]
@@ -166,3 +232,5 @@ if __name__ == "__main__":
asyncio.run(test_video_plays_behind_auth())
asyncio.run(test_history_page_and_delete())
asyncio.run(test_stream_failure_retry())
asyncio.run(test_recent_searches())
asyncio.run(test_auto_next_countdown())