features: auto-next w/ 5s countdown, prev/next, playback speed, hide fillers, recent searches
This commit is contained in:
1 parent
9fdf3944fc
commit
98fb4e5461
6 files changed
+269
-25
No files matched your search
@@ -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
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user