Files
MangaCircus/public/reader.js
T
Maashkinoozhe 8dd776b7f4 MangaParty: self-hosted manga/comic/webtoon reader
Zero-dependency Node server + plain HTML/JS frontend.

- Library is a folder tree (Manga/Book/Chapter/pages), indexed in place;
  page roles (cover, double, extra, rotation) are file name tags
- Accounts with per-user progress, reading settings, follows and
  new-chapter notifications (in-app + optional webhook)
- Reader with Flow, Page and Book modes, pointer-anchored page zoom,
  tap zones, auto-hiding controls, chapter picker, page preloading
- Multiple versions per chapter with community votes
- Docker image and compose file
- Demo library generator (tools/make-dummy.js)
- Docs: architecture, library format, import, scraper guide
2026-10-01 09:35:05 +02:00

539 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/*
* Chapter reader.
* - Modes: flow (all pages below each other, same width), page (one page), book (two facing pages; double pages alone).
* - Page zoom ("zoom" below): 1 = the page (in flow: a normal single page) exactly fits the screen.
* It is remembered per mode; flow for webtoons has its own value ("strip", 1 = strip as wide as the screen / its own width).
* - Tap zones: left/right third turns pages following the reading direction (top/bottom third in flow), the middle toggles the bars.
* - All pages are laid out with absolute positions computed from the image sizes the server knows, so zooming and
* restoring positions never depend on images having loaded.
*/
(async function () {
const P = new URLSearchParams(location.search);
const stage = $('#stage'), canvas = $('#canvas'), slider = $('#slider'), ver = $('#ver');
let me, D;
try { [me, D] = await Promise.all([api('api/me'), api('api/chapter?' + qsa({ m: P.get('m'), c: P.get('c'), v: P.get('v') }))]); }
catch (e) { canvas.textContent = ''; canvas.append(h('p', { class: 'loading' }, e.message + ' · ', h('a', { href: './' }, 'Library'))); return; }
const S = me.settings, rtl = D.direction === 'rtl', ttb = D.direction === 'ttb';
const GAP = ttb ? 0 : 8, END_H = 280;
document.title = `${D.chapter.label} · ${D.manga.title}`;
// ---------- pages ----------
const all = D.pages.map((p, i) => {
const t = p.t || [], r = p.r || 0, unknown = !p.w || !p.h;
const w = unknown ? (t.includes('double') ? 2000 : 1000) : p.w, hh = unknown ? 1414 : p.h;
const dw = r % 180 ? hh : w, dh = r % 180 ? w : hh; // size as displayed (after rotation)
return { i, u: p.u, r, w: dw, a: dw / dh, unknown, cover: t.includes('cover'), extra: t.includes('extra'), wide: t.includes('double') || dw > dh };
});
let list = all.filter(p => !(S.skipExtras && p.extra) && !(S.skipCovers && p.cover));
if (!list.length) list = all;
const median = xs => (xs.length ? xs.sort((x, y) => x - y)[xs.length >> 1] : 0);
const refA = median(list.filter(p => !p.wide).map(p => p.a)) || 0.7; // aspect ratio of a normal single page
const refW = median(list.map(p => p.w)) || 800; // natural width of a webtoon strip
const solo = p => p.wide || p.cover; // shown alone in book mode
// ---------- state ----------
const compatible = ttb ? ['flow'] : ['flow', 'page', 'book'];
const intended = S.startMode === 'last' ? S.lastMode : S.startMode;
let mode = S.adapt && !compatible.includes(intended) ? compatible[0] : intended;
if (!['flow', 'page', 'book'].includes(mode)) mode = compatible[0];
let cur = 0, atEnd = false, done = !!(D.progress && D.progress.done), bars = true, barsAt = 0;
let spreads = [], spreadIdx = 0, shown = [], els = [], boxes = [], endEl = null, lastW = 0, lastH = 0;
const shift = new Set(D.progress && D.progress.v === D.version ? D.progress.shift : []);
const zkey = () => (mode === 'flow' && ttb ? 'strip' : mode);
const zoom = () => S.zoom[zkey()] || 1;
const pr = D.progress;
if (pr && !(pr.done && pr.p >= pr.n - 1)) {
const p = pr.v === D.version || !pr.n ? pr.p : Math.round((pr.p / pr.n) * all.length);
const k = list.findIndex(x => x.i >= p);
cur = k < 0 ? list.length - 1 : k;
}
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
function computeSpreads() {
const out = [];
for (let i = 0; i < list.length;) {
if (solo(list[i])) { out.push([i++]); continue; }
let j = i, k = i;
while (j < list.length && !solo(list[j])) j++;
if (shift.has(list[i].i)) out.push([k++]);
for (; k < j; k += 2) out.push(k + 1 < j ? [k, k + 1] : [k]);
i = j;
}
return out;
}
const runStart = k => { while (k > 0 && !solo(list[k - 1])) k--; return k; };
// ---------- rendering ----------
const io = window.IntersectionObserver
? new IntersectionObserver(entries => {
for (const e of entries) if (e.isIntersecting) { setSrc(e.target, e.target._p.u); io.unobserve(e.target); }
}, { root: stage, rootMargin: '150% 50%' })
: null;
// Page/book mode: preloaded images are downloaded *and decoded* ahead of time and then shown as the very same
// elements, so a page turn swaps pictures within one frame instead of flashing an empty box.
const pool = new Map();
function pooled(url, priority) {
let img = pool.get(url);
if (img) pool.delete(url);
else {
img = new Image();
img.decoding = 'async';
img.fetchPriority = priority;
img.src = url;
if (img.decode) img.decode().catch(() => {});
}
pool.set(url, img); // most recently used last
while (pool.size > 12) pool.delete(pool.keys().next().value);
return img;
}
function preloadAround() {
const ahead = mode === 'page' ? [cur + 1, cur + 2, cur + 3, cur - 1]
: [].concat(spreads[spreadIdx + 1] || [], spreads[spreadIdx + 2] || [], spreads[spreadIdx - 1] || []);
for (const k of ahead) if (list[k]) pooled(list[k].u, 'low');
}
// A spinner shows (after a short delay, see CSS) while a page is still loading, a message if it failed.
function watch(el) {
const img = el.firstChild, p = el._p;
const state = () => {
el.classList.toggle('loading', !!img.getAttribute('src') && !img.complete);
el.classList.toggle('failed', img.complete && !!img.getAttribute('src') && !img.naturalWidth);
};
img.onload = () => {
state();
if (p.unknown && img.naturalWidth) {
const w = p.r % 180 ? img.naturalHeight : img.naturalWidth, hh = p.r % 180 ? img.naturalWidth : img.naturalHeight;
Object.assign(p, { unknown: false, w, a: w / hh, wide: p.wide || w > hh });
relayout();
}
};
img.onerror = state;
state();
}
function setSrc(el, url) { el.firstChild.src = url; watch(el); }
function pageEl(p, lazy) {
const img = lazy ? h('img', { decoding: 'async' }) : pooled(p.u, 'high');
img.alt = '';
img.draggable = false;
img.style.cssText = p.r ? `transform: translate(-50%, -50%) rotate(${p.r}deg)` : '';
if (!lazy) img.decoding = 'sync'; // paint together with the page turn (it is decoded already when preloaded)
const el = h('div', { class: 'pg' + (p.r ? ' rot' : '') }, img);
el._p = p;
if (!lazy) watch(el);
else if (io) io.observe(el);
else setSrc(el, p.u);
return el;
}
function endCard() {
const n = D.next;
return h('div', { class: 'end' }, h('div', { class: 'endbox' },
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
: h('div', {}, 'You are up to date.'),
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
}
function build() {
if (io) io.disconnect();
canvas.textContent = '';
canvas.className = mode + (ttb ? ' ttb' : '');
els = []; shown = []; endEl = null;
if (mode === 'flow') {
els = list.map(p => canvas.appendChild(pageEl(p, true)));
endEl = canvas.appendChild(endCard());
} else if (atEnd) {
endEl = canvas.appendChild(endCard());
} else {
shown = mode === 'page' ? [cur] : spreads[spreadIdx];
els = shown.map(k => canvas.appendChild(pageEl(list[k])));
// The pages on screen get the bandwidth first, then the next ones are fetched.
const token = ++buildToken, waiting = els.map(el => el.firstChild).filter(img => !img.complete);
Promise.all(waiting.map(img => new Promise(done => {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}))).then(() => { if (token === buildToken) preloadAround(); });
}
layout();
}
let buildToken = 0;
// Scrollbars appear/disappear with the content size and change the viewport, so repeat until it is stable.
function layout() {
for (let pass = 0; pass < 3; pass++) {
const vw = stage.clientWidth, vh = stage.clientHeight;
layoutFor(vw, vh);
if (stage.clientWidth === vw && stage.clientHeight === vh) break;
}
}
function layoutFor(vw, vh) {
const z = zoom();
let cw = vw, ch = vh;
boxes = [];
if (mode === 'flow') {
// Every page gets the width of a normal single page; wide pages keep that height but never get wider than the screen.
const W = (ttb ? Math.min(vw, refW) : Math.min(vw, vh * refA)) * z;
const ws = list.map(p => (p.wide && !ttb ? Math.min((W * p.a) / refA, Math.max(vw, W)) : W));
cw = Math.max(vw, ...ws);
let y = 0;
ws.forEach((w, k) => { boxes.push({ x: (cw - w) / 2, y, w, h: w / list[k].a }); y += w / list[k].a + GAP; });
endEl.style.top = y + 'px';
endEl.style.height = END_H + 'px';
ch = y + END_H;
} else if (shown.length) {
const sum = shown.reduce((s, k) => s + list[k].a, 0), H = Math.min(vh, vw / sum) * z, W = H * sum;
cw = Math.max(vw, W); ch = Math.max(vh, H);
const y = (ch - H) / 2, order = shown.map((k, j) => j);
if (rtl) order.reverse(); // right-to-left: the first page of a pair sits on the right
let x = (cw - W) / 2;
for (const j of order) { boxes[j] = { x, y, w: H * list[shown[j]].a, h: H }; x += boxes[j].w; }
}
canvas.style.width = cw + 'px';
canvas.style.height = ch + 'px';
els.forEach((el, j) => place(el, boxes[j]));
stage.classList.toggle('pan', cw > vw + 1 || (mode === 'flow' ? z > 1.01 : ch > vh + 1));
lastW = vw; lastH = vh;
}
function place(el, b) {
const s = el.style, x = Math.round(b.x), y = Math.round(b.y), w = Math.round(b.x + b.w) - x, hh = Math.round(b.y + b.h) - y;
s.left = x + 'px'; s.top = y + 'px'; s.width = w + 'px'; s.height = hh + 'px';
if (el._p.r) {
const im = el.firstChild.style, turned = el._p.r % 180;
im.width = (turned ? hh : w) + 'px';
im.height = (turned ? w : hh) + 'px';
}
}
function flowIndexAt(y) {
let lo = 0, hi = boxes.length - 1;
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (boxes[mid].y <= y) lo = mid; else hi = mid - 1; }
return lo;
}
function union() {
const x = Math.min(...boxes.map(b => b.x));
return { x, y: boxes[0].y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: boxes[0].h };
}
// Remember which point of which page is under (cx, cy) and put it back there after a re-layout.
function anchor(cx, cy) {
if (!boxes.length) return null;
const X = stage.scrollLeft + cx, Y = stage.scrollTop + cy, k = mode === 'flow' ? flowIndexAt(Y) : -1, b = k < 0 ? union() : boxes[k];
return { k, fx: (X - b.x) / b.w, fy: (Y - b.y) / b.h };
}
function restore(a, cx, cy) {
if (!a || !boxes.length) return;
const b = a.k < 0 ? union() : boxes[a.k];
stage.scrollLeft = b.x + a.fx * b.w - cx;
stage.scrollTop = b.y + a.fy * b.h - cy;
barsAt = stage.scrollTop;
}
function relayout() { const a = anchor(lastW / 2, lastH / 2); layout(); restore(a, lastW / 2, lastH / 2); }
// ---------- zoom ----------
let zoomTimer = 0;
function setZoom(z, cx = stage.clientWidth / 2, cy = stage.clientHeight / 2) {
z = Math.min(6, Math.max(0.2, z));
if (Math.abs(z - 1) < 0.03) z = 1; // easy to get back to "fit"
if (Math.abs(z - zoom()) < 0.001) return;
const a = anchor(cx, cy);
S.zoom[zkey()] = z;
layout();
restore(a, cx, cy);
toast(Math.round(z * 100) + '%');
updateUI();
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => api('api/settings', { zoom: S.zoom }).catch(() => {}), 800);
}
// Ctrl + wheel (and touchpad pinch, which browsers report the same way) zooms the pages, not the page controls.
stage.addEventListener('wheel', e => {
if (!e.ctrlKey) return;
e.preventDefault();
setZoom(zoom() * Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0025)), e.clientX, e.clientY);
}, { passive: false });
// Touch: two fingers zoom, a quick horizontal swipe turns the page (page/book mode, when not zoomed in).
let pinch = null, swipe = null, pinchRaf = 0, pinchNext = null;
const pinchApply = () => { pinchRaf = 0; if (pinchNext) setZoom(...pinchNext); pinchNext = null; };
const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
stage.addEventListener('touchstart', e => {
const t = e.touches;
if (t.length === 2) { pinch = { d: dist(t), z: zoom() }; swipe = null; }
else if (t.length === 1) swipe = { x: t[0].clientX, y: t[0].clientY, t: Date.now() };
}, { passive: true });
stage.addEventListener('touchmove', e => {
if (!pinch || e.touches.length !== 2) return;
e.preventDefault();
const t = e.touches;
pinchNext = [pinch.z * dist(t) / pinch.d, (t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2];
pinchRaf = pinchRaf || requestAnimationFrame(pinchApply);
}, { passive: false });
stage.addEventListener('touchend', e => {
if (e.touches.length < 2) { if (pinchNext) pinchApply(); pinch = null; }
const s = swipe;
swipe = null;
if (!s || mode === 'flow' || e.touches.length) return;
const t = e.changedTouches[0], dx = t.clientX - s.x, dy = t.clientY - s.y;
if (Math.abs(dx) < 50 || Math.abs(dx) < 2 * Math.abs(dy) || Date.now() - s.t > 800) return;
if (stage.scrollWidth > stage.clientWidth + 1) return; // zoomed in: the finger pans instead
((dx > 0) === rtl ? next : prev)();
});
// Safari reports pinches as gesture events; without this it would zoom the whole page.
let gestureZoom = 1;
stage.addEventListener('gesturestart', e => { e.preventDefault(); gestureZoom = zoom(); });
stage.addEventListener('gesturechange', e => { e.preventDefault(); if (!pinch) setZoom(gestureZoom * e.scale, e.clientX, e.clientY); });
// Mouse: drag to move a zoomed page around; a click without dragging uses the tap zones.
let drag = null, dragged = false;
stage.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse' && e.button === 0) drag = { x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, moved: false };
});
addEventListener('pointermove', e => {
if (!drag) return;
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 6) return;
drag.moved = true;
stage.classList.add('drag');
stage.scrollLeft = drag.l - dx;
stage.scrollTop = drag.t - dy;
});
addEventListener('pointerup', () => {
if (drag && drag.moved) { dragged = true; setTimeout(() => { dragged = false; }, 0); }
drag = null;
stage.classList.remove('drag');
});
// Outside the pages: toggle the controls. On a page: the outer 35 % of the visible page area turn pages
// (top/bottom in flow), the middle toggles the controls (needed when a zoomed page fills the whole screen).
stage.addEventListener('click', e => {
if (dragged || e.target.closest('a, button, select, input')) return;
const onPage = e.target.closest('.pg'), vw = stage.clientWidth;
if (!onPage && !(atEnd && mode !== 'flow')) return toggleBars();
if (mode === 'flow') {
const fy = e.clientY / stage.clientHeight;
return fy < 0.35 ? prev() : fy > 0.65 ? next() : toggleBars();
}
let left = 0, right = vw;
if (!atEnd) {
const rects = els.map(el => el.getBoundingClientRect());
left = Math.max(0, Math.min(...rects.map(r => r.left)));
right = Math.min(vw, Math.max(...rects.map(r => r.right)));
}
const fx = (e.clientX - left) / (right - left);
if (fx < 0.35) (rtl ? next : prev)();
else if (fx > 0.65) (rtl ? prev : next)();
else toggleBars();
});
addEventListener('keydown', e => {
if ((e.target.closest && e.target.closest('input, select, textarea')) || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') { e.preventDefault(); (k === (rtl ? 'ArrowLeft' : 'ArrowRight') ? next : prev)(); }
else if ((k === ' ' || k === 'PageDown') && mode !== 'flow') { e.preventDefault(); (e.shiftKey ? prev : next)(); }
else if (k === 'PageUp' && mode !== 'flow') { e.preventDefault(); prev(); }
else if (k === '+' || k === '=') setZoom(zoom() * 1.15);
else if (k === '-') setZoom(zoom() / 1.15);
else if (k === '0') setZoom(1);
else if (k === 'm' || k === 'Escape') toggleBars();
else if (k === 'f') toggleFullscreen();
});
let scrollRaf = 0;
stage.addEventListener('scroll', () => {
if (mode !== 'flow' || scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0;
cur = flowIndexAt(stage.scrollTop + stage.clientHeight * 0.3);
// Scrolling (up or down) only ever hides the bars; they come back with a tap in the middle.
if (bars && Math.abs(stage.scrollTop - barsAt) > 100) autoHide();
moved();
});
});
let resizeRaf = 0;
addEventListener('resize', () => { resizeRaf = resizeRaf || requestAnimationFrame(() => { resizeRaf = 0; relayout(); }); });
// ---------- navigation ----------
function next() {
if (mode === 'flow') return flowStep(1);
if (atEnd) return goChapter(D.next);
if (mode === 'page' ? cur >= list.length - 1 : spreadIdx >= spreads.length - 1) atEnd = true;
else if (mode === 'page') cur++;
else cur = spreads[++spreadIdx][0];
turned();
}
function prev() {
if (mode === 'flow') return flowStep(-1);
if (atEnd) atEnd = false;
else if (mode === 'page' ? cur === 0 : spreadIdx === 0) return toast('First page');
else if (mode === 'page') cur--;
else cur = spreads[--spreadIdx][0];
turned();
}
function turned() { build(); toStart(); autoHide(); moved(); }
function toStart() { stage.scrollTop = 0; stage.scrollLeft = rtl ? stage.scrollWidth : 0; barsAt = 0; }
function goChapter(c) {
if (!c) return toast('You are up to date');
saveProgress(true);
location.href = readUrl(D.manga.id, c.id);
}
// Flow: jump to the top of the next page, but never further than ~one screen.
function flowStep(dir) {
const st = stage.scrollTop, vh = stage.clientHeight;
if (dir > 0 && st + vh >= stage.scrollHeight - 2) return goChapter(D.next);
let t;
if (ttb) t = st + dir * vh * 0.85; // webtoon slices are arbitrary cuts: just scroll
else if (dir > 0) { const b = boxes.find(b => b.y > st + 2); t = b ? Math.min(b.y, st + vh * 0.9) : st + vh * 0.9; }
else { let b = null; for (const x of boxes) { if (x.y < st - 2) b = x; else break; } t = b ? Math.max(b.y, st - vh * 0.9) : 0; }
stage.scrollTo({ top: t, behavior: 'smooth' });
}
function goTo(k) {
atEnd = false;
if (mode === 'flow') { stage.scrollTop = boxes[k].y; barsAt = stage.scrollTop; cur = k; return moved(); }
if (mode === 'book') { spreadIdx = spreads.findIndex(s => s.includes(k)); cur = spreads[spreadIdx][0]; }
else cur = k;
build(); toStart(); moved();
}
function setMode(m, byUser) {
mode = m; atEnd = false;
if (mode === 'book') {
spreads = computeSpreads();
spreadIdx = Math.max(0, spreads.findIndex(s => s.includes(cur)));
cur = spreads[spreadIdx][0];
}
build();
if (mode === 'flow') {
stage.scrollTop = boxes[cur].y;
stage.scrollLeft = (stage.scrollWidth - stage.clientWidth) / 2;
barsAt = stage.scrollTop;
} else toStart();
// Only a click changes the remembered mode, never an automatic switch for incompatible content.
if (byUser) { S.lastMode = m; api('api/settings', { lastMode: m }).catch(() => {}); }
moved();
}
function toggleShift() {
const k = shown[0];
if (k === undefined || solo(list[k])) return toast('This page is shown alone anyway');
const s = list[runStart(k)].i;
if (shift.has(s)) shift.delete(s);
else shift.add(s);
spreads = computeSpreads();
spreadIdx = spreads.findIndex(sp => sp.includes(k));
cur = spreads[spreadIdx][0];
build(); toStart(); moved();
toast(shift.has(s) ? 'Pairs shifted by one page' : 'Pairs back to normal');
}
// ---------- bars, progress ----------
function setBars(on) {
// Stop a running smooth scroll first, otherwise it would hide the bars again right away.
if (on) stage.scrollTo({ left: stage.scrollLeft, top: stage.scrollTop });
bars = on;
document.body.classList.toggle('nobars', !on);
barsAt = stage.scrollTop;
}
function toggleBars() { setBars(!bars); }
// Reading hides the bars. The first time (per device and per kind of mode) say how to get them back.
function autoHide() {
if (!bars) return;
setBars(false);
const key = mode === 'flow' ? 'hint.flow' : 'hint.paged';
if (store.get(key)) return;
store.set(key, '1');
toast(mode === 'flow' ? 'Controls hidden · tap the middle of the screen or beside the pages to show them'
: 'Controls hidden · tap the middle of the page or beside it to show them', 3500);
}
let toastTimer = 0;
function toast(msg, ms = 900) {
const t = $('#toast');
t.textContent = msg;
t.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove('show'), ms);
}
const modeBtns = [...document.querySelectorAll('#modes button')];
function updateUI() {
const n = list.length;
$('#pageno').textContent = atEnd ? 'end' : (shown.length === 2 ? `${cur + 1}-${cur + 2}` : cur + 1) + ' / ' + n;
slider.max = n;
slider.value = cur + 1;
for (const b of modeBtns) {
b.classList.toggle('on', b.dataset.mode === mode);
b.classList.toggle('meh', !compatible.includes(b.dataset.mode));
}
const sh = $('#shift');
sh.hidden = mode !== 'book';
sh.classList.toggle('on', shown.length > 0 && !solo(list[shown[0]]) && shift.has(list[runStart(shown[0])].i));
$('#zoom').textContent = Math.round(zoom() * 100) + '%';
}
let saveTimer = 0;
function moved() {
if (mode === 'flow') {
const b = boxes[boxes.length - 1];
if (b && stage.scrollTop + stage.clientHeight >= b.y + b.h - 4) { done = true; cur = list.length - 1; }
} else if (atEnd || (mode === 'page' ? cur === list.length - 1 : spreadIdx === spreads.length - 1)) done = true;
updateUI();
clearTimeout(saveTimer);
saveTimer = setTimeout(saveProgress, 700);
}
function saveProgress(keepalive) {
clearTimeout(saveTimer);
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] });
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
}
addEventListener('pagehide', () => saveProgress(true));
// ---------- controls ----------
$('#title').textContent = D.manga.title;
// Chapter picker: every chapter of the manga, grouped by book when there is more than one.
const chsel = $('#chsel'), groups = {};
for (const b of D.books) {
groups[b.key] = D.books.length > 1 ? chsel.appendChild(h('optgroup', { label: b.label + (b.title ? ' · ' + b.title : '') })) : chsel;
}
for (const c of D.chapters) {
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
}
chsel.value = D.chapter.id;
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); };
$('#back').href = mangaUrl(D.manga.id);
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
const a = $(id);
if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); }
else a.classList.add('disabled');
}
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
ver.value = D.version;
ver.hidden = D.versions.length < 2;
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
$('#shift').onclick = toggleShift;
$('#zoom').onclick = () => setZoom(1);
slider.dir = rtl ? 'rtl' : 'ltr';
slider.addEventListener('input', () => goTo(+slider.value - 1));
function toggleFullscreen() {
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen().catch(() => {});
}
$('#fs').hidden = !document.fullscreenEnabled;
$('#fs').onclick = toggleFullscreen;
// Keep keyboard focus on the pages so Space/arrows never "press" the last clicked button again.
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
setMode(mode, false);
stage.focus({ preventScroll: true });
setBars(true);
})();