UX: remember sub/dub + filler toggle, hero time-left, next-ep CTA,
history filter chips, per-ep progress bars, stats in user menu - sub/dub choice persists per anime (localStorage aniweb_lang_<slug>), so search/reload no longer drops dub watchers back to sub - 'hide fillers' toggle persists (aniweb_ff) - continue-watching hero shows '~Xm left' when position+duration known - detail page: prominent 'next episode' button when the resumed ep is finished (auto-removed on play) - ?history: all / watching / completed / on-hold filter chips (client-side) - ep list: thin progress bar on partially-watched episodes - stats page link moved into the user menu (always reachable) - search results show a count line
This commit is contained in:
1 parent
1be3f4f307
commit
1de58629a0
3 files changed
+52
-11
No files matched your search
+46
-9
@@ -102,7 +102,9 @@ function showHome() {
|
|||||||
const body = el("div", "hero-body");
|
const body = el("div", "hero-body");
|
||||||
body.append(el("div", "hero-kicker", "continue watching"),
|
body.append(el("div", "hero-kicker", "continue watching"),
|
||||||
el("h2", null, hero.title),
|
el("h2", null, hero.title),
|
||||||
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in · ${timeago(hero.t)}`));
|
el("div", "muted", `Ep ${hero.ep} · ${hero.lang === "eng" ? "dub" : "sub"} · ${fmtPos(hero.pos)} in` +
|
||||||
|
(hero.dur > 0 && hero.pos < hero.dur * 0.95 ? ` · ${fmtMin((hero.dur - hero.pos) / 60)} left` : "") +
|
||||||
|
` · ${timeago(hero.t)}`));
|
||||||
const p = bar(hero);
|
const p = bar(hero);
|
||||||
if (p) body.append(p);
|
if (p) body.append(p);
|
||||||
const go = el("button", "btn resume", "▶ Resume");
|
const go = el("button", "btn resume", "▶ Resume");
|
||||||
@@ -157,6 +159,7 @@ async function showSearch(q) {
|
|||||||
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
const results = await api(`/api/search?q=${encodeURIComponent(q)}`);
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
|
if (!results.length) { main.append(el("p", "muted", "No results.")); return; }
|
||||||
|
main.append(el("p", "muted", `${results.length} result${results.length > 1 ? "s" : ""}`));
|
||||||
const grid = el("div", "grid");
|
const grid = el("div", "grid");
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
const card = el("a", "card");
|
const card = el("a", "card");
|
||||||
@@ -198,7 +201,7 @@ function initRecent() {
|
|||||||
async function showAnime(slug_id, opts = {}) {
|
async function showAnime(slug_id, opts = {}) {
|
||||||
skel();
|
skel();
|
||||||
detail = await api(`/api/anime/${slug_id}`);
|
detail = await api(`/api/anime/${slug_id}`);
|
||||||
lang = opts.lang || "jpn";
|
lang = opts.lang || localStorage.getItem("aniweb_lang_" + slug_id) || "jpn";
|
||||||
seasonId = opts.season || detail.id;
|
seasonId = opts.season || detail.id;
|
||||||
detail.slug_id = slug_id;
|
detail.slug_id = slug_id;
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
@@ -240,9 +243,10 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
const nextB = el("button", "tiny", "next ▶");
|
const nextB = el("button", "tiny", "next ▶");
|
||||||
prevB.onclick = () => stepEp(-1);
|
prevB.onclick = () => stepEp(-1);
|
||||||
nextB.onclick = () => stepEp(1);
|
nextB.onclick = () => stepEp(1);
|
||||||
const fillB = el("button", "tiny", "hide fillers");
|
const fillB = el("button", "tiny" + (hideFiller ? " on" : ""), "hide fillers");
|
||||||
fillB.onclick = () => {
|
fillB.onclick = () => {
|
||||||
hideFiller = !hideFiller;
|
hideFiller = !hideFiller;
|
||||||
|
localStorage.setItem("aniweb_ff", hideFiller ? "1" : "0");
|
||||||
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
|
fillB.textContent = hideFiller ? "show fillers" : "hide fillers";
|
||||||
fillB.classList.toggle("on", hideFiller);
|
fillB.classList.toggle("on", hideFiller);
|
||||||
renderEps();
|
renderEps();
|
||||||
@@ -273,12 +277,14 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
// good enough for sequential viewing)
|
// good enough for sequential viewing)
|
||||||
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
epsList = await api(`/api/anime/${seasonId}/episodes`);
|
||||||
watchDone = new Set();
|
watchDone = new Set();
|
||||||
|
epProg = new Map();
|
||||||
let started = false;
|
let started = false;
|
||||||
try {
|
try {
|
||||||
for (const x of await api("/api/history?all=1")) {
|
for (const x of await api("/api/history?all=1")) {
|
||||||
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
if (x.slug_id !== slug_id || x.season_id !== seasonId) continue;
|
||||||
started = true;
|
started = true;
|
||||||
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
|
if (x.dur > 0 && x.pos >= x.dur * 0.95) watchDone.add(String(x.ep_id));
|
||||||
|
else if (x.dur > 0) epProg.set(String(x.ep_id), Math.min(100, Math.round(100 * x.pos / x.dur)));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
|
watchNext = started ? (epsList.find(e => !watchDone.has(String(e.id))) || {}).id : null;
|
||||||
@@ -289,26 +295,48 @@ async function showAnime(slug_id, opts = {}) {
|
|||||||
const cur = [...eps.children].find(b => +b.dataset.ep === start);
|
const cur = [...eps.children].find(b => +b.dataset.ep === start);
|
||||||
if (cur) { markActive(cur); play(start, cur.dataset.no); }
|
if (cur) { markActive(cur); play(start, cur.dataset.no); }
|
||||||
}
|
}
|
||||||
|
// explicit CTA when the resumed episode is already finished
|
||||||
|
if (start && watchDone.has(String(start)) && watchNext != null && !nextCta) {
|
||||||
|
nextCta = el("button", "btn", "\u25b6 next episode");
|
||||||
|
nextCta.onclick = () => stepEp(1);
|
||||||
|
controls.append(nextCta);
|
||||||
|
}
|
||||||
|
|
||||||
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
if (detail.synopsis) main.append(el("p", "synopsis bottom", detail.synopsis));
|
||||||
}
|
}
|
||||||
|
|
||||||
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
|
||||||
|
|
||||||
// ---------- history page (full list + delete) ----------
|
// ---------- history page (full list + delete + client-side filter) ----------
|
||||||
|
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() {
|
async function showHistory() {
|
||||||
skel(10);
|
skel(10);
|
||||||
const items = await api("/api/history?all=1");
|
const items = await api("/api/history?all=1");
|
||||||
main.innerHTML = "";
|
main.innerHTML = "";
|
||||||
main.append(el("h2", null, "History"));
|
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.")); return; }
|
||||||
|
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
|
||||||
|
let active = "all";
|
||||||
|
const chips = el("div", "chips");
|
||||||
const grid = el("div", "grid");
|
const grid = el("div", "grid");
|
||||||
for (const x of items) grid.append(histCard(x));
|
const draw = () => {
|
||||||
|
grid.replaceChildren(...items.filter(x => active === "all" || histClass(x) === active).map(histCard));
|
||||||
|
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
|
||||||
|
};
|
||||||
|
for (const f of Object.keys(labels)) {
|
||||||
|
const b = el("button", "chip", labels[f]);
|
||||||
|
b.dataset.f = f;
|
||||||
|
b.onclick = () => { active = f; draw(); };
|
||||||
|
chips.append(b);
|
||||||
|
}
|
||||||
|
main.append(chips);
|
||||||
|
draw();
|
||||||
main.append(grid);
|
main.append(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function histCard(x) {
|
function histCard(x) {
|
||||||
const hold = x.t && Date.now() / 1000 - x.t > 7 * 86400;
|
const hold = histClass(x) === "hold";
|
||||||
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
const c = el("div", "card hist" + (hold ? " hold" : ""));
|
||||||
c.onclick = () => location.href = histHref(x);
|
c.onclick = () => location.href = histHref(x);
|
||||||
if (x.poster) {
|
if (x.poster) {
|
||||||
@@ -429,8 +457,8 @@ function markActive(b) {
|
|||||||
b.classList.add("active");
|
b.classList.add("active");
|
||||||
}
|
}
|
||||||
|
|
||||||
let epsList = [], hideFiller = false;
|
let epsList = [], hideFiller = localStorage.getItem("aniweb_ff") === "1";
|
||||||
let watchDone = new Set(), watchNext = null;
|
let watchDone = new Set(), watchNext = null, epProg = new Map();
|
||||||
|
|
||||||
function renderEps() {
|
function renderEps() {
|
||||||
const eps = document.getElementById("eps");
|
const eps = document.getElementById("eps");
|
||||||
@@ -442,6 +470,12 @@ function renderEps() {
|
|||||||
const done = watchDone.has(String(e.id));
|
const done = watchDone.has(String(e.id));
|
||||||
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
|
b.textContent = done ? "E" + e.number + " \u2713" : "E" + e.number;
|
||||||
if (done) b.classList.add("done");
|
if (done) b.classList.add("done");
|
||||||
|
const pct = epProg.get(String(e.id));
|
||||||
|
if (!done && pct != null && pct > 5) {
|
||||||
|
const pb = el("div", "bar");
|
||||||
|
pb.append(Object.assign(el("div", "bar-fill"), {style: `width:${pct}%`}));
|
||||||
|
b.append(pb);
|
||||||
|
}
|
||||||
if (watchNext != null && String(watchNext) === String(e.id)) b.classList.add("nextup");
|
if (watchNext != null && String(watchNext) === String(e.id)) b.classList.add("nextup");
|
||||||
if (e.filler) b.append(el("span", "badge", " filler"));
|
if (e.filler) b.append(el("span", "badge", " filler"));
|
||||||
b.dataset.ep = e.id; b.dataset.no = e.number;
|
b.dataset.ep = e.id; b.dataset.no = e.number;
|
||||||
@@ -468,7 +502,7 @@ function videoEl() {
|
|||||||
|
|
||||||
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
function stopHls() { if (hls) { hls.destroy(); hls = null; } }
|
||||||
|
|
||||||
let currentEp = null, lastSavedPos = 0, playerBroken = false;
|
let currentEp = null, lastSavedPos = 0, playerBroken = false, nextCta = null;
|
||||||
|
|
||||||
function playerOverlay(text, onAction, actionLabel = "Retry") {
|
function playerOverlay(text, onAction, actionLabel = "Retry") {
|
||||||
const p = document.getElementById("player");
|
const p = document.getElementById("player");
|
||||||
@@ -536,6 +570,7 @@ function nextCountdown(nxt) {
|
|||||||
|
|
||||||
async function play(ep_id, ep_no) {
|
async function play(ep_id, ep_no) {
|
||||||
playerBroken = false;
|
playerBroken = false;
|
||||||
|
localStorage.setItem("aniweb_lang_" + detail.slug_id, lang);
|
||||||
try {
|
try {
|
||||||
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
const s = await api(`/api/episode/${ep_id}/stream?lang=${lang}`);
|
||||||
stopHls();
|
stopHls();
|
||||||
@@ -555,6 +590,7 @@ async function play(ep_id, ep_no) {
|
|||||||
hls.attachMedia(v);
|
hls.attachMedia(v);
|
||||||
currentEp = {ep_id, ep_no, v};
|
currentEp = {ep_id, ep_no, v};
|
||||||
lastSavedPos = 0;
|
lastSavedPos = 0;
|
||||||
|
if (nextCta) { nextCta.remove(); nextCta = null; }
|
||||||
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
|
const saved = history.find(x => x.slug_id === detail.slug_id && x.ep_id === ep_id);
|
||||||
if (saved?.pos > 10) {
|
if (saved?.pos > 10) {
|
||||||
v.addEventListener("loadedmetadata", () => {
|
v.addEventListener("loadedmetadata", () => {
|
||||||
@@ -613,6 +649,7 @@ window.addEventListener("pagehide", savePos);
|
|||||||
// ---------- boot ----------
|
// ---------- boot ----------
|
||||||
(async () => {
|
(async () => {
|
||||||
document.querySelector(".logo").href = BASE;
|
document.querySelector(".logo").href = BASE;
|
||||||
|
document.getElementById("stats").href = BASE + "?stats";
|
||||||
try {
|
try {
|
||||||
const me = await api("/api/me");
|
const me = await api("/api/me");
|
||||||
document.getElementById("me").textContent = "hi, " + me.user;
|
document.getElementById("me").textContent = "hi, " + me.user;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<div id="menubox" style="display:none">
|
<div id="menubox" style="display:none">
|
||||||
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
|
<button id="menu" class="tiny" aria-label="menu" aria-haspopup="true">☰</button>
|
||||||
<div id="menu-items">
|
<div id="menu-items">
|
||||||
|
<a id="stats" class="tiny" href="?stats">stats</a>
|
||||||
<button id="logout" class="tiny">logout</button>
|
<button id="logout" class="tiny">logout</button>
|
||||||
<button id="delacct" class="tiny danger">delete account</button>
|
<button id="delacct" class="tiny danger">delete account</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-2
@@ -6,15 +6,16 @@ header { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 1.2rem;
|
|||||||
border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 5; }
|
border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 5; }
|
||||||
.logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); }
|
.logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); }
|
||||||
header form { flex: 1; max-width: 420px; }
|
header form { flex: 1; max-width: 420px; }
|
||||||
button.tiny { padding: .2rem .55rem; font-size: .8rem; border-radius: 6px;
|
button.tiny, a.tiny { padding: .2rem .55rem; font-size: .8rem; border-radius: 6px;
|
||||||
border: 1px solid var(--bd); background: var(--card); color: var(--fg); cursor: pointer; }
|
border: 1px solid var(--bd); background: var(--card); color: var(--fg); cursor: pointer; }
|
||||||
|
a.tiny:hover { border-color: var(--acc); }
|
||||||
button.tiny.danger { border-color: #7f1d1d; color: #f87171; }
|
button.tiny.danger { border-color: #7f1d1d; color: #f87171; }
|
||||||
#menubox { position: relative; }
|
#menubox { position: relative; }
|
||||||
#menu-items { display: none; position: absolute; right: 0; top: calc(100% + 4px);
|
#menu-items { display: none; position: absolute; right: 0; top: calc(100% + 4px);
|
||||||
background: var(--card); border: 1px solid var(--bd); border-radius: 8px;
|
background: var(--card); border: 1px solid var(--bd); border-radius: 8px;
|
||||||
padding: .35rem; min-width: 160px; z-index: 2; }
|
padding: .35rem; min-width: 160px; z-index: 2; }
|
||||||
#menubox.open #menu-items { display: block; }
|
#menubox.open #menu-items { display: block; }
|
||||||
#menu-items button { display: block; width: 100%; text-align: left; margin-top: .15rem; }
|
#menu-items button, #menu-items a { display: block; width: 100%; text-align: left; margin-top: .15rem; }
|
||||||
header #me { margin-left: auto; }
|
header #me { margin-left: auto; }
|
||||||
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd);
|
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd);
|
||||||
background: var(--card); color: var(--fg); }
|
background: var(--card); color: var(--fg); }
|
||||||
@@ -71,6 +72,8 @@ h2 { margin: 0.4rem 0; }
|
|||||||
.sa-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.sa-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.stat-anime .bar { grid-column: 2 / 4; margin-top: 0; }
|
.stat-anime .bar { grid-column: 2 / 4; margin-top: 0; }
|
||||||
.card.hold { opacity: .55; }
|
.card.hold { opacity: .55; }
|
||||||
|
.chips { display: flex; gap: .4rem; margin: .5rem 0 .8rem; }
|
||||||
|
.ep .bar { height: 3px; margin-top: .3rem; }
|
||||||
.detail-prog { margin-top: .1rem; font-size: .85rem; }
|
.detail-prog { margin-top: .1rem; font-size: .85rem; }
|
||||||
.mini-info { display: flex; flex-direction: column; padding: .5rem; }
|
.mini-info { display: flex; flex-direction: column; padding: .5rem; }
|
||||||
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
|
.mini-meta { font-size: .75rem; color: var(--muted); margin-top: .1rem; }
|
||||||
|
|||||||
Reference in new issue
Block a user