Intro skip: MAL timings via keyless Jikan + player button

- upstream: _intros(mal_id) -> per-episode {start,length} (seconds),
  30d TTL cache; Jikan is down or show uncovered -> [] (no button)
- app: GET /api/intros/{mal_id}, never errors into the player
- player: 'skip intro' pill appears while currentTime is inside the
  intro window (+8s grace); click seeks past; reappears if rewound.
  Ep-1 intros are often missing in MAL data, so falls back to the
  show's first known timing (usually identical across eps)
- test: _parse_intros unit test (Jikan itself is 504ing right now,
  live path verified by hand: endpoint returns [] cleanly)
This commit is contained in:
jozamudi committed 2026-08-30 21:01:24 +00:00
1 parent c2461f8fba
commit 0613cf7e27
5 files changed
+77 -1

No files matched your search

+29
View File
@@ -840,6 +840,7 @@ async function play(ep_id, ep_no) {
blocked();
});
watchPos(v);
setupIntro(v, ep_no);
postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId,
ep_id, lang, ep: ep_no, pos: saved?.pos || 0});
} catch (e) {
@@ -848,6 +849,34 @@ async function play(ep_id, ep_no) {
document.title = `${detail.name} E${ep_no} — ani-web`;
}
// ---------- intro skip (MAL timings via keyless Jikan, cached server-side) ----------
const introCache = new Map(); // mal_id -> Promise<timings>
function setupIntro(v, epNo) {
const mid = detail && detail.mal_id;
if (!mid) return;
let p = introCache.get(mid);
if (!p) { p = api(`/api/intros/${mid}`).then(t => t || []).catch(() => []); introCache.set(mid, p); }
p.then(list => {
if (!currentEp || currentEp.v !== v) return; // superseded by a newer play()
// ponytail: ep 1 intros are often missing in MAL data; fall back to the
// show's first known timing (intros are usually identical across eps)
let t = list[epNo - 1];
if (!t) for (const x of list) if (x) { t = x; break; }
if (!t || !(t.length > 0)) return;
const s = t.start, e = t.start + t.length;
let skipped = false;
const b = el("button", "skip-intro", "skip intro \u23ed");
b.hidden = true;
b.onclick = () => { v.currentTime = e + 0.5; skipped = true; b.hidden = true; };
document.getElementById("player").append(b);
v.addEventListener("timeupdate", () => {
if (skipped && v.currentTime < s) skipped = false; // user rewound back to the window
if (skipped) return;
b.hidden = !(v.currentTime >= s && v.currentTime <= e + 8);
});
});
}
// extra fields to persist with every history entry (progress + poster)
const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}),
poster: detail ? (detail.poster || null) : null});
+3
View File
@@ -110,6 +110,9 @@ h2 { margin: 0.4rem 0; }
.player { position: relative; }
.player video { width: 100%; max-height: 70vh; background: #000; border-radius: 10px; aspect-ratio: 16/9; }
.skip-intro { position: absolute; right: .8rem; bottom: 3rem; z-index: 2;
padding: .4rem 1rem; border-radius: 99px; border: 1px solid var(--acc);
background: rgba(14, 15, 19, .85); color: var(--fg); font-size: .9rem; cursor: pointer; }
.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; }