nextHunter

pure-JS hentai reader for e-hentai/exhentai/nhentai.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         nextHunter
// @namespace    https://github.com/luckylukam/newHunter
// @version      1.3.0
// @description  pure-JS hentai reader for e-hentai/exhentai/nhentai.
// @author       luckylukam
// @match        *://e-hentai.org/*
// @match        *://exhentai.org/*
// @match        *://nhentai.net/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";
  const THEME_CSS = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap');
:root {
  --r-bg:        #0d0d0f;
  --r-bg-alt:    #131318;
  --r-bg-glass:  rgba(13, 13, 15, 0.88);
  --r-fg:        #e8e3d8;
  --r-fg-dim:    rgba(232, 227, 216, 0.45);
  --r-accent:    #c9963a;
  --r-accent-lo: rgba(201, 150, 58, 0.18);
  --r-border:    rgba(255, 255, 255, 0.08);
  --r-red:       #e05858;
  --r-red-lo:    rgba(224, 88, 88, 0.15);
  --r-green:     #4fb86a;
  --r-radius:    6px;
  --r-font:      -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --r-bar-h:     44px;
}
/* ---- root shell ---- */
#ehunter-js-root {
  position: fixed;
  inset: 0;
  z-index: 999999;
  background: var(--r-bg);
  color: var(--r-fg);
  font: 13px/1.5 var(--r-font);
  display: flex;
  flex-direction: column;
}
/* ---- top bar ---- */
.r-topbar {
  position: relative;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 10px;
  height: var(--r-bar-h);
  flex-shrink: 0;
  background: var(--r-bg-glass);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--r-border);
  z-index: 10;
  transition: margin-top 0.22s ease, opacity 0.22s ease;
}
/* Slide the bar up by its own height — negative margin collapses it out of flow
   without reflowing the scroll content (the scroller fills the gap). */
