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
+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 */
|
||||
|
||||
Reference in new issue
Block a user