Search for an anime to get started.
diff --git a/static/app.js b/static/app.js
index 9c2b47e..5c13bda 100644
--- a/static/app.js
+++ b/static/app.js
@@ -32,6 +32,20 @@ function el(tag, cls, text) {
function fail(err) {
main.innerHTML = "";
main.append(el("p", "error", "Error: " + err.message));
+ const row = el("div", "chips");
+ const home = el("button", "tiny", "home");
+ home.onclick = () => location.href = BASE;
+ const retry = el("button", "tiny", "retry");
+ retry.onclick = () => location.reload();
+ row.append(home, retry);
+ main.append(row);
+ focusMain();
+}
+
+function focusMain() {
+ const t = main.querySelector("h2") || main;
+ t.tabIndex = -1;
+ t.focus({preventScroll: true});
}
function skel(n = 8) {
@@ -41,10 +55,16 @@ function skel(n = 8) {
main.append(g);
}
-function toast(msg) {
- const t = el("div", "toast", msg);
+function toast(msg, action) {
+ const t = el("div", "toast");
+ t.append(el("span", null, msg));
+ if (action) {
+ const b = el("button", "tiny", action.label);
+ b.onclick = () => { t.remove(); action.fn(); };
+ t.append(b);
+ }
document.getElementById("toasts").append(t);
- setTimeout(() => t.remove(), 2600);
+ setTimeout(() => t.remove(), action ? 6000 : 2600);
}
function showStreamError(msg) {
@@ -123,20 +143,29 @@ const showSec = (title, items, extra) => {
main.append(head, grid);
};
const link = (text, href) => Object.assign(el("a", null, text), {href: BASE + href});
+// recent-search chips under a "no results" / empty placeholder (shared by home + search)
+function recentChips(placeholder) {
+ const p = el("p", "muted", "loading…");
+ placeholder.after(p);
+ api("/api/searches").then(rs => {
+ if (!rs.length) { p.remove(); return; }
+ const d = el("div", null);
+ d.append(el("div", "muted", "recent searches"));
+ const chips = el("div", "chips");
+ for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q)));
+ d.append(chips);
+ p.replaceWith(d);
+ }).catch(() => p.remove());
+}
function showHome() {
+ document.title = "ani-web";
main.innerHTML = "";
if (!history.length) {
- main.append(el("p", "muted empty", "Search for an anime to get started."));
- api("/api/searches").then(rs => {
- if (!rs.length || !main.querySelector("p.empty")) return;
- const d = el("div", null);
- d.append(el("div", "muted", "recent searches"));
- const chips = el("div", "chips");
- for (const q of rs) chips.append(link(q, "?q=" + encodeURIComponent(q)));
- d.append(chips);
- main.append(d);
- }).catch(() => {});
+ const p = el("p", "muted empty", "Search for an anime to get started.");
+ main.append(p);
+ recentChips(p);
+ focusMain();
return;
}
const hero = history[0];
@@ -179,6 +208,7 @@ function showHome() {
link("history \u2192", "?history"));
const sl = el("div", "muted", "loading\u2026");
main.append(sh, sl);
+ focusMain();
api("/api/stats?days=30").then(d => {
sl.textContent = `${fmtMin(d.watch_minutes)} watched \u00b7 ${d.episodes.started} eps` +
` (${d.episodes.completed} finished) \u00b7 ${d.days_active} active days \u00b7` +
@@ -194,10 +224,17 @@ function saveSearch(q) {
async function showSearch(q) {
saveSearch(q);
+ document.title = q + " \u2014 ani-web";
skel(8);
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
main.innerHTML = "";
- if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
+ if (!results.length) {
+ const p = el("p", "muted", "No results.");
+ main.append(p);
+ recentChips(p);
+ focusMain();
+ return;
+ }
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
const grid = el("div", "grid");
for (const r of results) {
@@ -209,6 +246,7 @@ async function showSearch(q) {
grid.append(card);
}
main.append(grid);
+ focusMain();
}
// ---------- recent searches dropdown ----------
@@ -243,6 +281,7 @@ async function showAnime(slug_id, opts = {}) {
lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn";
seasonId = opts.season || detail.id;
detail.slug_id = slug_id;
+ document.title = detail.name + " \u2014 ani-web";
main.innerHTML = "";
const head = el("section", "detail-head");
@@ -290,8 +329,36 @@ async function showAnime(slug_id, opts = {}) {
fillB.classList.toggle("on", hideFiller);
renderEps();
};
- controls.append(langBtn, spd, prevB, nextB, fillB,
- el("span", "muted", " — click an episode to play"));
+ const jump = el("input", "spd");
+ jump.type = "number"; jump.min = "1"; jump.inputMode = "numeric";
+ jump.placeholder = "E#"; jump.title = "jump to episode";
+ jump.style.width = "4.5rem";
+ jump.onchange = () => {
+ const t = epsList.find(e => e.number == +jump.value);
+ if (!t) { jump.value = ""; return; }
+ const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === t.id);
+ if (b) markActive(b);
+ play(t.id, t.number);
+ jump.value = "";
+ };
+ const anB = el("button", "tiny" + (autoNext ? " on" : ""), "auto-next");
+ anB.title = "auto-play the next episode when one ends";
+ anB.onclick = () => {
+ autoNext = !autoNext;
+ localStorage.setItem("aniweb_auto", autoNext ? "1" : "0");
+ anB.classList.toggle("on", autoNext);
+ };
+ const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB];
+ if (document.pictureInPictureEnabled) {
+ const pipB = el("button", "tiny", "\u29c9 PiP");
+ pipB.title = "picture-in-picture";
+ pipB.onclick = () => {
+ const v = document.querySelector("#player video");
+ if (v && v.requestPictureInPicture) v.requestPictureInPicture().catch(() => {});
+ };
+ ctl.push(pipB);
+ }
+ controls.append(...ctl, el("span", "muted", " — click an episode to play"));
main.append(controls);
const tabs = el("div", "tabs");
@@ -342,6 +409,7 @@ async function showAnime(slug_id, opts = {}) {
}
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
+ focusMain();
}
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
@@ -350,12 +418,14 @@ function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
async function showHistory() {
+ document.title = "History \u2014 ani-web";
skel(10);
const items = await api("/api/history?all=1");
main.innerHTML = "";
main.append(el("h2", null, "History"));
- if (!items.length) { main.append(el("p", "muted", "Nothing here.")); return; }
+ if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
main.append(...histBoard(items));
+ focusMain();
}
function histCard(x) {
@@ -366,8 +436,10 @@ function histCard(x) {
const i = el("img"); i.src = x.poster; i.alt = "";
c.append(i);
}
+ const nm = el("a", "card-name", x.title);
+ nm.href = histHref(x);
const info = el("div", "mini-info");
- info.append(el("div", "card-name", x.title),
+ info.append(nm,
el("div", "mini-meta", `E${x.ep} \u00b7 ${x.lang === "eng" ? "dub" : "sub"} \u00b7 ${timeago(x.t)}` +
(hold ? " \u00b7 on hold" : "")));
const pb = bar(x, "small");
@@ -381,8 +453,11 @@ function histCard(x) {
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id),
{method: "DELETE", headers: {"X-Aniweb": "1"}});
- toast("Removed from history");
- await showHistory();
+ c.remove();
+ const i = history.indexOf(x);
+ if (i >= 0) history.splice(i, 1);
+ // undo = re-POST the same entry (the endpoint upserts on (user, slug, ep))
+ toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
};
c.append(d);
return c;
@@ -549,12 +624,19 @@ function heatSection() {
return sec;
}
-async function showStats() {
+async function renderStats(days) {
+ document.title = "Stats \u2014 ani-web";
skel(5);
- const d = await api("/api/stats?days=30");
+ const d = await api(`/api/stats?days=${days}`);
main.innerHTML = "";
- main.append(el("h2", null, "Stats \u2014 last 30 days"));
- main.append(statsCards(d));
+ main.append(el("h2", null, `Stats \u2014 last ${days === 365 ? "year" : days + " days"}`));
+ const chips = el("div", "chips");
+ for (const dd of [30, 90, 365]) {
+ const b = el("button", "chip" + (dd === days ? " on" : ""), dd === 365 ? "1y" : dd + "d");
+ b.onclick = () => renderStats(dd);
+ chips.append(b);
+ }
+ main.append(chips, statsCards(d));
main.append(heatSection());
const ta = topAnime(d);
if (ta) main.append(ta);
@@ -563,6 +645,11 @@ async function showStats() {
el("h2", null, "Top searches"),
el("div", "muted", d.top_searches.join(" \u00b7 "))));
}
+ focusMain();
+}
+function showStats() {
+ const p = +new URLSearchParams(location.search).get("days") || 0;
+ return renderStats([30, 90, 365].includes(p) ? p : 30);
}
function markActive(b) {
@@ -571,6 +658,7 @@ function markActive(b) {
}
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
+let autoNext = localStorage.getItem("aniweb_auto") !== "0";
let watchDone = new Set(), watchNext = null, epProg = new Map();
function renderEps() {
@@ -652,6 +740,15 @@ function onEnded() {
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; }
+ if (!autoNext) {
+ playerHideOverlay();
+ toast("Next: E" + nxt.number, {label: "\u25b6 Play", fn: () => {
+ const b = [...document.querySelectorAll(".ep")].find(x => +x.dataset.ep === nxt.id);
+ if (b) markActive(b);
+ play(nxt.id, nxt.number);
+ }});
+ return;
+ }
nextCountdown(nxt);
}
@@ -788,6 +885,10 @@ window.addEventListener("pagehide", savePos);
};
const mb = document.getElementById("menubox");
mb.style.display = "";
+ // #me is hidden on narrow screens: surface the username in the menu there
+ if (matchMedia("(max-width: 640px)").matches) {
+ document.getElementById("menu-items").prepend(el("div", "menu-user", "hi, " + me.user));
+ }
document.getElementById("menu").onclick = e => {
e.stopPropagation(); mb.classList.toggle("open");
};
diff --git a/static/index.html b/static/index.html
index 9dd38e9..3edccf1 100644
--- a/static/index.html
+++ b/static/index.html
@@ -12,7 +12,7 @@
Search for an anime to get started.