#ehunter-js-root.r-bar-hidden .r-topbar {
  margin-top: calc(-1 * var(--r-bar-h));
  opacity: 0;
  pointer-events: none;
}
.r-topbar__title {
  font-weight: 600;
  font-size: 13px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 26vw;
  opacity: 0.85;
  margin-right: 4px;
}
.r-topbar__sep {
  width: 1px;
  height: 18px;
  background: var(--r-border);
  flex-shrink: 0;
  margin: 0 2px;
}
.r-topbar__page {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  color: var(--r-fg-dim);
  white-space: nowrap;
}
.r-progressbar {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 2px;
  background: var(--r-accent);
  transition: width 0.3s ease;
  pointer-events: none;
}
/* ---- buttons ---- */
.r-btn {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  background: transparent;
  color: var(--r-fg-dim);
  border: 1px solid transparent;
  border-radius: var(--r-radius);
  padding: 5px 9px;
  cursor: pointer;
  font: inherit;
  font-size: 12px;
  white-space: nowrap;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.r-btn:hover, .r-btn:focus-visible {
  color: var(--r-fg);
  border-color: var(--r-border);
  background: rgba(255,255,255,0.05);
  outline: none;
}
.r-btn--active {
  color: var(--r-accent);
  border-color: var(--r-accent);
  background: var(--r-accent-lo);
}
.r-btn--close:hover {
  color: var(--r-red);
  border-color: var(--r-red);
  background: var(--r-red-lo);
}
.r-btn svg { flex-shrink: 0; }
/* ---- body + layout ---- */
.r-body {
  display: flex;
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
/* ---- scroll mode ---- */
.r-scrollmode {
  flex: 1;
  overflow-y: auto;
  overflow-x: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  scroll-behavior: smooth;
}
/* ---- page wrapper (shared by scroll + book) ---- */
/* NOTE: min-height on unloaded pages stops the container from collapsing while the
   blurred thumb preview is showing. Once loaded the image's intrinsic size drives
   layout, so min-height drops to 0 — this prevents the large empty gap that appears
   when zooming out (CSS zoom shrinks the content but vh-based min-height stays fixed
   in viewport units, creating dead space between images). */
.r-page {
  position: relative;
  max-width: 100%;
  width: 100%;          /* scroll mode: fill column so it never collapses horizontally */
  min-height: 400px;    /* px (not vh) so CSS zoom scales it down — vh is viewport-relative
                           and stays large even when content is zoomed out, creating gaps */
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  /* No aspect-ratio in transition — animating it causes intermediate wrong sizes = visible gaps */
  transition: width 0.25s ease, min-width 0.25s ease, min-height 0.25s ease;
}
/* Once loaded, drop min-height entirely — the image's intrinsic height drives layout.
   This removes the gap that appears at low zoom levels. */
.r-page--loaded { min-height: 0; }
.r-page--book {
  max-height: 100%;
  width: 42vw;          /* book mode: hard floor — prevents collapse before image loads */
  min-width: 42vw;
  min-height: 55vh;
}
.r-page--book.r-page--loaded { width: auto; min-width: 0; min-height: 0; }
/* spinner on loading pages */
.r-page--loading::after {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid var(--r-border);
  border-top-color: var(--r-accent);
  animation: r-spin 0.7s linear infinite;
  pointer-events: none;
}
.r-page--failed { border: 1px dashed rgba(224,88,88,0.4); cursor: pointer; }
/* images */
.r-page-img {
  position: relative;
  z-index: 1;           /* above placeholder so partial paints show through */
  width: 100%;          /* fill the column — lets the image's natural aspect ratio drive height */
  height: auto;         /* intrinsic height: no explicit height means no layout collapse */
  max-width: 100%;
  display: block;
  opacity: 0;
  pointer-events: none; /* pass clicks through to placeholder actions while invisible */
  transition: opacity 0.25s ease;
}
.r-page--loading .r-page-img { opacity: 1; }
.r-page-img--loaded { opacity: 1; pointer-events: auto; }
.r-page-img--book { width: auto; max-height: 92vh; }
/* zoom applied via JS: --r-zoom CSS variable.
   We control zoom via max-width on .r-page rather than CSS zoom on the scroller.
   CSS zoom on the scroller conflicts with width:100% on images — the image always
   fills 100% of the zoomed container so it looks unchanged. Instead, we shrink the
   page wrapper's max-width, which limits the column width so width:100% images
   actually render smaller. Book mode keeps CSS zoom since it uses fixed vh sizing. */
.r-scrollmode .r-page { max-width: calc(var(--r-zoom, 1) * 100%); }
.r-bookmode            { zoom: var(--r-zoom, 1); }
/* ---- HD badge ---- */
.r-hd-btn {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 5;
  background: rgba(0,0,0,0.6);
  color: var(--r-fg-dim);
  border: 1px solid var(--r-border);
  border-radius: 4px;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.05em;
  padding: 2px 7px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s, color 0.15s, border-color 0.15s;
}
.r-page:hover .r-hd-btn { opacity: 0.8; }
.r-hd-btn:hover { opacity: 1 !important; color: var(--r-accent); border-color: var(--r-accent); }
.r-hd-btn--active { opacity: 1 !important; color: var(--r-accent); border-color: var(--r-accent); background: var(--r-accent-lo); }
/* ---- placeholder (shown until image loads) ---- */
.r-placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 16px;
  text-align: center;
  z-index: 0;           /* behind img (z-index:1) so partial paints show through */
  pointer-events: none;
  overflow: hidden;
  max-width: 100%;
  max-height: 100%;
}
.r-page--loaded .r-placeholder { display: none; }
/* blurry thumb shown behind loading status */
.r-placeholder__thumb-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: hidden;
  filter: blur(12px) brightness(0.45);
  z-index: 0;
  opacity: 0;
  transition: opacity 0.3s ease;
  border-radius: 2px;
  /* background-image/size/position are set entirely by JS (sprite math or plain cover) */
  background-repeat: no-repeat;
}
.r-placeholder__thumb-bg--visible { opacity: 1; }
.r-placeholder__num,
.r-placeholder__status,
.r-placeholder__actions { position: relative; z-index: 1; }
.r-placeholder__num {
  font-family: 'Cinzel', serif;
  font-size: 64px;
  font-weight: 700;
  line-height: 1;
  color: var(--r-fg);
  opacity: 0.06;
  letter-spacing: -0.02em;
  user-select: none;
}
.r-placeholder__status {
  font-size: 11px;
  color: var(--r-fg-dim);
  letter-spacing: 0.02em;
}
.r-page--failed .r-placeholder__status { color: #ff8080; opacity: 1; }
.r-placeholder__actions {
  display: flex;
  gap: 12px;
  pointer-events: all;
}
.r-placeholder__action {
  color: var(--r-accent);
  cursor: pointer;
  font-size: 11px;
  opacity: 0.85;
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: opacity 0.15s, border-color 0.15s;
}
.r-placeholder__action:hover { opacity: 1; border-color: var(--r-accent); }
/* ---- book mode ---- */
.r-bookmode {
  flex: 1;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 2px;
  overflow: hidden;
}
.r-clickzone {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 30%;
  cursor: pointer;
  z-index: 3;
}
.r-clickzone--left  { left: 0; }
.r-clickzone--right { right: 0; }
/* ---- thumbnail sidebar ---- */
.r-thumbbar {
  display: none;
  flex-direction: column;
  gap: 4px;
  overflow-y: auto;
  padding: 8px 6px;
  width: 96px;
  background: var(--r-bg-alt);
  border-left: 1px solid var(--r-border);
  flex-shrink: 0;
}
.r-thumb-wrap {
  position: relative;
  border-radius: 4px;
  overflow: hidden;
  cursor: pointer;
  flex-shrink: 0;
  background: var(--r-bg);
  border: 1.5px solid transparent;
  transition: border-color 0.15s;
}
.r-thumb-wrap--active { border-color: var(--r-accent); }
.r-thumb-wrap:hover:not(.r-thumb-wrap--active) { border-color: var(--r-border); }
.r-thumb {
  position: relative;
  overflow: hidden;
  width: 100%;
  /* aspect-ratio is set per-thumb in JS from the actual sprite crop / image dimensions
     (paintThumb injects it once the probe image loads).  Fall back to 2/3 (manga default)
     only until the real value is known — overridden inline once the thumb loads. */
  aspect-ratio: 2/3;
  display: block;
  object-fit: cover;
  opacity: 0.6;
  transition: opacity 0.2s;
}
.r-thumb-wrap--active .r-thumb,
.r-thumb-wrap:hover .r-thumb { opacity: 1; }
.r-thumb--shimmer {
  background: linear-gradient(90deg, var(--r-bg) 25%, var(--r-bg-alt) 50%, var(--r-bg) 75%);
  background-size: 200% 100%;
  animation: r-shimmer 1.4s ease infinite;
}
.r-thumb--missing {
  border-color: rgba(224,88,88,0.3) !important;
  background: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(224,88,88,0.08) 5px, rgba(224,88,88,0.08) 10px);
}
.r-thumb-badge {
  position: absolute;
  bottom: 3px;
  right: 4px;
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  color: rgba(255,255,255,0.6);
  text-shadow: 0 1px 3px rgba(0,0,0,0.9);
  pointer-events: none;
}
/* ---- toast ---- */
.r-toast {
  position: fixed;
  top: calc(var(--r-bar-h) + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(-8px);
  padding: 7px 16px;
  border-radius: var(--r-radius);
  background: var(--r-bg-alt);
  border: 1px solid var(--r-border);
  color: var(--r-fg);
  font-size: 12px;
  z-index: 1000000;
  opacity: 0;
  transition: opacity 0.2s ease, transform 0.2s ease;
  pointer-events: none;
  white-space: nowrap;
}
.r-toast--visible { opacity: 1; transform: translateX(-50%) translateY(0); }
.r-toast--error  { border-color: var(--r-red); color: #ff9090; }
.r-toast--success { border-color: var(--r-green); color: #80e898; }
/* ---- dialog ---- */
.r-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  backdrop-filter: blur(4px);
  z-index: 1000000;
  display: flex;
  align-items: center;
  justify-content: center;
}
.r-dialog {
  position: relative;
  background: var(--r-bg-alt);
  border: 1px solid var(--r-border);
  border-radius: 10px;
  padding: 22px 24px;
  min-width: 280px;
  max-width: 400px;
  width: 90vw;
}
.r-dialog__title {
  font-weight: 600;
  font-size: 14px;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--r-border);
}
.r-dialog__close {
  position: absolute;
  top: 10px;
  right: 10px;
  padding: 4px 8px;
  font-size: 14px;
}
/* ---- settings ---- */
.r-settings { display: flex; flex-direction: column; gap: 14px; }
.r-settings__num-input {
  background: rgba(255,255,255,0.06);
  color: var(--r-fg);
  border: 1px solid var(--r-border);
  border-radius: var(--r-radius);
  padding: 4px 8px;
  font: inherit;
  font-size: 12px;
  width: 64px;
  text-align: center;
}
.r-settings__label {
  font-size: 12px;
  color: var(--r-fg-dim);
  display: block;
  margin-bottom: 5px;
}
.r-settings__hint {
  font-size: 11px;
  color: var(--r-fg-dim);
  line-height: 1.5;
  margin-top: -6px;
}
.r-settings__shortcut-grid {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 12px;
  font-size: 11px;
}
.r-settings__key {
  font-family: monospace;
  background: rgba(255,255,255,0.07);
  border: 1px solid var(--r-border);
  border-radius: 3px;
  padding: 1px 5px;
  color: var(--r-fg);
  text-align: center;
}
.r-settings__key-desc { color: var(--r-fg-dim); align-self: center; }
/* switch row */
.r-switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  font-size: 12px;
}
.r-switch { position: relative; display: inline-block; width: 32px; height: 18px; flex: none; }
.r-switch input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; z-index: 1; }
.r-switch__track {
  position: absolute;
  inset: 0;
  background: rgba(255,255,255,0.1);
  border-radius: 999px;
  transition: background 0.15s;
}
.r-switch__track::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--r-fg);
  transition: transform 0.15s;
}
.r-switch input:checked + .r-switch__track { background: var(--r-accent); }
.r-switch input:checked + .r-switch__track::after { transform: translateX(14px); }
/* select */
.r-select {
  background: rgba(255,255,255,0.06);
  color: var(--r-fg);
  border: 1px solid var(--r-border);
  border-radius: var(--r-radius);
  padding: 5px 8px;
  font: inherit;
  font-size: 12px;
  cursor: pointer;
  width: 100%;
}
.r-select:focus { outline: 1px solid var(--r-accent); }
/* ---- boot overlay ---- */
.r-boot {
  position: fixed;
  inset: 0;
  z-index: 2147483647;
  background: var(--r-bg);
  color: var(--r-fg);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  font: 13px/1.5 var(--r-font);
}
.r-boot__ring {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 2.5px solid var(--r-border);
  border-top-color: var(--r-accent);
  animation: r-spin 0.75s linear infinite;
}
.r-boot__label { color: var(--r-fg-dim); font-size: 12px; }
.r-boot--error .r-boot__label { color: var(--r-red); opacity: 1; max-width: 300px; text-align: center; }
/* ---- keyframes ---- */
@keyframes r-spin    { to { transform: rotate(360deg); } }
@keyframes r-shimmer { to { background-position: -200% 0; } }
`;
  function injectStyles() {
    const s = document.createElement('style');
    s.textContent = THEME_CSS;
    document.head.appendChild(s);
    document.documentElement.style.overflow = 'hidden';
    document.body.style.overflow = 'hidden';
  }
  function icon(path, size = 14) {
    const ns = 'http://www.w3.org/2000/svg';
    const svg = document.createElementNS(ns, 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', size);
    svg.setAttribute('height', size);
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    svg.innerHTML = path;
    return svg;
  }
  const ICONS = {
    scroll:    '<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>',
    book:      '<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>',
    thumbs:    '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
    zoomIn:    '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/>',
    zoomOut:   '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/>',
    download:  '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
    settings:  '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
    close:     '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
  };
  const CONFIG = {
    rootId: 'ehunter-js-root',
    defaultMode: 'scroll',
    defaultZoom: 1,
    defaultLang: 'en',
    defaultHighRes: false,
    thumbnailWidth: 96,
    prefetchAhead: 2,
    gridLookahead: 8,
    retry: { maxAttempts: 6, baseDelayMs: 800 },
    requestTimeoutMs: 45000,
    imageTimeoutMs:   60000,
    autoRetryDelay:   8000,
    storageKeys: { layoutPreference: 'ehunter-js:layout-preference' },
    defaultInitialForward:  3,
    defaultInitialBackward: 1,
    defaultPreloadForward:  15,
    defaultPreloadBackward: 5,
  };
  const Storage = {
    get(key, fallback) {
      try {
        if (typeof GM_getValue === 'function') {
          const v = GM_getValue(key);
          return v === undefined ? fallback : v;
        }
      } catch {   }
      try {
        const raw = localStorage.getItem(key);
        return raw === null ? fallback : JSON.parse(raw);
      } catch {
        return fallback;
      }
    },
    set(key, value) {
      try {
        if (typeof GM_setValue === 'function') { GM_setValue(key, value); return; }
      } catch {   }
      try { localStorage.setItem(key, JSON.stringify(value)); } catch {   }
    },
  };
  function h(tag, attrs = {}, children = []) {
    const el = document.createElement(tag);
    for (const key in attrs) {
      const value = attrs[key];
      if (key === 'class') el.className = value;
      else if (key === 'style') Object.assign(el.style, value);
      else if (key.startsWith('on') && typeof value === 'function')
        el.addEventListener(key.slice(2).toLowerCase(), value);
      else if (value !== null && value !== undefined)
        el.setAttribute(key, value);
    }
    for (const child of [].concat(children)) {
      if (child == null) continue;
      el.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
    }
    return el;
  }
  function clear(el) { el.replaceChildren(); }
  class EventBus {
    constructor() { this._m = new Map(); }
    on(ev, fn) {
      if (!this._m.has(ev)) this._m.set(ev, new Set());
      this._m.get(ev).add(fn);
      return () => this._m.get(ev)?.delete(fn);
    }
    emit(ev, d) { this._m.get(ev)?.forEach(fn => fn(d)); }
  }
  const DICTS = {
    en: { scrollMode:'Scroll', bookMode:'Book', thumbnails:'Thumbnails', download:'Download', settings:'Settings', close:'Close', zoomIn:'Zoom in', zoomOut:'Zoom out', page:'Page' },
    zh: { scrollMode:'滚动', bookMode:'书本', thumbnails:'缩略图', download:'下载', settings:'设置', close:'关闭', zoomIn:'放大', zoomOut:'缩小', page:'页' },
    jp: { scrollMode:'スクロール', bookMode:'ブック', thumbnails:'サムネ', download:'ダウンロード', settings:'設定', close:'閉じる', zoomIn:'拡大', zoomOut:'縮小', page:'ページ' },
  };
  class I18n {
    constructor(lang = CONFIG.defaultLang) { this.lang = lang; }
    setLang(lang) { if (DICTS[lang]) this.lang = lang; }
    t(key) { return DICTS[this.lang]?.[key] ?? DICTS.en[key] ?? key; }
  }
  class LayoutPreference {
    constructor() {
      const defaults = {
        mode: CONFIG.defaultMode, zoom: CONFIG.defaultZoom, lang: CONFIG.defaultLang, rtl: true, highRes: CONFIG.defaultHighRes,
        initialForward:  CONFIG.defaultInitialForward,
        initialBackward: CONFIG.defaultInitialBackward,
        preloadForward:  CONFIG.defaultPreloadForward,
        preloadBackward: CONFIG.defaultPreloadBackward,
      };
      this.data = { ...defaults, ...Storage.get(CONFIG.storageKeys.layoutPreference, {}) };
    }
    save(patch) {
      this.data = { ...this.data, ...patch };
      Storage.set(CONFIG.storageKeys.layoutPreference, this.data);
    }
  }
  class AppState {
    constructor(bus, pref) {
      this.bus  = bus;
      this.pref = pref;
      const p = pref.data;
      this.album = { title: '', pages: [] };
      this.mode = p.mode;
      this.zoom = p.zoom;
      this.rtl = p.rtl;
      this.currentPage = 0;
      this.thumbVisible = false;
      this.highRes = !!p.highRes;
      this.highResPages = new Set();
      this.isLoadingAlbum = true;
    }
    _emit() { this.bus.emit('state:changed', this); }
    setMode(m)           { this.mode = m;                            this.pref.save({ mode: m });          this._emit(); }
    setZoom(z, maxZoom)  {
      const cap = (maxZoom != null && maxZoom > 0) ? maxZoom : 3;
      this.zoom = Math.round(Math.min(cap, Math.max(0.25, z)) * 100) / 100;
      this.pref.save({ zoom: this.zoom });
      this._emit();
    }
    setCurrentPage(i)    { this.currentPage = Math.min(Math.max(0,i), this.album.pages.length-1); this._emit(); }
    toggleThumbnails()   { this.thumbVisible = !this.thumbVisible;   this._emit(); }
    setAlbum(a)          { this.album = a;                           this._emit(); }
    setLoadingAlbum(v)   { this.isLoadingAlbum = v;                  this._emit(); }
    setHighRes(on)       {
      this.highRes = on;
      if (!on) this.highResPages.clear();
      this.pref.save({ highRes: on });
      this._emit();
    }
    setHighResForPage(i, on) { on ? this.highResPages.add(i) : this.highResPages.delete(i); this._emit(); }
    isHighResPage(i)     { return this.highResPages.has(i) || this.highRes; }
  }
  function computeBookSpreads(pageCount, rtl = true) {
    const spreads = [];
    if (!pageCount) return spreads;
    spreads.push([0]);
    for (let i = 1; i < pageCount; i += 2) {
      const hasSecond = i + 1 < pageCount;
      spreads.push(rtl && hasSecond ? [i + 1, i] : hasSecond ? [i, i + 1] : [i]);
    }
    return spreads;
  }
  async function fetchText(url, opts = {}) {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), CONFIG.requestTimeoutMs);
    try {
      const res = await fetch(url, { credentials: 'include', ...opts, signal: ctrl.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
      return await res.text();
    } catch (err) {
      if (err?.name === 'AbortError') throw new Error(`Timed out after ${CONFIG.requestTimeoutMs}ms: ${url}`);
      throw err;
    } finally {
      clearTimeout(t);
    }
  }
  async function fetchDoc(url, opts = {}) {
    return new DOMParser().parseFromString(await fetchText(url, opts), 'text/html');
  }
  function politeDelay(ms) { return new Promise(r => setTimeout(r, ms)); }
  async function withRetry(fn, { maxAttempts, baseDelayMs } = CONFIG.retry) {
    let last;
    for (let a = 1; a <= maxAttempts; a++) {
      try { return await fn(a); }
      catch (err) {
        last = err;
        if (a < maxAttempts) await new Promise(r => setTimeout(r, baseDelayMs * 2 ** (a - 1) + Math.random() * baseDelayMs));
      }
    }
    throw last;
  }
  class TaskQueue {
    constructor(concurrency = 3) {
      this.concurrency = concurrency;
      this.running = 0;
      this._high = [];
      this._low  = [];
    }
    push(task, { priority = 0 } = {}) {
      return new Promise((resolve, reject) => {
        const item = { task, resolve, reject };
        if (priority >= 10) this._high.push(item);
        else                this._low.push(item);
        this._drain();
      });
    }
    _drain() {
      while (this.running < this.concurrency && (this._high.length || this._low.length)) {
        const { task, resolve, reject } = this._high.length ? this._high.shift() : this._low.shift();
        this.running++;
        Promise.resolve().then(task).then(resolve, reject).finally(() => { this.running--; this._drain(); });
      }
    }
  }
  class AlbumService {
    constructor(platformService) {
      this.ps = platformService;
      this.queue = new TaskQueue(3);
      this.sourceCache = new Map();
      this.meta = null;
    }
    async loadAlbum(prefetchedMeta) {
      const meta = prefetchedMeta ?? await this.ps.fetchMeta();
      this.meta = meta;
      const count = meta.pageCount ?? meta.pageLinks?.length ?? 0;
      const pages = Array.from({ length: count }, (_, i) => ({
        index: i,
        pageUrl: meta.pageLinks?.[i] ?? null,
        thumb: meta.thumbLinks?.[i] ?? null,
        thumbStyle: null,
        imageUrl: null,
        loaded: false,
        loading: false,
      }));
      return { title: meta.title, pages, supportsHD: meta.supportsHD !== false };
    }
    async resolvePageUrl(page) {
      if (!page.pageUrl && this.meta?.getPageLink) page.pageUrl = await this.meta.getPageLink(page.index);
      return page.pageUrl;
    }
    async resolveThumb(page) {
      if (!page.thumb && this.meta?.getThumbLink) {
        const data = await this.meta.getThumbLink(page.index);
        if (data && typeof data === 'object') {
          page.thumb = data.url ?? null;
          page.thumbStyle = data.offsetX != null
            ? { offsetX: data.offsetX, offsetY: data.offsetY, width: data.width, height: data.height }
            : null;
        } else {
          page.thumb = data ?? null;
        }
      }
      return page.thumb;
    }
    _cacheKey(index, highRes) { return `${index}:${highRes ? 'hi' : 'lo'}`; }
    async getPageSources(page, { highRes = false, priority = 0 } = {}) {
      const key = this._cacheKey(page.index, highRes);
      if (this.sourceCache.has(key)) return this.sourceCache.get(key);
      const pageUrl = await this.resolvePageUrl(page);
      if (!pageUrl) return [];
      const sources = await this.queue.push(
        () => withRetry(() => this.ps.fetchPageImageSources(page, pageUrl, { highRes }), CONFIG.retry),
        { priority }
      );
      const list = (sources ?? []).filter(Boolean);
      this.sourceCache.set(key, list);
      return list;
    }
    async reloadPageSources(page, opts) {
      this.sourceCache.delete(this._cacheKey(page.index, !!opts?.highRes));
      return this.getPageSources(page, opts);
    }
  }
  function createPreloadEngine(albumService, pref, isHighResPage) {
    const sourcesQueued = new Set();
    const imagesQueued  = new Set();
    let imageSlots = 0;
    const IMAGE_CONCURRENCY = 6;
    const imageQueue = [];
    let imageQueueHead = 0;
    let initialBurstDone = false;
    let _cfgCache = null;
    let _cfgPrefData = null;
    function cfg() {
      const d = pref.data;
      if (_cfgCache && _cfgPrefData === d) return _cfgCache;
      _cfgPrefData = d;
      _cfgCache = {
        initFwd:  Math.max(0, d.initialForward  ?? CONFIG.defaultInitialForward),
        initBwd:  Math.max(0, d.initialBackward ?? CONFIG.defaultInitialBackward),
        fwd:      Math.max(0, d.preloadForward  ?? CONFIG.defaultPreloadForward),
        bwd:      Math.max(0, d.preloadBackward ?? CONFIG.defaultPreloadBackward),
      };
      return _cfgCache;
    }
    function _qkey(idx, highRes) { return `${idx}:${highRes ? 'hi' : 'lo'}`; }
    function drainImages() {
      while (imageSlots < IMAGE_CONCURRENCY && imageQueueHead < imageQueue.length) {
        const item = imageQueue[imageQueueHead++];
        imageSlots++;
        _loadImageIntoCache(item.page, item.url, item.highRes).finally(() => {
          imageSlots--;
          drainImages();
        });
      }
      // Compact the consumed portion of the queue once more than half is dead,
      // done outside the loop so the current iteration's item is never affected.
      if (imageQueueHead > 64 && imageQueueHead > imageQueue.length >> 1) {
        imageQueue.splice(0, imageQueueHead);
        imageQueueHead = 0;
      }
    }
    function _loadImageIntoCache(page, url, highRes) {
      return new Promise(resolve => {
        if (page.loaded && (page.loadedAsHD === !!highRes || !highRes)) { resolve(); return; }
        const tokenAtStart = page.cacheToken ?? 0;
        const img = new Image();
        const tid = setTimeout(() => { img.src = ''; resolve(); }, CONFIG.imageTimeoutMs);
        img.onload  = () => {
          clearTimeout(tid);
          if (!page.loaded && (page.cacheToken ?? 0) === tokenAtStart) {
            page.loaded      = true;
            page.imageUrl    = url;
            page.loadedAsHD  = !!highRes;
          }
          resolve();
        };
        img.onerror = () => { clearTimeout(tid); resolve(); };
        img.src = url;
      });
    }
    function enqueue(pages, idx) {
      if (idx < 0 || idx >= pages.length) return;
      const page = pages[idx];
      if (!page) return;
      const highRes = !!(isHighResPage?.(idx));
      const qk = _qkey(idx, highRes);
      if (page.loaded && (page.loadedAsHD === highRes || !highRes)) return;
      if (!sourcesQueued.has(qk)) {
        sourcesQueued.add(qk);
        albumService.getPageSources(page, { highRes, priority: 0 }).then(sources => {
          const url = sources?.[0];
          if (!url || imagesQueued.has(qk)) return;
          if (page.loaded && (page.loadedAsHD === highRes || (highRes && page.loadedAsHD))) return;
          imagesQueued.add(qk);
          imageQueue.push({ page, url, highRes });
          drainImages();
        }).catch(() => {});
      }
    }
    function expandWindow(pages, center) {
      const { fwd, bwd } = cfg();
      for (let i = center; i <= Math.min(pages.length - 1, center + fwd); i++) enqueue(pages, i);
      for (let i = Math.max(0, center - bwd); i < center; i++) enqueue(pages, i);
    }
    function onAlbumReady(pages, startIndex = 0) {
      const { initFwd, initBwd } = cfg();
      for (let i = startIndex; i <= Math.min(pages.length - 1, startIndex + initFwd - 1); i++) enqueue(pages, i);
      for (let i = Math.max(0, startIndex - initBwd); i < startIndex; i++) enqueue(pages, i);
      setTimeout(() => expandWindow(pages, startIndex), 100);
      initialBurstDone = true;
    }
    function onPageVisible(pages, index) {
      imagesQueued.delete(_qkey(index, true));
      imagesQueued.delete(_qkey(index, false));
      if (!initialBurstDone) onAlbumReady(pages, index);
      else expandWindow(pages, index);
    }
    function reset() {
      sourcesQueued.clear();
      imagesQueued.clear();
      imageQueue.length = 0;
      imageQueueHead = 0;
      initialBurstDone = false;
      _cfgCache = null;
      _cfgPrefData = null;
    }
    function cancelPage(idx) {
      sourcesQueued.delete(_qkey(idx, true));
      sourcesQueued.delete(_qkey(idx, false));
      imagesQueued.delete(_qkey(idx, true));
      imagesQueued.delete(_qkey(idx, false));
      // Single-pass filter on the live portion of the queue — avoids repeated
      // splice(i,1) which shifts the array tail on every removed entry.
      if (imageQueueHead < imageQueue.length) {
        const kept = imageQueue.slice(imageQueueHead).filter(item => item.page.index !== idx);
        imageQueue.splice(imageQueueHead, imageQueue.length - imageQueueHead, ...kept);
      }
    }
    return { onAlbumReady, onPageVisible, reset, cancelPage };
  }
  const JSZIP_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
  let _jszipPromise = null;
  function getGlobal() {
    try { if (typeof unsafeWindow !== 'undefined') return unsafeWindow; } catch {   }
    return window;
  }
  function loadJSZip() {
    const g = getGlobal();
    if (g.JSZip) return Promise.resolve(g.JSZip);
    if (_jszipPromise) return _jszipPromise;
    _jszipPromise = new Promise((res, rej) => {
      const s = document.createElement('script');
      s.src = JSZIP_CDN;
      s.onload = () => res(getGlobal().JSZip);
      s.onerror = () => rej(new Error('JSZip CDN load failed'));
      document.head.appendChild(s);
    });
    return _jszipPromise;
  }
  async function fetchImageBlob(url, imgEls) {
    const _imgEls = imgEls ?? document.querySelectorAll('img.r-page-img');
    for (const img of _imgEls) {
      if (img.src === url && img.complete && img.naturalWidth) {
        try {
          const canvas = document.createElement('canvas');
          canvas.width  = img.naturalWidth;
          canvas.height = img.naturalHeight;
          canvas.getContext('2d').drawImage(img, 0, 0);
          const blob = await new Promise((res, rej) =>
            canvas.toBlob(b => b && b.size > 0 ? res(b) : rej(new Error('toBlob empty')), 'image/png')
          );
          canvas.width = canvas.height = 0; // release GPU/memory backing store early
          return { blob, fromCache: true };
        } catch {   }
      }
    }
    if (typeof GM_xmlhttpRequest === 'function') {
      const blob = await new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          responseType: 'blob',
          anonymous: false,
          onload: r => {
            if (r.status >= 200 && r.status < 300 && r.response?.size > 0) {
              resolve(r.response);
            } else {
              reject(new Error(`GM XHR HTTP ${r.status} size=${r.response?.size ?? 0}`));
            }
          },
          onerror:   e => reject(new Error(`GM XHR error: ${e.error ?? 'unknown'}`)),
          ontimeout: () => reject(new Error('GM XHR timed out')),
        });
      });
      return { blob, fromCache: false };
    }
    const r = await fetch(url, { credentials: 'include' });
    if (!r.ok) throw new Error(`fetch HTTP ${r.status}`);
    const blob = await r.blob();
    if (!blob.size) throw new Error('fetch returned empty blob');
    return { blob, fromCache: false };
  }
  class DownloadService {
    async downloadAll(pages, resolveUrl, titlePrefix, { onProgress } = {}) {
      const entries = [];
      for (const page of pages) {
        try {
          if (page.loaded && page.imageUrl) {
            entries.push({ page, url: page.imageUrl, wasLoaded: true });
          } else {
            const url = await resolveUrl(page);
            if (url) entries.push({ page, url, wasLoaded: false });
          }
        } catch (err) {
          console.warn(`[eHunter-js] could not resolve page ${page.index + 1} for download`, err);
        }
      }
      if (!entries.length) return;
      let JSZip;
      try { JSZip = await loadJSZip(); } catch {   }
      if (JSZip) {
        await this._zip(entries, titlePrefix, JSZip, onProgress);
      } else {
        await this._sequential(entries, titlePrefix, onProgress);
      }
    }
    async _fetchWithRetry(url, maxRetries = 3, imgEls) {
      let lastErr;
      for (let attempt = 1; attempt <= maxRetries; attempt++) {
        try {
          const result = await fetchImageBlob(url, imgEls);
          if (!result.blob || result.blob.size === 0) throw new Error('empty blob');
          return result;
        } catch (err) {
          lastErr = err;
          if (attempt < maxRetries) await new Promise(r => setTimeout(r, 800 * attempt));
        }
      }
      throw lastErr;
    }
    async _zip(entries, titlePrefix, JSZip, onProgress) {
      const zip = new JSZip();
      let done = 0, cached = 0, fetched = 0, failed = 0;
      const total = entries.length;
      const imgEls = Array.from(document.querySelectorAll('img.r-page-img'));
      const CACHE_CONCURRENCY   = 16;
      const NETWORK_CONCURRENCY =  4;
      const results = new Array(total);
      const runPool = async (items, concurrency) => {
        let idx = 0;
        const worker = async () => {
          while (idx < items.length) {
            const { entry, origIdx } = items[idx++];
            const { page, url, wasLoaded } = entry;
            try {
              const { blob, fromCache } = await this._fetchWithRetry(url, 3, imgEls);
              results[origIdx] = { page, url, blob, ok: true };
              if (wasLoaded || fromCache) cached++; else fetched++;
            } catch (err) {
              results[origIdx] = { page, ok: false };
              failed++;
              console.warn(`[eHunter-js] skipping page ${page.index + 1} after retries`, err);
            }
            done++;
            onProgress?.(done, total, { cached, fetched, failed });
          }
        };
        await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
      };
      const cacheItems   = entries.map((e, i) => ({ entry: e, origIdx: i })).filter(x => x.entry.wasLoaded);
      const networkItems = entries.map((e, i) => ({ entry: e, origIdx: i })).filter(x => !x.entry.wasLoaded);
      await Promise.all([
        runPool(cacheItems,   CACHE_CONCURRENCY),
        runPool(networkItems, NETWORK_CONCURRENCY),
      ]);
      for (const r of results) {
        if (r?.ok) zip.file(`${String(r.page.index + 1).padStart(3, '0')}.${this._ext(r.url, r.blob.type)}`, r.blob);
      }
      const zipBlob = await zip.generateAsync({ type: 'blob', compression: 'STORE' });
      if (zipBlob.size > 22) this._saveBlob(zipBlob, `${titlePrefix}.zip`);
    }
    async _sequential(entries, titlePrefix, onProgress) {
      const imgEls = Array.from(document.querySelectorAll('img.r-page-img'));
      let cached = 0, fetched = 0, failed = 0;
      for (let i = 0; i < entries.length; i++) {
        const { page, url, wasLoaded } = entries[i];
        try {
          const { blob, fromCache } = await this._fetchWithRetry(url, 3, imgEls);
          this._saveBlob(blob, `${titlePrefix}_${String(page.index + 1).padStart(3, '0')}.${this._ext(url, blob.type)}`);
          if (wasLoaded || fromCache) cached++; else fetched++;
          if (!wasLoaded && !fromCache) await new Promise(r => setTimeout(r, 200));
        } catch (err) {
          failed++;
          console.warn(`[eHunter-js] failed page ${page.index + 1} after retries`, err);
        }
        onProgress?.(i + 1, entries.length, { cached, fetched, failed });
      }
    }
    _ext(url, mimeType) {
      const path = (url ?? '').split('?')[0];
      const last = path.split('.').pop().toLowerCase();
      if (DownloadService._KNOWN_EXTS.has(last)) return last;
      return DownloadService._MIME[mimeType] ?? 'jpg';
    }
    _saveBlob(blob, filename) {
      const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: filename });
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 500);
    }
  }
  DownloadService._MIME       = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' };
  DownloadService._KNOWN_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp']);
  function createSharedPageLoader(albumService) {
    const tokens      = new Map();
    const inFlight    = new Map();
    const autoTimers  = new Map();
    const failCounts  = new Map();
    const domHandles  = new Map();
    function registerDom(index, handles) {
      domHandles.set(index, handles);
    }
    function _clearAutoRetry(index) {
      clearTimeout(autoTimers.get(index));
      autoTimers.delete(index);
    }
    function _scheduleAutoRetry(page) {
      _clearAutoRetry(page.index);
      const attempts = failCounts.get(page.index) ?? 0;
      if (attempts >= 3) return;
      failCounts.set(page.index, attempts + 1);
      const delay = CONFIG.autoRetryDelay * (attempts + 1);
      const tid = setTimeout(() => {
        autoTimers.delete(page.index);
        const handles = domHandles.get(page.index);
        if (!handles || page.loaded) return;
        _kick(page, handles.wrapperEl, handles.imgEl, handles.statusEl, { force: true }, handles.onLoaded);
      }, delay);
      autoTimers.set(page.index, tid);
    }
    function loadInto(page, wrapperEl, imgEl, statusEl, opts = {}, onLoaded) {
      registerDom(page.index, { wrapperEl, imgEl, statusEl, onLoaded });
      const wantsHD = opts.highRes ?? false;
      if (page.loaded && !opts.force && !(wantsHD && !page.loadedAsHD)) {
        if (page.imageUrl && imgEl.src !== page.imageUrl) {
          imgEl.src = page.imageUrl;
          imgEl.classList.add('r-page-img--loaded');
          wrapperEl.classList.add('r-page--loaded');
          wrapperEl.classList.remove('r-page--loading', 'r-page--failed');
          wrapperEl.style.aspectRatio = '';
          onLoaded?.();
        } else if (imgEl.src === page.imageUrl) {
          const wrapLoaded = wrapperEl.classList.contains('r-page--loaded');
          const imgLoaded  = imgEl.classList.contains('r-page-img--loaded');
          if (!wrapLoaded || !imgLoaded) {
            wrapperEl.classList.add('r-page--loaded');
            wrapperEl.classList.remove('r-page--loading', 'r-page--failed');
            imgEl.classList.add('r-page-img--loaded');
            if (!wrapLoaded) onLoaded?.();
          }
        }
        return;
      }
      if (inFlight.has(page.index)) {
        if (!opts.force) return;
        inFlight.delete(page.index);
      }
      _kick(page, wrapperEl, imgEl, statusEl, opts, onLoaded);
    }
    function _kick(page, wrapperEl, imgEl, statusEl, opts, onLoaded) {
      if (opts.force) { failCounts.delete(page.index); _clearAutoRetry(page.index); }
      if (opts.force) page.cacheToken = (page.cacheToken ?? 0) + 1;
      const _wantsHD = opts.highRes ?? false;
      if (page.loaded && page.imageUrl && !opts.force && !(_wantsHD && !page.loadedAsHD)) {
        wrapperEl.classList.remove('r-page--loading', 'r-page--failed');
        wrapperEl.classList.add('r-page--loaded');
        wrapperEl.style.aspectRatio = '';
        imgEl.src = page.imageUrl;
        imgEl.classList.add('r-page-img--loaded');
        if (statusEl) statusEl.textContent = '';
        onLoaded?.();
        return;
      }
      const curW = imgEl.naturalWidth, curH = imgEl.naturalHeight;
      if (curW && curH) wrapperEl.style.aspectRatio = `${curW} / ${curH}`;
      wrapperEl.classList.remove('r-page--failed', 'r-page--loaded');
      wrapperEl.classList.add('r-page--loading');
      imgEl.classList.remove('r-page-img--loaded');
      if (statusEl) statusEl.textContent = 'Loading…';
      wrapperEl.dispatchEvent(new CustomEvent('r:reloadstarted', { bubbles: false }));
      page.loaded = false;
      page.imageUrl = null;
      const loadOpts = { highRes: opts.highRes ?? false, priority: 10 };
      const srcPromise = opts.force
        ? albumService.reloadPageSources(page, loadOpts)
        : albumService.getPageSources(page, loadOpts);
      const p = srcPromise
        .then(sources => _attempt(page, wrapperEl, imgEl, statusEl, sources, 0, 1, onLoaded, loadOpts.highRes))
        .catch(err => {
          console.error(`[eHunter-js] source fetch failed p${page.index + 1}`, err);
          wrapperEl.classList.remove('r-page--loading');
          wrapperEl.classList.add('r-page--failed');
          if (statusEl) statusEl.textContent = 'Could not reach any source — click to retry';
          _scheduleAutoRetry(page);
        })
        .finally(() => { if (inFlight.get(page.index) === p) inFlight.delete(page.index); });
      inFlight.set(page.index, p);
    }
    function skipSource(page, wrapperEl, imgEl, statusEl, onLoaded) {
      registerDom(page.index, { wrapperEl, imgEl, statusEl, onLoaded });
      const highRes = !!(page.loadedAsHD);
      const key = `${page.index}:${highRes ? 'hi' : 'lo'}`;
      const cached = albumService.sourceCache?.get(key);
      if (!cached || cached.length <= 1) {
        _kick(page, wrapperEl, imgEl, statusEl, { force: true }, onLoaded);
        return;
      }
      const currentSrcIdx = cached.indexOf(imgEl.src);
      const nextSrcIdx = currentSrcIdx >= 0 ? currentSrcIdx + 1 : 1;
      if (nextSrcIdx >= cached.length) {
        _kick(page, wrapperEl, imgEl, statusEl, { force: true }, onLoaded);
        return;
      }
      if (failCounts.has(page.index)) failCounts.delete(page.index);
      _clearAutoRetry(page.index);
      page.cacheToken = (page.cacheToken ?? 0) + 1;
      page.loaded = false;
      page.imageUrl = null;
      wrapperEl.classList.remove('r-page--failed', 'r-page--loaded');
      wrapperEl.classList.add('r-page--loading');
      imgEl.classList.remove('r-page-img--loaded');
      if (statusEl) statusEl.textContent = 'Trying next source…';
      wrapperEl.dispatchEvent(new CustomEvent('r:reloadstarted', { bubbles: false }));
      const p = Promise.resolve()
        .then(() => _attempt(page, wrapperEl, imgEl, statusEl, cached, nextSrcIdx, 1, onLoaded, highRes))
        .finally(() => { if (inFlight.get(page.index) === p) inFlight.delete(page.index); });
      inFlight.set(page.index, p);
    }
    function _attempt(page, wrapperEl, imgEl, statusEl, sources, srcIdx, retryN, onLoaded, highRes = false) {
      if (!sources?.length || srcIdx >= sources.length) {
        wrapperEl.classList.remove('r-page--loading');
        wrapperEl.classList.add('r-page--failed');
        if (statusEl) statusEl.textContent = 'Every source failed — click to retry';
        _scheduleAutoRetry(page);
        return;
      }
      const token = (tokens.get(page.index) ?? 0) + 1;
      tokens.set(page.index, token);
      const alive = () => tokens.get(page.index) === token;
      let settled = false;
      let elapsed = 0;
      const srcLabel = sources.length > 1 ? ` (src ${srcIdx + 1}/${sources.length})` : '';
      if (statusEl) statusEl.textContent = `Loading…${srcLabel}`;
      let ticker = null;
      const tickerStart = setTimeout(() => {
        if (settled || !alive()) return;
        ticker = setInterval(() => {
          if (settled || !alive()) { clearInterval(ticker); ticker = null; return; }
          elapsed++;
          if (statusEl) statusEl.textContent = `Loading… ${elapsed}s${srcLabel}`;
        }, 1000);
      }, 2000);
      const fail = () => {
        if (settled || !alive()) return;
        settled = true;
        clearTimeout(watchdog);
        clearTimeout(tickerStart);
        if (ticker) { clearInterval(ticker); ticker = null; }
        if (retryN < 2) {
          const delay = 1200 * retryN;
          if (statusEl) statusEl.textContent = `Retrying in ${Math.round(delay/1000)}s…${srcLabel}`;
          setTimeout(() => { if (alive()) _attempt(page, wrapperEl, imgEl, statusEl, sources, srcIdx, retryN + 1, onLoaded, highRes); }, delay);
        } else {
          _attempt(page, wrapperEl, imgEl, statusEl, sources, srcIdx + 1, 1, onLoaded, highRes);
        }
      };
      const watchdog = setTimeout(fail, CONFIG.imageTimeoutMs);
      imgEl.src = sources[srcIdx];
      imgEl.onerror = fail;
      const _hideBlurOnce = (() => {
        let called = false;
        return () => {
          if (called) return;
          called = true;
          wrapperEl.dispatchEvent(new CustomEvent('r:loadstarted', { bubbles: false }));
        };
      })();
      imgEl.addEventListener('loadstart', _hideBlurOnce, { once: true });
      imgEl.addEventListener('progress',  _hideBlurOnce, { once: true });
      imgEl.onload = () => {
        if (settled || !alive()) return;
        settled = true;
        _hideBlurOnce();
        clearTimeout(watchdog);
        clearTimeout(tickerStart);
        if (ticker) { clearInterval(ticker); ticker = null; }
        page.loaded      = true;
        page.imageUrl    = sources[srcIdx];
        page.loadedAsHD  = !!highRes;
        failCounts.delete(page.index);
        _clearAutoRetry(page.index);
        wrapperEl.classList.remove('r-page--loading', 'r-page--failed');
        wrapperEl.classList.add('r-page--loaded');
        wrapperEl.style.aspectRatio = '';
        imgEl.classList.add('r-page-img--loaded');
        onLoaded?.();
      };
    }
    function destroy() {
      autoTimers.forEach(t => clearTimeout(t));
      autoTimers.clear();
      inFlight.clear();
    }
    return { loadInto, skipSource, destroy };
  }
  function createPagePlaceholder(pageIndex, { onOriginal, onRefresh, onOtherSource } = {}) {
    const statusEl = h('div', { class: 'r-placeholder__status' }, 'Waiting…');
    const thumbBg  = h('div', { class: 'r-placeholder__thumb-bg' });
    const el = h('div', { class: 'r-placeholder' }, [
      thumbBg,
      h('div', { class: 'r-placeholder__num' }, String(pageIndex + 1)),
      statusEl,
      h('div', { class: 'r-placeholder__actions' }, [
        h('a', { class: 'r-placeholder__action', onClick: e => { e.stopPropagation(); onOriginal?.(); } }, 'Original'),
        h('a', { class: 'r-placeholder__action', onClick: e => { e.stopPropagation(); onRefresh?.(); } }, 'Refresh'),
        h('a', { class: 'r-placeholder__action', onClick: e => { e.stopPropagation(); onOtherSource?.(); } }, 'Other source'),
      ]),
    ]);
    let _bgResizeObs = null;
    return {
      el, statusEl,
      hideBlur: () => {
        thumbBg.classList.remove('r-placeholder__thumb-bg--visible');
      },
      showBlur: () => {
        if (thumbBg.style.backgroundImage) {
          thumbBg.classList.add('r-placeholder__thumb-bg--visible');
        }
      },
      attachToWrapper: (wrapEl) => {
        wrapEl.addEventListener('r:loadstarted', () => {
          thumbBg.classList.remove('r-placeholder__thumb-bg--visible');
        });
        wrapEl.addEventListener('r:reloadstarted', () => {
          if (thumbBg.style.backgroundImage) {
            thumbBg.classList.add('r-placeholder__thumb-bg--visible');
          }
        });
      },
      setContainerSize: (wrapEl, data, zoom = 1) => {
        if (!wrapEl || !data) return;
        const cropW = (typeof data === 'object' && data.width)  ? data.width  : null;
        const cropH = (typeof data === 'object' && data.height) ? data.height : null;
        if (!cropW || !cropH) return;
        const ratio = cropW / cropH;
        wrapEl.style.aspectRatio = `${ratio}`;
      },
      setThumbBg: data => {
        if (!data) return;
        const url      = typeof data === 'string' ? data : data.url;
        const isSprite = typeof data === 'object' && data.offsetX != null;
        if (!url) return;
        if (thumbBg.dataset.paintedUrl === url) {
          thumbBg.classList.add('r-placeholder__thumb-bg--visible');
          return;
        }
        thumbBg.dataset.paintedUrl = url;
        if (!isSprite) {
          thumbBg.style.backgroundImage    = `url("${url}")`;
          thumbBg.style.backgroundSize     = 'cover';
          thumbBg.style.backgroundPosition = 'center';
          thumbBg.classList.add('r-placeholder__thumb-bg--visible');
          return;
        }
        const probe = new Image();
        probe.onload = () => {
          const spriteW = probe.naturalWidth;
          const spriteH = probe.naturalHeight;
          const cropW   = data.width  || spriteW;
          const cropH   = data.height || spriteH;
          const ox      = data.offsetX ?? 0;
          const oy      = data.offsetY ?? 0;
          const fit = () => {
            const rect = thumbBg.getBoundingClientRect();
            if (!rect.width || !rect.height) return;
            const zoomEl = thumbBg.closest('.r-bookmode');
            const cssZoom = zoomEl
              ? parseFloat(getComputedStyle(zoomEl).getPropertyValue('--r-zoom') || '1') || 1
              : 1;
            const w = rect.width  / cssZoom;
            const h = rect.height / cssZoom;
            const scale  = Math.max(w / cropW, h / cropH);
            const tileW  = cropW * scale;
            const tileH  = cropH * scale;
            const posX = ox * scale - (tileW - w) / 2;
            const posY = oy * scale - (tileH - h) / 2;
            thumbBg.style.backgroundImage    = `url("${url}")`;
            thumbBg.style.backgroundSize     = `${spriteW * scale}px ${spriteH * scale}px`;
            thumbBg.style.backgroundPosition = `${posX}px ${posY}px`;
          };
          fit();
          if (!thumbBg.closest('.r-page--loaded')) {
            thumbBg.classList.add('r-placeholder__thumb-bg--visible');
          }
          if (typeof ResizeObserver !== 'undefined') {
            if (_bgResizeObs) _bgResizeObs.disconnect();
            _bgResizeObs = new ResizeObserver(fit);
            _bgResizeObs.observe(thumbBg);
          }
        };
        probe.onerror = () => {   };
        probe.src = url;
      },
      destroy: () => {
        if (_bgResizeObs) { _bgResizeObs.disconnect(); _bgResizeObs = null; }
      },
    };
  }
  function createToast() {
    const el = h('div', { class: 'r-toast' });
    let hideTimer = null;
    function show(message, { type = 'info', durationMs = 3000 } = {}) {
      el.textContent = message;
      el.className = `r-toast r-toast--${type} r-toast--visible`;
      clearTimeout(hideTimer);
      if (durationMs > 0) hideTimer = setTimeout(() => el.classList.remove('r-toast--visible'), durationMs);
    }
    return { el, show };
  }
  function createDialog() {
    const box = h('div', { class: 'r-dialog' });
    const overlay = h('div', { class: 'r-overlay', style: { display: 'none' } }, [box]);
    overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
    function open(contentEl, title = '') {
      clear(box);
      if (title) box.appendChild(h('div', { class: 'r-dialog__title' }, title));
      box.appendChild(contentEl);
      box.appendChild(h('button', { class: 'r-btn r-dialog__close r-btn--close', onClick: close }, [icon(ICONS.close, 14)]));
      overlay.style.display = 'flex';
    }
    function close() { overlay.style.display = 'none'; }
    return { el: overlay, open, close };
  }
  function createSettingsContent({ state, i18n, onChange }) {
    const langSelect = h(
      'select', { class: 'r-select', onChange: e => onChange({ lang: e.target.value }) },
      ['en', 'zh', 'jp'].map(c => h('option', { value: c, ...(i18n.lang === c ? { selected: '' } : {}) }, c === 'en' ? 'English' : c === 'zh' ? '中文' : '日本語'))
    );
    const switchRow = (label, checked, onCh) => h('label', { class: 'r-switch-row' }, [
      h('span', {}, label),
      h('span', { class: 'r-switch' }, [
        h('input', { type: 'checkbox', ...(checked ? { checked: '' } : {}), onChange: e => onCh(e.target.checked) }),
        h('span', { class: 'r-switch__track' }),
      ]),
    ]);
    const numInput = (key, defaultVal, min, max, labelText, hint) => {
      const inp = h('input', {
        type: 'number', min: String(min), max: String(max), step: '1',
        value: String(state.pref.data[key] ?? defaultVal),
        class: 'r-settings__num-input',
        onChange: e => {
          const v = Math.min(max, Math.max(min, parseInt(e.target.value, 10) || defaultVal));
          e.target.value = String(v);
          onChange({ [key]: v });
        },
      });
      return h('div', {}, [
        h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', marginBottom: hint ? '3px' : '0' } }, [
          h('span', { class: 'r-settings__label', style: { marginBottom: '0' } }, labelText),
          inp,
        ]),
        ...(hint ? [h('div', { class: 'r-settings__hint' }, hint)] : []),
      ]);
    };
    return h('div', { class: 'r-settings' }, [
      h('div', {}, [h('span', { class: 'r-settings__label' }, 'Language'), langSelect]),
      switchRow('Right-to-left reading (manga)', state.rtl,    v => onChange({ rtl: v })),
      ...(state.album.supportsHD !== false ? [
        switchRow('Always load full resolution', state.highRes, v => onChange({ highRes: v })),
        h('div', { class: 'r-settings__hint' }, 'Use the per-page HD button to load just one page at original quality.'),
      ] : [h('div', { class: 'r-settings__hint' }, 'HD/original resolution is not available on this site.')]),
      h('button', { class: 'r-btn', onClick: () => onChange({ zoom: 1 }) }, 'Reset zoom to 100%'),
      h('div', { style: { borderTop: '1px solid var(--r-border)', paddingTop: '12px', marginTop: '2px' } }, [
        h('div', { class: 'r-settings__label', style: { marginBottom: '10px', fontWeight: '600', color: 'var(--r-fg)', opacity: '0.9' } }, 'Preload settings'),
        numInput('initialForward',  CONFIG.defaultInitialForward,  0, 20, 'Initial forward pages',
          'Pages loaded immediately on open (ahead). Default: 3'),
        h('div', { style: { marginTop: '8px' } },
          [numInput('initialBackward', CONFIG.defaultInitialBackward, 0, 10, 'Initial backward pages',
            'Pages loaded immediately on open (behind). Default: 1')]),
        h('div', { style: { marginTop: '8px' } },
          [numInput('preloadForward',  CONFIG.defaultPreloadForward,  1, 50, 'Preload forward window',
            'How many pages ahead to always keep loaded. Default: 15')]),
        h('div', { style: { marginTop: '8px' } },
          [numInput('preloadBackward', CONFIG.defaultPreloadBackward, 0, 20, 'Preload backward window',
            'How many pages behind to keep loaded. Default: 5')]),
        h('div', { class: 'r-settings__hint', style: { marginTop: '6px' } },
          'Changes apply to the next page turn. Settings are saved automatically.'),
      ]),
      h('div', { class: 'r-settings__label' }, 'Keyboard shortcuts'),
      h('div', { class: 'r-settings__shortcut-grid' }, [
        h('div', { class: 'r-settings__key' }, 'T'), h('div', { class: 'r-settings__key-desc' }, 'Toggle thumbnails'),
        h('div', { class: 'r-settings__key' }, 'M'), h('div', { class: 'r-settings__key-desc' }, 'Switch mode'),
        h('div', { class: 'r-settings__key' }, '+'), h('div', { class: 'r-settings__key-desc' }, 'Zoom in'),
        h('div', { class: 'r-settings__key' }, '−'), h('div', { class: 'r-settings__key-desc' }, 'Zoom out'),
        h('div', { class: 'r-settings__key' }, 'Esc'), h('div', { class: 'r-settings__key-desc' }, 'Close reader'),
        h('div', { class: 'r-settings__key' }, '←/→'), h('div', { class: 'r-settings__key-desc' }, 'Previous/next (book mode)'),
      ]),
    ]);
  }
  function createTopBar({ state, i18n, onAction }) {
    const el = h('div', { class: 'r-topbar', style: { position: 'relative' } });
    function btn(labelKey, actionKey, ico, extraClass = '') {
      const b = h('button', { class: `r-btn${extraClass}`, title: labelKey, onClick: () => onAction(actionKey) });
      if (ico) b.appendChild(icon(ICONS[ico], 13));
      b.appendChild(document.createTextNode(' ' + i18n.t(labelKey)));
      return b;
    }
    const pageCounterEl = h('span', { class: 'r-topbar__page' });
    const progressBarEl = h('div',  { class: 'r-progressbar' });
    let _builtForMode    = null;
    let _builtForThumb   = null;
    let _modeBtn  = null;
    let _thumbBtn = null;
    function _updateLive() {
      const total = state.album.pages.length;
      const pct = total ? Math.round(((state.currentPage + 1) / total) * 100) : 0;
      pageCounterEl.textContent = state.isLoadingAlbum ? 'Loading…' : `${state.currentPage + 1} / ${total || '?'}`;
      progressBarEl.style.width = pct + '%';
    }
    function render() {
      const isScroll = state.mode === 'scroll';
      const modeChanged  = state.mode      !== _builtForMode;
      const thumbChanged = state.thumbVisible !== _builtForThumb;
      if (modeChanged || thumbChanged || !_modeBtn) {
        _builtForMode  = state.mode;
        _builtForThumb = state.thumbVisible;
        _modeBtn  = btn(isScroll ? 'bookMode' : 'scrollMode', 'toggle-mode', isScroll ? 'book' : 'scroll');
        _thumbBtn = btn('thumbnails', 'toggle-thumbs', 'thumbs', state.thumbVisible ? ' r-btn--active' : '');
        clear(el);
        el.append(
          h('span', { class: 'r-topbar__title', title: state.album.title }, state.album.title || 'eHunter'),
          h('div', { class: 'r-topbar__sep' }),
          _modeBtn,
          _thumbBtn,
          h('div', { class: 'r-topbar__sep' }),
          btn('zoomOut', 'zoom-out', 'zoomOut'),
          btn('zoomIn', 'zoom-in', 'zoomIn'),
          h('div', { class: 'r-topbar__sep' }),
          btn('download', 'download', 'download'),
          btn('settings', 'settings', 'settings'),
          pageCounterEl,
          h('div', { class: 'r-topbar__sep' }),
          (() => { const b = h('button', { class: 'r-btn r-btn--close', title: 'Close', onClick: () => onAction('close') }); b.appendChild(icon(ICONS.close, 13)); return b; })(),
          progressBarEl
        );
      }
      _updateLive();
    }
    render();
    return { el, render };
  }
  function createThumbnailBar({ state, albumService, onSelect }) {
    const el = h('div', { class: 'r-thumbbar' });
    let wrapByIndex = new Map();
    let activeIndex = -1;
    const elToWrap = new Map();
    function paintThumb(div, data) {
      if (!data) { div.classList.add('r-thumb--missing'); return; }
      const url = typeof data === 'string' ? data : data.url;
      const isSprite = typeof data === 'object' && data.offsetX != null;
      if (!url) { div.classList.add('r-thumb--missing'); return; }
      const probe = new Image();
      probe.onload = () => {
        div.classList.remove('r-thumb--missing');
        if (isSprite) {
          const cropW = data.width  || probe.naturalWidth;
          const cropH = data.height || probe.naturalHeight;
          if (cropW && cropH) div.style.aspectRatio = `${cropW} / ${cropH}`;
          paintSpriteBackground(div, data, probe.naturalWidth, probe.naturalHeight);
        } else {
          if (probe.naturalWidth && probe.naturalHeight) {
            div.style.aspectRatio = `${probe.naturalWidth} / ${probe.naturalHeight}`;
          }
          div.style.backgroundImage = `url("${url}")`;
          div.style.backgroundSize = 'cover';
          div.style.backgroundPosition = 'center';
          div.style.backgroundRepeat = 'no-repeat';
        }
      };
      probe.onerror = () => div.classList.add('r-thumb--missing');
      probe.src = url;
    }
    function resolveAndSet(div, page) {
      thumbObs.unobserve(div);
      const wrap = elToWrap.get(div);
      div.classList.add('r-thumb--shimmer');
      albumService.resolveThumb(page).then(() => {
        div.classList.remove('r-thumb--shimmer');
        const data = page.thumbStyle ? { url: page.thumb, ...page.thumbStyle } : page.thumb;
        if (data) {
          paintThumb(div, data);
          wrap?.classList.remove('r-thumb--missing');
        } else {
          wrap?.classList.add('r-thumb--missing');
          if (wrap) wrap.onclick = e => { e.stopPropagation(); resolveAndSet(div, page); };
        }
      }).catch(() => {
        div.classList.remove('r-thumb--shimmer');
        wrap?.classList.add('r-thumb--missing');
        if (wrap) wrap.onclick = e => { e.stopPropagation(); resolveAndSet(div, page); };
      });
    }
    let thumbObs = new IntersectionObserver(entries => {
      for (const e of entries) {
        if (!e.isIntersecting) continue;
        const idx = Number(e.target.dataset.index);
        const page = state.album.pages[idx];
        if (page) resolveAndSet(e.target, page);
      }
    }, { root: el, rootMargin: '200px 0px' });
    // Divs that need thumbObs.observe() once the bar becomes visible.
    // Cleared (and observed) by startObserving(). Also cleared at the top of
    // _buildDom() so a second album-load never leaks stale entries.
    const pendingObserve = new Set();
    function startObserving() {
      for (const div of pendingObserve) thumbObs.observe(div);
      pendingObserve.clear();
    }
    // Track which album the DOM was last built for so we only rebuild on album change.
    let _builtForPages = null;
    // Build (or rebuild) the full DOM — called only when the page list changes.
    function _buildDom() {
      thumbObs.disconnect();
      elToWrap.forEach((_, div) => div._spriteUnobserve?.());
      pendingObserve.clear();
      elToWrap.clear();
      clear(el);
      wrapByIndex = new Map();
      activeIndex = state.currentPage;
      _builtForPages = state.album.pages;
      state.album.pages.forEach(page => {
        const div = h('div', { class: 'r-thumb', 'data-index': String(page.index) });
        const badge = h('div', { class: 'r-thumb-badge' }, String(page.index + 1));
        const wrap = h('div', { class: `r-thumb-wrap${page.index === state.currentPage ? ' r-thumb-wrap--active' : ''}`, onClick: () => onSelect(page.index) }, [div, badge]);
        elToWrap.set(div, wrap);
        if (page.thumbStyle || page.thumb) {
          paintThumb(div, page.thumbStyle ? { url: page.thumb, ...page.thumbStyle } : page.thumb);
          wrap.classList.remove('r-thumb--missing');
        } else {
          // Defer observation: if the bar is already visible, observe immediately;
          // otherwise queue for startObserving() which fires on first show.
          pendingObserve.add(div);
        }
        wrapByIndex.set(page.index, wrap);
        el.appendChild(wrap);
      });
      // If bar is visible right now, kick off observation immediately.
      if (state.thumbVisible) requestAnimationFrame(() => startObserving());
    }
    // Lightweight update: only toggle visibility and sync the active indicator.
    // Does NOT touch the DOM structure — avoids rebuilding hundreds of nodes on
    // every page turn, mode switch, or zoom change.
    function render() {
      const pagesChanged = state.album.pages !== _builtForPages;
      if (pagesChanged) _buildDom();
      const wasVisible = el.style.display !== 'none';
      el.style.display = state.thumbVisible ? 'flex' : 'none';
      // If the bar just became visible and there are pending divs, start observing.
      if (state.thumbVisible && !wasVisible && pendingObserve.size) {
        requestAnimationFrame(() => startObserving());
      }
      updateActive(state.currentPage);
    }
    function updateActive(index) {
      if (index === activeIndex) return;
      wrapByIndex.get(activeIndex)?.classList.remove('r-thumb-wrap--active');
      wrapByIndex.get(index)?.classList.add('r-thumb-wrap--active');
      activeIndex = index;
      wrapByIndex.get(index)?.scrollIntoView({ block: 'nearest' });
    }
    render();
    return {
      el, render, updateActive,
      onShow: startObserving,
      destroy: () => { thumbObs.disconnect(); elToWrap.forEach((_, div) => div._spriteUnobserve?.()); pendingObserve.clear(); elToWrap.clear(); },
    };
  }
  function computeMaxZoom(modeEl, mode) {
    if (mode !== 'book' || !modeEl) return Infinity;
    const container = modeEl;
    const availW = container.clientWidth || window.innerWidth;
    const availH = container.clientHeight || window.innerHeight;
    const imgs = Array.from(container.querySelectorAll('.r-page-img--book'));
    if (!imgs.length) return Infinity;
    let totalW = 0;
    let anyMeasured = false;
    for (const img of imgs) {
      const nw = img.naturalWidth;
      const nh = img.naturalHeight;
      if (!nw || !nh) {
        const curZoomStr = container.style.getPropertyValue('--r-zoom') || '1';
        const curZoom = parseFloat(curZoomStr) || 1;
        const renderedW = img.offsetWidth;
        totalW += renderedW / curZoom;
        anyMeasured = true;
        continue;
      }
      anyMeasured = true;
      const renderedH = Math.min(nh, 0.92 * availH);
      const renderedW = nw * (renderedH / nh);
      totalW += renderedW;
    }
    if (!anyMeasured || totalW <= 0) return Infinity;
    totalW += (imgs.length - 1) * 4;
    const max = availW / totalW;
    return Math.max(0.25, Math.round(max * 100) / 100);
  }
  function createScrollMode({ state, albumService, loader, preloadEngine }) {
    const el = h('div', { class: 'r-scrollmode' });
    const pageEls = new Map();
    const placeholderMap = new Map();
    let _lastScrollY = 0;
    let _barHidden = false;
    let _suppressScrollUntil = 0;
    function _onScroll() {
      if (performance.now() < _suppressScrollUntil) {
        _lastScrollY = el.scrollTop;
        return;
      }
      const root = document.getElementById('ehunter-js-root');
      if (!root) return;
      const sy = el.scrollTop;
      const delta = sy - _lastScrollY;
      _lastScrollY = sy;
      if (sy < 80) {
        if (_barHidden) { _barHidden = false; root.classList.remove('r-bar-hidden'); }
        return;
      }
      if (delta > 4 && !_barHidden) { _barHidden = true; root.classList.add('r-bar-hidden'); }
      if (delta < -4 && _barHidden)  { _barHidden = false; root.classList.remove('r-bar-hidden'); }
    }
    el.addEventListener('scroll', _onScroll, { passive: true });
    function _removeScrollListener() { el.removeEventListener('scroll', _onScroll); }
    function applyZoom() {
      el.style.setProperty('--r-zoom', state.zoom);
      _suppressScrollUntil = performance.now() + 300;
      _lastScrollY = el.scrollTop;
    }
    function showBar() {
      const root = document.getElementById('ehunter-js-root');
      if (root && _barHidden) { _barHidden = false; root.classList.remove('r-bar-hidden'); }
    }
    const loadObserver = new IntersectionObserver(entries => {
      for (const e of entries) {
        if (e.isIntersecting) loadPage(Number(e.target.dataset.index));
      }
    }, { root: el, rootMargin: '400px 0px', threshold: 0 });
    const visibleRatios = new Map();
    const pageObserver = new IntersectionObserver(entries => {
      for (const e of entries) {
        const idx = Number(e.target.dataset.index);
        if (e.isIntersecting) visibleRatios.set(idx, e.intersectionRatio);
        else visibleRatios.delete(idx);
      }
      if (visibleRatios.size) {
        const topmost = Math.min(...visibleRatios.keys());
        state.setCurrentPage(topmost);
        preloadEngine?.onPageVisible(state.album.pages, topmost);
      }
    }, { root: el, rootMargin: '0px', threshold: [0, 0.1, 0.5, 1.0] });
    const observer = {
      observe:    el => { loadObserver.observe(el); pageObserver.observe(el); },
      disconnect: ()  => { loadObserver.disconnect(); pageObserver.disconnect(); },
    };
    function loadPage(index, opts = {}) {
      const page = state.album.pages[index];
      const refs = pageEls.get(index);
      const wrap   = refs?.wrap;
      const img    = refs?.img;
      const status = refs?.status;
      if (!page || !img || !wrap) return;
      const ph = placeholderMap.get(index);
      if (ph) {
        const thumbData = () => page.thumbStyle ? { url: page.thumb, ...page.thumbStyle } : page.thumb;
        const feedThumbBg = () => {
          const data = thumbData();
          ph.setThumbBg(data);
          ph.setContainerSize(wrap, data, state.zoom);
          if (!opts.force) ph.showBlur();
        };
        if (page.thumb) feedThumbBg();
        else albumService.resolveThumb(page).then(feedThumbBg).catch(() => {});
      }
      const fullOpts = { ...opts, highRes: opts.highRes ?? state.isHighResPage(index) };
      loader.loadInto(page, wrap, img, status, fullOpts, () => {
        ph?.hideBlur();
        preloadEngine?.onPageVisible(state.album.pages, index);
      });
    }
    function render() {
      observer.disconnect();
      placeholderMap.forEach(ph => ph.destroy());
      clear(el);
      pageEls.clear();
      placeholderMap.clear();
      applyZoom();
      state.album.pages.forEach(page => {
        const img = h('img', { class: 'r-page-img', alt: `page-${page.index + 1}` });
        const hdBtn = state.album.supportsHD ? h('button', {
          class: `r-hd-btn${state.isHighResPage(page.index) ? ' r-hd-btn--active' : ''}`,
          title: 'Toggle original resolution',
          onClick: e => { e.stopPropagation(); const isHd = state.highResPages.has(page.index); state.setHighResForPage(page.index, !isHd); hdBtn.classList.toggle('r-hd-btn--active', !isHd); loadPage(page.index, { force: true, highRes: !isHd }); },
        }, 'HD') : null;
        const ph = createPagePlaceholder(page.index, {
          onOriginal:    () => { state.setHighResForPage(page.index, true); loadPage(page.index, { force: true, highRes: true }); },
          onRefresh:     () => loadPage(page.index, { force: true }),
          onOtherSource: () => {
            const refs = pageEls.get(page.index);
            if (refs?.wrap && refs?.img) loader.skipSource(page, refs.wrap, refs.img, refs.status);
          },
        });
        placeholderMap.set(page.index, ph);
        const wrap = h('div', {
          class: 'r-page',
          'data-index': String(page.index),
          onClick: () => { if (wrap.classList.contains('r-page--failed')) loadPage(page.index, { force: true }); },
        }, [ph.el, img, hdBtn]);
        ph.attachToWrapper(wrap);
        pageEls.set(page.index, { wrap, img, status: ph.statusEl });
        el.appendChild(wrap);
      });
      pageEls.forEach(refs => observer.observe(refs.wrap));
      requestAnimationFrame(() => {
        const { initialForward: initFwd = CONFIG.defaultInitialForward,
                initialBackward: initBwd = CONFIG.defaultInitialBackward } = state.pref?.data ?? {};
        const startPage = state.currentPage;
        for (let i = Math.max(0, startPage - initBwd); i <= Math.min(state.album.pages.length - 1, startPage + initFwd - 1); i++) {
          loadPage(i);
        }
      });
    }
    function scrollToPage(index) { pageEls.get(index)?.wrap?.scrollIntoView({ block: 'start' }); }
    function applyZoomOnly() { applyZoom(); }
    function reloadAll() {
      pageEls.forEach((refs, i) => {
        const page = state.album.pages[i];
        if (!page) return;
        if (page.loaded || refs.wrap.classList.contains('r-page--loading') || refs.wrap.classList.contains('r-page--failed')) {
          preloadEngine?.cancelPage(i);
          loadPage(i, { force: true });
        }
      });
    }
    render();
    return { el, render, scrollToPage, reloadAll, applyZoom: applyZoomOnly, showBar, destroy: () => {
      _removeScrollListener();
      observer.disconnect();
      placeholderMap.forEach(ph => ph.destroy());
      showBar();
    } };
  }
  function createBookMode({ state, albumService, loader, preloadEngine }) {
    const el = h('div', { class: 'r-bookmode' });
    let spreads = [];
    let spreadIndex = 0;
    function recompute() {
      spreads = computeBookSpreads(state.album.pages.length, state.rtl);
      spreadIndex = Math.max(0, spreads.findIndex(s => s.includes(state.currentPage)));
    }
    function loadPageAt(idx) {
      const page = state.album.pages[idx];
      const handles = currentHandles.get(idx);
      const wrap   = handles?.wrap;
      const img    = handles?.img;
      const status = handles?.status;
      if (!page || !wrap || !img) return;
      const ph = placeholderMap.get(idx);
      ph?.showBlur();
      loader.loadInto(page, wrap, img, status, { highRes: state.isHighResPage(idx), force: !page.loaded }, () => {
        ph?.hideBlur();
        applyZoom();
      });
    }
    function skipAt(idx) {
      const page = state.album.pages[idx];
      const handles = currentHandles.get(idx);
      const wrap   = handles?.wrap;
      const img    = handles?.img;
      const status = handles?.status;
      if (!page || !wrap || !img) return;
      loader.skipSource(page, wrap, img, status);
    }
    function applyZoom() {
      const maxZ = computeMaxZoom(el, 'book');
      if (state.zoom > maxZ) {
        state.zoom = maxZ;
        state.pref.save({ zoom: maxZ });
      }
      el.style.setProperty('--r-zoom', state.zoom);
    }
    function buildWrap(idx) {
      const page = state.album.pages[idx];
      const img = h('img', { class: 'r-page-img r-page-img--book', alt: `page-${idx + 1}` });
      let wrap;
      let status;
      const hdBtn = state.album.supportsHD ? h('button', {
        class: `r-hd-btn${state.isHighResPage(idx) ? ' r-hd-btn--active' : ''}`,
        title: 'Toggle original resolution',
        onClick: e => { e.stopPropagation(); const isHd = state.highResPages.has(idx); state.setHighResForPage(idx, !isHd); hdBtn.classList.toggle('r-hd-btn--active', !isHd); loader.loadInto(page, wrap, img, status, { force: true, highRes: !isHd }); },
      }, 'HD') : null;
      const ph = createPagePlaceholder(idx, {
        onOriginal:    () => { state.setHighResForPage(idx, true); loader.loadInto(page, wrap, img, status, { force: true, highRes: true }); },
        onRefresh:     () => loader.loadInto(page, wrap, img, status, { force: true, highRes: state.isHighResPage(idx) }),
        onOtherSource: () => skipAt(idx),
      });
      status = ph.statusEl;
      const feedThumbBg = () => {
        const data = page.thumbStyle ? { url: page.thumb, ...page.thumbStyle } : page.thumb;
        ph.setThumbBg(data);
        ph.setContainerSize(wrap, data, state.zoom);
        ph.showBlur();
      };
      if (page.thumb) feedThumbBg();
      else albumService.resolveThumb(page).then(feedThumbBg).catch(() => {});
      wrap = h('div', { class: 'r-page r-page--book', 'data-index': String(idx), onClick: () => { if (wrap.classList.contains('r-page--failed')) loadPageAt(idx); } }, [ph.el, img, hdBtn]);
      ph.attachToWrapper(wrap);
      el.appendChild(wrap);
      placeholderMap.set(idx, ph);
      return { wrap, img, status, ph };
    }
    const currentHandles = new Map();
    const placeholderMap = new Map();
    function renderSpread() {
      placeholderMap.forEach(ph => ph.destroy());
      clear(el);
      currentHandles.clear();
      placeholderMap.clear();
      applyZoom();
      const indices = spreads[spreadIndex] ?? [];
      indices.forEach(idx => {
        const handles = buildWrap(idx);
        currentHandles.set(idx, handles);
        loadPageAt(idx);
      });
      el.appendChild(h('div', { class: 'r-clickzone r-clickzone--left',  onClick: () => state.rtl ? _nav.next() : _nav.prev() }));
      el.appendChild(h('div', { class: 'r-clickzone r-clickzone--right', onClick: () => state.rtl ? _nav.prev() : _nav.next() }));
      state.setCurrentPage(indices[0] ?? state.currentPage);
      preloadEngine?.onPageVisible(state.album.pages, state.currentPage);
    }
    function next() { if (spreadIndex < spreads.length - 1) { spreadIndex++; renderSpread(); } }
    function prev() { if (spreadIndex > 0) { spreadIndex--; renderSpread(); } }
    function onKey(e) {
      if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return;
      if (e.key === 'ArrowLeft')  { e.preventDefault(); e.stopPropagation(); state.rtl ? next() : prev(); }
      if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); state.rtl ? prev() : next(); }
    }
    function reloadAll() {
      (spreads[spreadIndex] ?? []).forEach(idx => {
        const handles = currentHandles.get(idx);
        const wrap   = handles?.wrap;
        const img    = handles?.img;
        if (!wrap || !img) return;
        loader.loadInto(state.album.pages[idx], wrap, img, handles?.status ?? null, { force: true, highRes: state.isHighResPage(idx) });
      });
    }
    // _nav is a mutable dispatch table so renderSpread's click-zones always call
    // the current (possibly wrapped) next/prev even though closures are formed first.
    const _nav = { next: () => {}, prev: () => {} };
    recompute();
    renderSpread();

    // ---- topbar auto-hide for book mode ----
    // Mirror of scroll mode's scroll-driven hide: hide the bar after a page turn
    // (key or click-zone click), show it again on mouse movement, with a longer
    // mouse-idle timer as a secondary fallback. Hovering the topbar itself pauses
    // the timer so users can aim at buttons without the bar vanishing mid-click.
    const BOOK_TURN_MS  = 1800;  // delay after a page turn before hiding
    const BOOK_MOUSE_MS = 3500;  // delay after last mouse movement before hiding
    let _idleTimer = null;
    let _bookBarHidden = false;
    function _getRoot() { return document.getElementById('ehunter-js-root'); }
    function _showBar() {
      const root = _getRoot();
      if (root && _bookBarHidden) { _bookBarHidden = false; root.classList.remove('r-bar-hidden'); }
    }
    function _hideBar() {
      const root = _getRoot();
      if (root && !_bookBarHidden) { _bookBarHidden = true; root.classList.add('r-bar-hidden'); }
    }
    // After a page turn: bar stays visible briefly, then fades — feels like "focus on content"
    function _armTurnTimer() {
      clearTimeout(_idleTimer);
      _idleTimer = setTimeout(_hideBar, BOOK_TURN_MS);
    }
    // On mouse movement: show bar, reset to longer idle window
    function _onMouseMove() {
      _showBar();
      clearTimeout(_idleTimer);
      _idleTimer = setTimeout(_hideBar, BOOK_MOUSE_MS);
    }
    // Hovering the topbar itself: pause the timer so the bar doesn't vanish mid-aim
    function _onTopbarEnter() { clearTimeout(_idleTimer); }
    function _onTopbarLeave() { _idleTimer = setTimeout(_hideBar, BOOK_MOUSE_MS); }

    // Wrap next/prev so every page turn arms the short hide timer
    const _rawNext = next, _rawPrev = prev;
    function next() { _rawNext(); _showBar(); _armTurnTimer(); }
    function prev() { _rawPrev(); _showBar(); _armTurnTimer(); }

    // Click-zones call next/prev directly via closure so the wrappers above handle them.
    // Re-wire the left/right arrow keys through the wrapped versions.
    function onKeyNav(e) {
      if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return;
      if (e.key === 'ArrowLeft')  { e.preventDefault(); e.stopPropagation(); state.rtl ? next() : prev(); }
      if (e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); state.rtl ? prev() : next(); }
    }
    window.addEventListener('keydown', onKeyNav, true);

    el.addEventListener('mousemove', _onMouseMove, { passive: true });
    const _topbarEl = document.querySelector('.r-topbar');
    _topbarEl?.addEventListener('mouseenter', _onTopbarEnter);
    _topbarEl?.addEventListener('mouseleave', _onTopbarLeave);

    // Point _nav at the wrapped versions so click-zones benefit from the hide-on-turn logic
    _nav.next = next;
    _nav.prev = prev;

    // Start with bar visible; arm the mouse-idle timer
    _showBar();
    _idleTimer = setTimeout(_hideBar, BOOK_MOUSE_MS);

    return {
      el,
      render: () => { recompute(); renderSpread(); },
      applyZoom,
      next, prev, reloadAll,
      destroy: () => {
        window.removeEventListener('keydown', onKeyNav, true);
        clearTimeout(_idleTimer);
        _topbarEl?.removeEventListener('mouseenter', _onTopbarEnter);
        _topbarEl?.removeEventListener('mouseleave', _onTopbarLeave);
        const root = _getRoot();
        if (root) { _bookBarHidden = false; root.classList.remove('r-bar-hidden'); }
      },
    };
  }
  async function mountApp({ platformService, meta, rootId = CONFIG.rootId }) {
    const bus = new EventBus();
    const pref = new LayoutPreference();
    const i18n = new I18n(pref.data.lang);
    const state = new AppState(bus, pref);
    const albumService = new AlbumService(platformService);
    const downloadService = new DownloadService();
    const album = await albumService.loadAlbum(meta);
    state.setAlbum(album);
    state.setLoadingAlbum(false);
    const sharedLoader = createSharedPageLoader(albumService);
    const preloadEngine = createPreloadEngine(albumService, pref, i => state.isHighResPage(i));
    preloadEngine.onAlbumReady(album.pages, 0);
    const root = h('div', { id: rootId });
    document.documentElement.appendChild(root);
    const dialog  = createDialog();
    const toast   = createToast();
    const topBar  = createTopBar({ state, i18n, onAction });
    const thumbBar = createThumbnailBar({ state, albumService, onSelect: goToPage });
    const body    = h('div', { class: 'r-body' });
    root.append(topBar.el, body, dialog.el, toast.el);
    body.append(thumbBar.el);
    let modeComp = mountMode(state.mode);
    modeComp.applyZoom?.();
    function mountMode(mode) {
      body.querySelector('.r-scrollmode, .r-bookmode')?.remove();
      const comp = mode === 'book'
        ? createBookMode({ state, albumService, loader: sharedLoader, preloadEngine })
        : createScrollMode({ state, albumService, loader: sharedLoader, preloadEngine });
      body.appendChild(comp.el);
      return comp;
    }
    function goToPage(index) {
      state.setCurrentPage(index);
      if (state.mode === 'scroll') modeComp.scrollToPage(index);
      else modeComp.render();
    }
    function onAction(action) {
      const root = document.getElementById('ehunter-js-root');
      root?.classList.remove('r-bar-hidden');
      switch (action) {
        case 'toggle-mode':
          modeComp.destroy?.();
          state.setMode(state.mode === 'scroll' ? 'book' : 'scroll');
          modeComp = mountMode(state.mode);
          modeComp.applyZoom?.();
          break;
        case 'toggle-thumbs':
          state.toggleThumbnails();
          if (state.thumbVisible) requestAnimationFrame(() => thumbBar.onShow());
          break;
        case 'zoom-in': {
          const maxZ = state.mode === 'book' ? computeMaxZoom(modeComp.el, 'book') : Infinity;
          state.setZoom(state.zoom + 0.05, maxZ);
          modeComp.applyZoom?.();
          break;
        }
        case 'zoom-out':
          state.setZoom(state.zoom - 0.05);
          modeComp.applyZoom?.();
          break;
        case 'download': {
          const total = state.album.pages.length;
          toast.show(`Preparing download… 0 / ${total}`, { durationMs: 0 });
          downloadService.downloadAll(
            state.album.pages,
            page => albumService.getPageSources(page, { highRes: state.isHighResPage(page.index) }).then(s => s[0] ?? null),
            state.album.title || 'album',
            { onProgress: (done, _total, { cached, fetched, failed }) => {
                const parts = [`${done} / ${total}`];
                if (cached)  parts.push(`${cached} cached`);
                if (fetched) parts.push(`${fetched} fetched`);
                if (failed)  parts.push(`${failed} failed`);
                toast.show(`Downloading…  ${parts.join('  ·  ')}`, { durationMs: 0 });
              }
            }
          )
          .then(() => toast.show('Download complete ✓', { type: 'success', durationMs: 5000 }))
          .catch(err => { console.error(err); toast.show('Download failed — see console', { type: 'error' }); });
          break;
        }
        case 'settings': {
          const content = createSettingsContent({ state, i18n, onChange: patch => {
            if (patch.lang)        { i18n.setLang(patch.lang); pref.save({ lang: patch.lang }); topBar.render(); }
            if (patch.rtl != null) { state.rtl = patch.rtl;   pref.save({ rtl: patch.rtl });   modeComp.render?.(); }
            if (patch.zoom != null)    { const maxZ = state.mode === 'book' ? computeMaxZoom(modeComp.el, 'book') : Infinity; state.setZoom(patch.zoom, maxZ); modeComp.applyZoom?.(); }
            if (patch.highRes != null) { state.setHighRes(patch.highRes); toast.show(patch.highRes ? 'Full resolution enabled' : 'Switched to compressed images', { durationMs: 2500 }); modeComp.reloadAll?.(); }
            const preloadKeys = ['initialForward', 'initialBackward', 'preloadForward', 'preloadBackward'];
            const preloadPatch = {};
            let hasPreload = false;
            for (const k of preloadKeys) { if (patch[k] != null) { preloadPatch[k] = patch[k]; hasPreload = true; } }
            if (hasPreload) pref.save(preloadPatch);
          }});
          dialog.open(content, i18n.t('settings'));
          break;
        }
        case 'close':
          root.remove();
          modeComp.destroy?.();
          sharedLoader.destroy?.();
          window.removeEventListener('keydown', onGlobalKey);
          document.documentElement.style.overflow = '';
          document.body.style.overflow = '';
          break;
      }
    }
    let lastPages = state.album.pages;
    let lastThumbVisible = state.thumbVisible;
    bus.on('state:changed', () => {
      topBar.render();
      if (state.album.pages !== lastPages || state.thumbVisible !== lastThumbVisible) {
        thumbBar.render();
        lastPages = state.album.pages;
        lastThumbVisible = state.thumbVisible;
      } else {
        thumbBar.updateActive(state.currentPage);
      }
    });
    function onGlobalKey(e) {
      if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return;
      switch (e.key) {
        case 't': case 'T': onAction('toggle-thumbs'); break;
        case 'm': case 'M': onAction('toggle-mode');   break;
        case '+': case '=': e.preventDefault(); onAction('zoom-in');  break;
        case '-': case '_': e.preventDefault(); onAction('zoom-out'); break;
        case 'Escape':      onAction('close');          break;
      }
    }
    window.addEventListener('keydown', onGlobalKey);
    return { root, state, bus };
  }
  function showBootOverlay() {
    injectStyles();
    const labelEl = h('div', { class: 'r-boot__label' }, 'Loading gallery…');
    const el = h('div', { class: 'r-boot', id: 'ehunter-js-boot' }, [
      h('div', { class: 'r-boot__ring' }),
      labelEl,
    ]);
    document.documentElement.appendChild(el);
    return {
      remove: () => el.remove(),
      fail: msg => {
        el.querySelector('.r-boot__ring')?.remove();
        labelEl.textContent = msg;
        el.classList.add('r-boot--error');
        el.appendChild(h('button', { class: 'r-btn', style: { marginTop: '14px' }, onClick: () => el.remove() }, 'Dismiss'));
      },
    };
  }
  const MAX_GRID_PAGES = 40;
  function extractThumbData(anchor) {
    let width = null, height = null, bgUrl = null, offsetX = null, offsetY = null;
    const d = anchor.querySelector('div');
    if (d) {
      const cs = d.style;
      const w = parseInt(cs.width, 10);  if (!Number.isNaN(w)) width = w;
      const hv = parseInt(cs.height, 10); if (!Number.isNaN(hv)) height = hv;
      if (cs.backgroundImage && cs.backgroundImage !== 'none') {
        const m = cs.backgroundImage.match(/url\((['"]?)(.*?)\1\)/i);
        if (m) {
          bgUrl = m[2];
          const [xStr, yStr] = (cs.backgroundPosition || '0px 0px').trim().split(/\s+/);
          offsetX = parseFloat(xStr) || 0;
          offsetY = parseFloat(yStr) || 0;
        }
      }
    }
    if (bgUrl) return { url: bgUrl, offsetX, offsetY, width, height };
    // Fallback: regex over the raw HTML text, in case anchor came from a context where
    // .style wasn't populated (e.g. a plain string was hand-parsed some other way).
    const html = anchor.innerHTML;
    const sizeMatch = html.match(/width:\s*(\d+)px[\s;]+height:\s*(\d+)px/i)
                    || html.match(/height:\s*(\d+)px[\s;]+width:\s*(\d+)px/i);
    let rWidth = null, rHeight = null;
    if (sizeMatch) {
      const isHeightFirst = /^height/i.test(sizeMatch[0]);
      rWidth = parseInt(isHeightFirst ? sizeMatch[2] : sizeMatch[1], 10);
      rHeight = parseInt(isHeightFirst ? sizeMatch[1] : sizeMatch[2], 10);
    }
    const bgMatch = html.match(/background(?:-image)?:\s*(?:transparent\s+)?url\(([^)]+)\)\s*(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)(?:px)?\s+no-repeat/i);
    if (bgMatch) {
      return { url: bgMatch[1], offsetX: parseFloat(bgMatch[2]), offsetY: parseFloat(bgMatch[3]), width: rWidth, height: rHeight };
    }
    const bgImgMatch = html.match(/background-image:\s*url\((['"]?)([^'")\s]+)\1\)/i);
    if (bgImgMatch && bgImgMatch[2]) return { url: bgImgMatch[2], offsetX: null, offsetY: null, width: rWidth, height: rHeight };
    // Last resort: look for the actual thumbnail <img> that isn't a navigation icon.
    // EH nav icons live at ehgt.org/g/*.png or *.gif — skip those.
    const img = anchor.querySelector('img');
    if (img) {
      const src = img.getAttribute('src') || img.getAttribute('data-src') || '';
      if (src && !/ehgt\.org\/g\/[^/]+\.(png|gif)$/i.test(src)) return { url: src, offsetX: null, offsetY: null, width: rWidth, height: rHeight };
    }
    return null;
  }
  // Paints one sprite crop directly as this element's own background — no extra child
  // elements, no transforms, nothing that can drift out of sync with the container.
  // Earlier versions resized/repositioned a separate tile element and scaled it with a CSS
  // transform to "cover" the container; that kept breaking because (a) any later inline-style
  // write on the tile (even `cssText =`) silently wiped its position, and (b) the container's
  // own measured size sometimes turned out to depend on the very tile box we'd just resized,
  // so the "fit" math was measuring a target that had already been corrupted by the same call.
  //
  // Instead: scale the ENTIRE sprite sheet via background-size (using its real loaded pixel
  // size, spriteW/spriteH), and shift it via background-position (scaling the crop's raw
  // offset by the same factor) so exactly this one crop — scaled to "cover" the container,
  // centered, cropping whichever axis overhangs — fills the element. Recomputed from the
  // element's actual getBoundingClientRect() every time, so it's correct regardless of when
  // or how the element ends up sized.
  const spriteResizeObs = typeof ResizeObserver !== 'undefined'
    ? new ResizeObserver(entries => { for (const e of entries) e.target._spriteFit?.(); })
    : null;
  function paintSpriteBackground(el, data, spriteW, spriteH) {
    const { url, offsetX, offsetY, width, height } = data;
    const w = width || 84, hgt = height || 126;
    el._spriteFit = () => {
      const rect = el.getBoundingClientRect();
      if (!rect.width || !rect.height) return; // resized to 0 (e.g. hidden) — retry via ResizeObserver
      const scale = Math.max(rect.width / w, rect.height / hgt); // "cover": fill box, crop overhang
      const tileW = w * scale, tileH = hgt * scale;
      const posX = offsetX * scale - (tileW - rect.width) / 2;   // center whichever axis overhangs
      const posY = offsetY * scale - (tileH - rect.height) / 2;
      el.style.backgroundImage = `url("${url}")`;
      el.style.backgroundRepeat = 'no-repeat';
      el.style.backgroundSize = `${spriteW * scale}px ${spriteH * scale}px`;
      el.style.backgroundPosition = `${posX}px ${posY}px`;
    };
    el._spriteFit();
    // Unobserve first in case this element was already being observed (e.g. thumb
    // data resolved after a repaint) — prevents accumulating duplicate observations
    // on the global singleton spriteResizeObs, which would otherwise grow unboundedly.
    spriteResizeObs?.unobserve(el);
    spriteResizeObs?.observe(el);
    // Store a cleanup handle on the element so callers can unobserve it on removal.
    el._spriteUnobserve = () => spriteResizeObs?.unobserve(el);
  }
  // Legacy shim for any caller that only wants the plain URL.
  function extractThumbUrl(anchor) {
    return extractThumbData(anchor)?.url ?? null;
  }
  function createEhAdapter() {
    return {
      name: 'eh',
      _grid: null,
      getGalleryId() {
        const m = location.pathname.match(/\/g\/(\d+)\/(\w+)/);
        return m ? { gid: m[1], token: m[2] } : null;
      },
      _resolveGalleryUrl(doc) {
        const direct = doc.querySelector('a[href*="/g/"]');
        if (direct) return direct.href;
        return Array.from(doc.querySelectorAll('a')).find(a => /\/g\/\d+\/\w+/.test(a.getAttribute('href') || ''))?.href ?? null;
      },
      _extractTotalPages(doc) {
        const m = (doc.querySelector('.sn')?.textContent ?? '').match(/(\d+)\s*\/\s*(\d+)/);
        return m ? parseInt(m[2], 10) : null;
      },
      async _fetchGridPageAnchors(p) {
        const base = this._grid.galleryUrl;
        const url = p === 0 ? base : `${base}${base.includes('?') ? '&' : '?'}p=${p}`;
        const doc = await withRetry(() => fetchDoc(url));
        return { doc, anchors: Array.from(doc.querySelectorAll('#gdt a')).filter(a => /\/s\//.test(a.getAttribute('href') || '')) };
      },
      async _fillGridUpTo(target) {
        const g = this._grid;
        while (g.links.length <= target && !g.exhausted && g.nextPage < MAX_GRID_PAGES) {
          const { anchors } = await this._fetchGridPageAnchors(g.nextPage);
          if (!anchors.length) { g.exhausted = true; break; }
          for (const a of anchors) { g.links.push(a.href); g.thumbs.push(extractThumbData(a)); }
          g.nextPage++;
          if (g.links.length <= target) await politeDelay(300);
        }
      },
      async _ensureGridUpTo(index) {
        const g = this._grid;
        const prevSettled = (g._chain ?? Promise.resolve()).catch(() => {});
        const task = prevSettled.then(() => this._fillGridUpTo(index));
        g._chain = task.catch(() => {});
        await task;
        if (!g.exhausted && !g._lookaheadQueued) {
          g._lookaheadQueued = true;
          const look = g._chain.then(() => this._fillGridUpTo(index + CONFIG.gridLookahead));
          g._chain = look.catch(() => {});
          look.catch(() => {}).finally(() => { g._lookaheadQueued = false; });
        }
      },
      async fetchMeta() {
        const readerDoc = await withRetry(() => fetchDoc(location.href));
        const galleryUrl = this._resolveGalleryUrl(readerDoc) ?? location.href;
        const expectedTotal = this._extractTotalPages(readerDoc);
        this._grid = { galleryUrl, links: [], thumbs: [], nextPage: 0, exhausted: false };
        const { doc: firstDoc, anchors: first } = await this._fetchGridPageAnchors(0);
        for (const a of first) { this._grid.links.push(a.href); this._grid.thumbs.push(extractThumbData(a)); }
        this._grid.nextPage = 1;
        if (!first.length) this._grid.exhausted = true;
        const title = firstDoc.querySelector('#gn')?.textContent?.trim() ?? readerDoc.querySelector('h1')?.textContent?.trim() ?? document.title;
        return {
          title,
          pageCount: expectedTotal ?? this._grid.links.length,
          getPageLink:  async i => { await this._ensureGridUpTo(i); return this._grid.links[i] ?? null; },
          getThumbLink: async i => { await this._ensureGridUpTo(i); return this._grid.thumbs[i] ?? null; }, // {url, style} | null
        };
      },
      async fetchPageImageSources(page, pageUrl, { highRes = false } = {}) {
        const rawHtml = await withRetry(() => fetchText(pageUrl));
        const imgMatch = rawHtml.match(/id="img"\s+src="([^"]+)"/);
        const normal = imgMatch ? imgMatch[1] : null;
        let original = null;
        if (highRes) {
          try {
            const parser = new DOMParser();
            const doc = parser.parseFromString(rawHtml.replace(/\bsrc=/g, 'x-src='), 'text/html');
            const i6Kids = doc.querySelector('#i6')?.children;
            if (i6Kids?.length) {
              original = i6Kids[i6Kids.length - 1].children[1]?.getAttribute('href') ?? null;
            }
            if (!original) {
              const dlMatch = rawHtml.match(/href="(https?:\/\/[^"]+)"[^>]*>\s*Download original/i);
              if (dlMatch) original = dlMatch[1];
            }
            // Final fallback: nl= source reassignment
            if (!original) {
              const onclick = doc.querySelector('#loadfail')?.getAttribute('onclick') ?? '';
              const nlMatch = onclick.match(/nl\('([^']+)'\)/);
              if (nlMatch) {
                const sourceId = nlMatch[1];
                const sep = pageUrl.includes('?') ? '&' : '?';
                const reloadRaw = await withRetry(() => fetchText(`${pageUrl}${sep}nl=${sourceId}`));
                const reloadMatch = reloadRaw.match(/id="img"\s+x?src="([^"]+)"/);
                if (reloadMatch) original = reloadMatch[1];
              }
            }
          } catch (e) {
            console.warn('[eHunter-js] original-image lookup failed', e);
          }
        }
        const sources = (highRes ? [original, normal] : [normal, original])
          .filter(Boolean)
          .filter((v, i, a) => a.indexOf(v) === i);
        return sources;
      },
    };
  }
  // Recursively searches a JSON structure for an nhentai gallery object (has media_id + images).
  function _deepFindGallery(obj, depth = 0) {
    if (!obj || typeof obj !== 'object' || depth > 10) return null;
    if (obj.media_id != null && obj.images) return obj;
    for (const val of Object.values(obj)) {
      if (Array.isArray(val)) {
        for (const item of val) {
          const found = _deepFindGallery(item, depth + 1);
          if (found) return found;
        }
      } else {
        const found = _deepFindGallery(val, depth + 1);
        if (found) return found;
      }
    }
    return null;
  }

  // Decode SvelteKit's flat value-pool format and search for a gallery object.
  // SvelteKit serialises load() data as:
  //   { nodes: [ { type: "data", data: [pool values...], uses: {} } ] }
  // where objects in the pool are plain JS objects (not index maps in modern SK).
  // This walks every value in every node's data pool.
  function _decodeSvelteKitPool(svData, id) {
    if (!svData || typeof svData !== 'object') return null;
    const nodes = Array.isArray(svData.nodes) ? svData.nodes : [];
    for (const node of nodes) {
      if (!node) continue;
      // Pool is node.data (array) or node itself might be the data
      const pools = [node.data, node].filter(Array.isArray);
      for (const pool of pools) {
        for (const item of pool) {
          if (item && typeof item === 'object' && item.media_id != null && item.images) return item;
          // Sometimes the gallery is nested one level under a key like 'book' or 'gallery'
          if (item && typeof item === 'object') {
            for (const v of Object.values(item)) {
              if (v && typeof v === 'object' && v.media_id != null && v.images) return v;
            }
          }
        }
      }
    }
    return null;
  }

  // Scrapes the current live DOM for SvelteKit hydration data containing the gallery.
  // This is the fastest path after a SPA navigation because no fetch is needed.
  function _extractNhGalleryFromDom(id) {
    // SvelteKit injects its serialised store into a <script> with id="__sveltekit_data"
    // or a script whose text content starts with `__sveltekit_data =`.
    const candidates = [
      document.getElementById('__sveltekit_data'),
      ...Array.from(document.querySelectorAll('script[type="application/json"]')),
      ...Array.from(document.querySelectorAll('script:not([src])')).filter(s =>
        s.textContent.includes('media_id') || s.textContent.includes('"' + id + '"')
      ),
    ].filter(Boolean);

    for (const el of candidates) {
      let text = el.textContent.trim();
      // Strip leading assignment if present: `__sveltekit_data = {...}`
      text = text.replace(/^\s*\w[\w.]*\s*=\s*/, '');
      try {
        const parsed = JSON.parse(text);
        const found = _deepFindGallery(parsed) ?? _decodeSvelteKitPool(parsed, id);
        if (found) return found;
      } catch {   }
    }
    return null;
  }
  const EXT_BY_TYPE = { j: 'jpg', p: 'png', g: 'gif', w: 'webp' };
  const extFor = t => EXT_BY_TYPE[t] ?? 'jpg';
  const NH_IMAGE_HOSTS = ['i1.nhentai.net','i2.nhentai.net','i3.nhentai.net','i4.nhentai.net','i5.nhentai.net'];
  // nhentai now serves images as .webp regardless of the type code in the API response.
  // Fall back to the declared extension only when the type is explicitly non-webp.
  const nhExtFor = t => (t === 'p' ? 'png' : t === 'g' ? 'gif' : 'webp');
  const buildImageUrl = (mid, n, t) => { const host = NH_IMAGE_HOSTS[(n - 1) % NH_IMAGE_HOSTS.length]; return `https://${host}/galleries/${mid}/${n}.${nhExtFor(t)}`; };
  const NH_THUMB_HOSTS = ['t1.nhentai.net','t2.nhentai.net','t3.nhentai.net','t4.nhentai.net'];
  const buildThumbUrl = (mid, n, t) => { const host = NH_THUMB_HOSTS[(n - 1) % NH_THUMB_HOSTS.length]; return `https://${host}/galleries/${mid}/${n}t.${nhExtFor(t)}`; };
  function createNhAdapter() {
    return {
      name: 'nh',
      getGalleryId() {
        // Matches both /g/12345/ (gallery index) and /g/12345/1/ (reader page)
        const m = location.pathname.match(/\/g\/(\d+)/);
        return m ? { id: m[1] } : null;
      },
      async _fetchGalleryJson(id) {
        // 1. Try window._gallery (legacy / some mirrors still set this)
        if (typeof window !== 'undefined' && window._gallery) return window._gallery;

        // 2. Scrape the live DOM — SvelteKit stores its dehydrated state in
        //    <script id="__sveltekit_data" type="application/json"> (or similar).
        //    This is the fastest path when the script runs after a SPA navigation
        //    because the data is already in the page.
        try {
          const found = _extractNhGalleryFromDom(id);
          if (found) return found;
        } catch (err) {
          console.warn('[eHunter-js] DOM scrape failed', err);
        }

        // 3. Fetch the gallery index HTML and scrape SvelteKit's dehydrated payload.
        //    SvelteKit serialises its load() data as a flat value-pool inside
        //    <script id="__sveltekit_data"> (or an inline script ending in
        //    `__sveltekit_data = {...}`).  The pool format:
        //      { nodes: [ { type: "data", data: [...flat pool...], uses: {...} } ] }
        //    Objects in the pool are encoded as plain JS objects when they are
        //    small, but string/number leaf values share the pool.  We just need
        //    to walk every value in the pool looking for media_id + images.
        const galleryBase = `${location.origin}/g/${id}/`;
        try {
          const html = await withRetry(() => fetchText(galleryBase));

          // 3a. <script type="application/json"> blocks (SvelteKit ≥ 2.x)
          const jsonScriptRe = /<script[^>]+type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/gi;
          for (const sm of html.matchAll(jsonScriptRe)) {
            try {
              const parsed = JSON.parse(sm[1]);
              const found = _deepFindGallery(parsed) ?? _decodeSvelteKitPool(parsed, id);
              if (found) return found;
            } catch {   }
          }

          // 3b. Inline __sveltekit_data assignment  (SvelteKit 1.x / some builds)
          const assignRe = /\b__sveltekit_data\s*=\s*(\{[\s\S]*?\})(?=\s*;?\s*<\/script>)/;
          const am = html.match(assignRe);
          if (am) {
            try {
              const parsed = JSON.parse(am[1]);
              const found = _deepFindGallery(parsed) ?? _decodeSvelteKitPool(parsed, id);
              if (found) return found;
            } catch {   }
          }

          // 3c. Legacy: JSON.parse("...") pattern (pre-SvelteKit builds)
          const legacyM = html.match(/JSON\.parse\("(.+?)"\)/);
          if (legacyM) {
            try {
              const g = JSON.parse(legacyM[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
              if (g?.media_id) return g;
            } catch {   }
          }
        } catch (err) {
          console.warn('[eHunter-js] HTML fetch/scrape failed', err);
        }

        // 4. SvelteKit __data.json endpoint (works on some deployments)
        try {
          const dataUrl = `${galleryBase}__data.json?x-sveltekit-invalidated=001`;
          const raw = await withRetry(() => fetchText(dataUrl));
          const svData = JSON.parse(raw);
          const found = _deepFindGallery(svData) ?? _decodeSvelteKitPool(svData, id);
          if (found) return found;
        } catch (err) {
          console.warn('[eHunter-js] __data.json fetch failed', err);
        }

        throw new Error(`[eHunter-js] Could not retrieve gallery data for id=${id}. All scraping strategies failed — see console for details.`);
      },
      // Scrape the gallery index page DOM for all the data we need.
      // nhentai's gallery page at /g/ID/ renders thumbnail <img> tags with src like:
      //   https://t4.nhentai.net/galleries/3973899/1t.webp
      //   https://t2.nhentai.net/galleries/3973899/2t.webp.webp
      // These give us: media_id, page count, exact thumbnail URLs (including any double ext),
      // and CDN hosts per page. Full image URLs use the same media_id on i*.nhentai.net.
      // This requires no JSON, no API, and works even when the API returns 403.
      async _scrapeGalleryPage(id) {
        // Fetch the gallery index page (always /g/ID/ regardless of current subpage)
        const galleryUrl = `${location.origin}/g/${id}/`;
        const html = await withRetry(() => fetchText(galleryUrl));
        const parser = new DOMParser();
        // Blank out src= to prevent the browser from firing requests in the parsed doc
        const doc = parser.parseFromString(html.replace(/\bsrc=/g, 'data-src='), 'text/html');

        // Pull title from <h1> or <title>
        const title =
          doc.querySelector('h1')?.textContent?.trim() ||
          doc.querySelector('h2')?.textContent?.trim() ||
          doc.title?.replace(/\s*[»|].*$/, '').trim() ||
          document.title;

        // Thumbnail <img> tags are inside <a href="/g/ID/PAGE/"> links.
        // Each anchor wraps exactly one thumbnail image.
        // The src pattern: https://tN.nhentai.net/galleries/MEDIA_ID/PAGEt.EXT[.EXT]
        const thumbRe = /^https?:\/\/t\d+\.nhentai\.net\/galleries\/(\d+)\/(\d+)t(\..+)$/i;
        const pageAnchors = Array.from(
          doc.querySelectorAll('a[href*="/g/' + id + '/"]')
        ).filter(a => /\/g\/\d+\/\d+\/?$/.test(a.getAttribute('href') || ''));

        const pages = [];
        let mediaId = null;

        for (const anchor of pageAnchors) {
          const img = anchor.querySelector('img[data-src]');
          if (!img) continue;
          const src = img.getAttribute('data-src') || '';
          const m = src.match(thumbRe);
          if (!m) continue;
          const mid  = m[1];
          const pageNum = parseInt(m[2], 10);
          const thumbExt = m[3]; // e.g. ".webp" or ".webp.webp" or ".png.webp"
          if (!mediaId) mediaId = mid;
          pages[pageNum - 1] = { pageNum, thumbUrl: src, thumbExt, mid };
        }

        if (!mediaId || !pages.length) return null;

        // Compact (fill any gaps — shouldn't happen but be safe)
        const compact = pages.filter(Boolean);
        compact.sort((a, b) => a.pageNum - b.pageNum);

        // Derive full-image URLs: same CDN host pattern but on i*.nhentai.net, no 't' suffix.
        // Use the same extension as the thumb (strip any double .webp.webp → just .webp for
        // full images, since thumbs have the 't' marker but full images don't double-up).
        const fullImageHosts = ['i1.nhentai.net','i2.nhentai.net','i3.nhentai.net','i4.nhentai.net','i5.nhentai.net'];
        const fullExt = ext => {
          // Thumb exts look like ".webp", ".webp.webp", ".png.webp"
          // For full images nhentai serves the original format: use the first extension only
          return ext.split('.').filter(Boolean)[0]; // "webp", "png", etc.
        };

        return {
          title,
          mediaId,
          pages: compact.map((p, i) => ({
            thumbUrl: p.thumbUrl,
            imageUrl: `https://${fullImageHosts[i % fullImageHosts.length]}/galleries/${mediaId}/${p.pageNum}.${fullExt(p.thumbExt)}`,
          })),
        };
      },
      async fetchMeta() {
        const id = this.getGalleryId()?.id;
        if (!id) return null;

        // First try DOM scraping (no API needed, works with 403)
        try {
          const scraped = await this._scrapeGalleryPage(id);
          if (scraped && scraped.pages.length > 0) {
            return {
              title: scraped.title,
              pageCount: scraped.pages.length,
              pageLinks:  scraped.pages.map(p => p.imageUrl),
              thumbLinks: scraped.pages.map(p => p.thumbUrl),
              supportsHD: false,
            };
          }
        } catch (err) {
          console.warn('[eHunter-js] DOM scrape failed, falling back to JSON', err);
        }

        // Fallback: try JSON-based approach
        try {
          const g = await this._fetchGalleryJson(id);
          const mid = g.media_id;
          const pages = g.images?.pages ?? [];
          return {
            title: g.title?.pretty ?? g.title?.english ?? g.title?.japanese ?? document.title,
            pageCount: pages.length,
            pageLinks:  pages.map((p, i) => buildImageUrl(mid, i + 1, p.t)),
            thumbLinks: pages.map((p, i) => buildThumbUrl(mid, i + 1, p.t)),
            supportsHD: false,
          };
        } catch (err) {
          console.warn('[eHunter-js] JSON fallback also failed', err);
        }

        return null;
      },
      async fetchPageImageSources(page, pageUrl) {
        const mirrors = ['i1.nhentai.net', 'i2.nhentai.net', 'i3.nhentai.net', 'i4.nhentai.net', 'i5.nhentai.net'];
        try {
          const url = new URL(pageUrl);
          // Generate mirror variants — the primary URL already has the correct extension
          // from DOM scraping, so just swap hosts
          if (mirrors.includes(url.hostname)) {
            return mirrors.map(host => { const u = new URL(pageUrl); u.hostname = host; return u.toString(); });
          }
          return [pageUrl];
        } catch { return [pageUrl]; }
      },
    };
  }
  // ---------------------------------------------------------------------------
  // PLATFORM DETECTION
  // ---------------------------------------------------------------------------
  function detectPlatform() {
    const h = location.hostname;
    if (h.includes('e-hentai.org') || h.includes('exhentai.org')) return 'eh';
    if (h.includes('nhentai.net')) return 'nh';
    return null;
  }
  function isReaderPage(platform) {
    if (platform === 'eh') return /\/s\//.test(location.pathname);
    // Only activate on individual reader pages /g/12345/1/ — NOT on the gallery index /g/12345/
    if (platform === 'nh') return /\/g\/\d+\/\d+/.test(location.pathname);
    return false;
  }
  const ADAPTERS = { eh: createEhAdapter, nh: createNhAdapter };
  async function initPlatform() {
    const name = detectPlatform();
    if (!name || !isReaderPage(name)) return null;
    const service = ADAPTERS[name]?.();
    if (!service) return null;
    const meta = await service.fetchMeta();
    if (!meta) { console.warn('[eHunter-js] failed to load gallery metadata'); return null; }
    return { name, service, meta };
  }
  // ---------------------------------------------------------------------------
  // BOOTSTRAP
  // ---------------------------------------------------------------------------
  async function bootstrap() {
    const name = detectPlatform();
    const isReader = name && isReaderPage(name);
    const overlay = isReader ? showBootOverlay() : null;
    try {
      const platform = await initPlatform();
      if (!platform) {
        overlay?.fail("Couldn't load gallery metadata — the site's markup may have changed.");
        return;
      }
      await mountApp({ platformService: platform.service, meta: platform.meta });
      overlay?.remove();
    } catch (err) {
      console.error('[eHunter-js] init failed', err);
      overlay?.fail('Something went wrong — see console for details.');
    }
  }
  // ---------------------------------------------------------------------------
  // SPA NAVIGATION WATCHER
  // ---------------------------------------------------------------------------
  // nhentai is a SvelteKit SPA: navigating between pages never triggers a full
  // page reload, so @run-at document-idle fires only once on the very first load.
  // We need to re-run bootstrap() on every client-side navigation that lands on
  // a gallery page, and tear down any existing reader overlay first.
  //
  // Strategy (layered, most reliable first):
  //   1. SvelteKit's own navigation events via window.__sveltekit_router (if exposed)
  //   2. History API monkey-patching (pushState / replaceState)
  //   3. popstate event
  //   4. MutationObserver on <body> as a last-resort fallback
  // ---------------------------------------------------------------------------
  (function startSpaWatcher() {
    const ROOT_ID = CONFIG.rootId;
    let _lastPath = location.pathname;
    let _running = false;

    function teardownReader() {
      const existing = document.getElementById(ROOT_ID);
      if (existing) existing.remove();
      const boot = document.getElementById('ehunter-js-boot');
      if (boot) boot.remove();
      document.documentElement.style.overflow = '';
      document.body.style.overflow = '';
      _running = false;
    }

    function onNavigate() {
      const path = location.pathname;
      if (path === _lastPath) return;
      _lastPath = path;
      // Always tear down the reader when navigating away
      teardownReader();
      const platform = detectPlatform();
      if (platform && isReaderPage(platform)) {
        // Small delay: let SvelteKit finish rendering the new page DOM
        // (especially important for the DOM-scraping path in _extractNhGalleryFromDom)
        setTimeout(() => {
          if (location.pathname !== path) return; // navigated again before timeout
          bootstrap().catch(err => console.error('[eHunter-js] bootstrap error (spa)', err));
        }, 400);
      }
    }

    // 1. Monkey-patch History API
    const _origPush    = history.pushState.bind(history);
    const _origReplace = history.replaceState.bind(history);
    history.pushState = function (...args) {
      _origPush(...args);
      setTimeout(onNavigate, 0);
    };
    history.replaceState = function (...args) {
      _origReplace(...args);
      setTimeout(onNavigate, 0);
    };

    // 2. popstate (back/forward navigation)
    window.addEventListener('popstate', () => setTimeout(onNavigate, 0));

    // 3. SvelteKit navigation events (exposed on window in some builds)
    //    afterNavigate fires after the DOM is committed — ideal timing.
    try {
      const g = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window);
      if (typeof g.navigation?.addEventListener === 'function') {
        g.navigation.addEventListener('navigate', () => setTimeout(onNavigate, 0));
      }
    } catch {   }

    // 4. MutationObserver fallback: watch for SvelteKit's route marker attribute
    //    or major DOM changes that indicate a page transition completed.
    let _mutTimer = null;
    const _mutObs = new MutationObserver(() => {
      if (location.pathname === _lastPath) return;
      clearTimeout(_mutTimer);
      _mutTimer = setTimeout(onNavigate, 200);
    });
    _mutObs.observe(document.body, { childList: true, subtree: false });

    // Run immediately for the initial page load
    bootstrap().catch(err => console.error('[eHunter-js] bootstrap error', err));
  })();
})();