Relamanhua Overlay Reader (2-page, RTL)

Fullscreen overlay reader: landscape, 2-page spread, right-to-left, first page single.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Relamanhua Overlay Reader (2-page, RTL)
// @namespace    manga-tools
// @version      0.1.0
// @description  Fullscreen overlay reader: landscape, 2-page spread, right-to-left, first page single.
// @author       paoMian(https://github.com/panda8246)
// @license      MIT
// @homepageURL  https://github.com/panda8246/RelamanhuaReader
// @supportURL   https://github.com/panda8246/RelamanhuaReader/issues
// @match        https://www.relamanhua.org/comic/*/chapter/*
// @match        https://relamanhua.org/comic/*/chapter/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  "use strict";

  /** @type {{debug: boolean; lockUnderlyingScroll: boolean; syncUnderlyingScroll: boolean; useUnderlyingLazyloadTrigger: boolean; syncThrottleMs: number; autoDriveUnderlyingScroll: boolean; autoDriveStepPx: number; autoDriveIntervalMs: number; autoDriveStallRounds: number; autoDriveMaxMs: number; autoDriveJitterPx: number}} */
  const CONFIG = {
    debug: false,
    // Lock underlying page scroll while reading in overlay (recommended when underlying has bugs)
    lockUnderlyingScroll: true,
    // Keep underlying list scroll/progress in sync with overlay position
    // so original lazyload/list progress follows the overlay.
    syncUnderlyingScroll: false,
    // Whether to force original site lazyload by setting underlying img.src = img.dataset.src
    // If the original site has issues, keep this OFF and let overlay load images by itself.
    useUnderlyingLazyloadTrigger: false,
    // Throttle to avoid excessive scroll/observer feedback loops.
    syncThrottleMs: 200,

    // Auto-drive underlying page scroll to force original site to create more <li>/<img>
    // Useful when the site only creates a few items initially and relies on scroll to append more.
    autoDriveUnderlyingScroll: true,
    // Scroll step in px for each tick. Keep moderate to avoid skipping triggers.
    autoDriveStepPx: 900,
    // Interval between drive ticks.
    autoDriveIntervalMs: 220,
    // Consider stalled if no new imgs/pages are discovered for N consecutive ticks.
    autoDriveStallRounds: 12,
    // Stop auto drive after max duration (ms).
    autoDriveMaxMs: 30_000,
    // Jitter amount in px when stalled (scroll up then down).
    autoDriveJitterPx: 260,
  };

  /** @param {...any} args */
  function log(...args) {
    if (CONFIG.debug) console.log("[A1Reader]", ...args);
  }

  /** @param {string} msg */
  function warn(msg) {
    console.warn("[A1Reader]", msg);
  }

  function isChapterPage() {
    return /\/comic\/[^/]+\/chapter\/[^/?#]+/.test(location.pathname);
  }

  function main() {
    if (!isChapterPage()) return;
    log("init", location.href);
    const reader = createReader();
    reader.install();
  }

  try {
    main();
  } catch (e) {
    warn("init failed");
    console.error(e);
  }

  function createReader() {
    const STATE = {
      isOpen: false,
      overlayEl: /** @type {HTMLDivElement | null} */ (null),
      topTitleEl: /** @type {HTMLDivElement | null} */ (null),
      spreadEl: /** @type {HTMLDivElement | null} */ (null),
      leftImg: /** @type {HTMLImageElement | null} */ (null),
      rightImg: /** @type {HTMLImageElement | null} */ (null),
      hintEl: /** @type {HTMLDivElement | null} */ (null),
      viewMode: /** @type {"double" | "single"} */ ("double"),
      // Reading direction: rtl = Japanese manga (right->left, LeftArrow next)
      // ltr = modern (left->right, RightArrow next)
      readingDir: /** @type {"rtl" | "ltr"} */ ("rtl"),
      restoreOverflow: /** @type {string | null} */ (null),
      restoreBodyOverflow: /** @type {string | null} */ (null),
      cleanupInputBlockers: /** @type {null | (() => void)} */ (null),

      listEl: /** @type {HTMLUListElement | null} */ (null),
      observer: /** @type {MutationObserver | null} */ (null),
      refreshQueued: false,

      pages: /** @type {string[]} */ ([]),
      imgByUrl: /** @type {Map<string, HTMLImageElement>} */ (new Map()),
      loadedUrls: /** @type {Set<string>} */ (new Set()),
      lastRenderedRight: /** @type {string | null} */ (null),
      lastRenderedLeft: /** @type {string | null} */ (null),
      // 0 = default: page1 single, then (2-3)(4-5)...
      // 1 = alt: pair from start: (1-2)(3-4)...
      pairingMode: 0,

      spreads:
        /** @type {Array<{right?: string; left?: string; rightNo?: number; leftNo?: number}>} */ ([]),
      index: 0,

      lastSyncUrl: /** @type {string | null} */ (null),
      lastSyncTs: 0,

      autoDriveTimer: /** @type {number | null} */ (null),
      autoDriveStartTs: 0,
      autoDriveLastProgressTs: 0,
      autoDriveLastImgCount: 0,
      autoDriveStall: 0,
      autoDriveStartScrollY: 0,
      autoDriveStopReason: /** @type {string | null} */ (null),
    };

    function install() {
      injectStyles();
      buildOverlay();
      attachGlobalHotkeys();
    }

    function injectStyles() {
      if (document.getElementById("tmReaderStyles")) return;
      const style = document.createElement("style");
      style.id = "tmReaderStyles";
      style.textContent = `
        #tmReaderOverlay{
          position:fixed; inset:0; z-index:2147483647;
          background:#000;
          /* Reserve space for bottom hint (updated dynamically in JS) */
          --tmHintH: 44px;
          --tmHintBottom: 18px;
          --tmHintGap: 10px;
          --tmSafeBottom: env(safe-area-inset-bottom, 0px);
          display:none;
          user-select:none;
          -webkit-user-select:none;
          touch-action:manipulation;
        }
        #tmReaderOverlay[data-open="1"]{ display:block; }
        #tmReaderTopBar{
          position:absolute; top:0; left:0; right:0;
          height:44px;
          display:flex; align-items:center; justify-content:space-between;
          padding:0 12px;
          color:#fff;
          font: 14px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans","PingFang SC","Microsoft YaHei",sans-serif;
          background:linear-gradient(to bottom, rgba(0,0,0,.72), rgba(0,0,0,0));
          pointer-events:none;
        }
        #tmReaderTopBar .tmReaderTitle{ opacity:.9; }
        #tmReaderTopBar .tmReaderHelp{ opacity:.75; }
        #tmReaderSpread{
          position:absolute; inset:0;
          /* Scheme B: treat two pages as ONE group, center the group horizontally */
          display:flex;
          flex-direction:row;
          justify-content:center;
          /* make children take full available height (after padding) */
          align-items:stretch;
          gap:0;
          /* no horizontal padding: keep two pages touching seamlessly */
          padding: 52px 0 calc(
            12px + var(--tmHintH) + var(--tmHintBottom) + var(--tmHintGap) + var(--tmSafeBottom)
          );
          box-sizing:border-box;
        }
        .tmReaderPane{
          /* shrink-wrap width to its image so the whole two-page spread can be centered */
          flex:0 0 auto;
          min-width:0;
          height:100%;
          display:flex;
          align-items:center;
          /* default center, but we override per side to make pages meet at center */
          justify-content:center;
          overflow:hidden;
        }
        /* Left page (left column) sticks to center (right edge). */
        #tmReaderSpread .tmReaderPane:first-child{
          justify-content:flex-end;
        }
        /* Right page (right column) sticks to center (left edge). */
        #tmReaderSpread .tmReaderPane:last-child{
          justify-content:flex-start;
        }
        .tmReaderPane img{
          /* cap each page to half viewport width, but allow a tiny overlap to kill the seam */
          max-width:calc(50vw + 1px);
          max-height:100%;
          object-fit:contain;
          display:block;
          margin:0;
          image-rendering:auto;
        }
        /* Eliminate 1px seam caused by subpixel rounding at center line */
        #tmReaderSpread .tmReaderPane:first-child img{ margin-right:-1px; }
        #tmReaderSpread .tmReaderPane:last-child img{ margin-left:-1px; }

        /* View mode toggle: single page (big image centered) */
        #tmReaderOverlay[data-view="single"][data-dir="rtl"] #tmReaderSpread .tmReaderPane:first-child{
          display:none;
        }
        #tmReaderOverlay[data-view="single"][data-dir="rtl"] #tmReaderSpread .tmReaderPane:last-child{
          justify-content:center;
        }
        #tmReaderOverlay[data-view="single"][data-dir="rtl"] #tmReaderSpread .tmReaderPane img{
          max-width:100vw;
          margin:0 !important;
        }
        #tmReaderOverlay[data-view="single"][data-dir="ltr"] #tmReaderSpread .tmReaderPane:last-child{
          display:none;
        }
        #tmReaderOverlay[data-view="single"][data-dir="ltr"] #tmReaderSpread .tmReaderPane:first-child{
          justify-content:center;
        }
        #tmReaderOverlay[data-view="single"][data-dir="ltr"] #tmReaderSpread .tmReaderPane img{
          max-width:100vw;
          margin:0 !important;
        }
        #tmReaderHint{
          position:absolute;
          left:50%;
          bottom:calc(var(--tmHintBottom) + var(--tmSafeBottom));
          transform:translateX(-50%);
          color:#fff;
          font: 13px/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,"Noto Sans","PingFang SC","Microsoft YaHei",sans-serif;
          opacity:.85;
          background:rgba(0,0,0,.45);
          padding:8px 10px;
          border-radius:10px;
          pointer-events:none;
          white-space:nowrap;
          max-width:min(92vw, 980px);
          overflow:hidden;
          text-overflow:ellipsis;
        }
      `;
      document.head.appendChild(style);
    }

    function syncHintReserve() {
      if (!STATE.isOpen) return;
      if (!STATE.overlayEl || !STATE.hintEl) return;
      // overlay is display:none when closed; only measure when open
      const r = STATE.hintEl.getBoundingClientRect();
      const h = Math.max(0, Math.ceil(r.height || 0));
      if (h > 0) STATE.overlayEl.style.setProperty("--tmHintH", `${h}px`);
    }

    function getViewModeText() {
      return STATE.viewMode === "single" ? "单页" : "双页";
    }

    function getReadingDirText() {
      return STATE.readingDir === "rtl" ? "日式(右→左)" : "现代(左→右)";
    }

    function toggleViewMode() {
      STATE.viewMode = STATE.viewMode === "double" ? "single" : "double";
      if (STATE.overlayEl)
        STATE.overlayEl.setAttribute("data-view", STATE.viewMode);
      // Update UI immediately
      updateTopBar(computeLoadedCount());
      updateHint(computeLoadedCount());
      if (STATE.isOpen) renderCurrent();
    }

    function toggleReadingDir() {
      STATE.readingDir = STATE.readingDir === "rtl" ? "ltr" : "rtl";
      if (STATE.overlayEl)
        STATE.overlayEl.setAttribute("data-dir", STATE.readingDir);
      // Update UI immediately
      updateTopBar(computeLoadedCount());
      updateHint(computeLoadedCount());
      if (STATE.isOpen) renderCurrent();
    }

    function buildOverlay() {
      if (STATE.overlayEl) return;

      const overlay = document.createElement("div");
      overlay.id = "tmReaderOverlay";
      overlay.setAttribute("aria-hidden", "true");
      overlay.setAttribute("data-dir", STATE.readingDir);

      const topBar = document.createElement("div");
      topBar.id = "tmReaderTopBar";
      topBar.innerHTML = `
        <div class="tmReaderTitle">A1 阅读器(双页)</div>
        <div class="tmReaderHelp">R:进入/退出  F:单/双页  G:配对  C:方向  Esc:退出</div>
      `;
      const titleEl = /** @type {HTMLDivElement | null} */ (
        topBar.querySelector(".tmReaderTitle")
      );

      const spread = document.createElement("div");
      spread.id = "tmReaderSpread";

      const leftPane = document.createElement("div");
      leftPane.className = "tmReaderPane";
      const rightPane = document.createElement("div");
      rightPane.className = "tmReaderPane";

      const leftImg = document.createElement("img");
      leftImg.alt = "left";
      const rightImg = document.createElement("img");
      rightImg.alt = "right";
      leftPane.appendChild(leftImg);
      rightPane.appendChild(rightImg);

      spread.appendChild(leftPane);
      spread.appendChild(rightPane);

      const hint = document.createElement("div");
      hint.id = "tmReaderHint";
      hint.textContent = "点击左半屏:下一页组;右半屏:上一页组";

      overlay.appendChild(topBar);
      overlay.appendChild(spread);
      overlay.appendChild(hint);

      overlay.addEventListener(
        "click",
        (e) => {
          // prevent click-through
          e.stopPropagation();
          e.preventDefault();
        },
        true
      );

      overlay.addEventListener("pointerup", (e) => {
        // click half screen navigation depends on readingDir
        if (!STATE.isOpen) return;
        const w = overlay.clientWidth || window.innerWidth;
        const x = e.clientX;
        const isLeft = x < w / 2;
        if (STATE.readingDir === "rtl") {
          // left half => next, right half => prev
          if (isLeft) gotoNext();
          else gotoPrev();
        } else {
          // right half => next, left half => prev
          if (isLeft) gotoPrev();
          else gotoNext();
        }
      });

      document.documentElement.appendChild(overlay);

      STATE.overlayEl = overlay;
      STATE.topTitleEl = titleEl;
      STATE.spreadEl = spread;
      STATE.leftImg = leftImg;
      STATE.rightImg = rightImg;
      STATE.hintEl = hint;

      // Track successful loads in overlay itself (independent of underlying lazyload state)
      leftImg.addEventListener("load", () => {
        const url =
          leftImg.currentSrc ||
          leftImg.getAttribute("src") ||
          leftImg.src ||
          "";
        if (isValidPageUrl(url)) STATE.loadedUrls.add(url);
      });
      rightImg.addEventListener("load", () => {
        const url =
          rightImg.currentSrc ||
          rightImg.getAttribute("src") ||
          rightImg.src ||
          "";
        if (isValidPageUrl(url)) STATE.loadedUrls.add(url);
      });
    }

    function attachGlobalHotkeys() {
      window.addEventListener(
        "keydown",
        (e) => {
          if (e.key === "r" || e.key === "R") {
            e.preventDefault();
            toggle();
            return;
          }
          if (e.key === "f" || e.key === "F") {
            // Allow toggling even when overlay is closed.
            e.preventDefault();
            toggleViewMode();
            return;
          }
          if (e.key === "g" || e.key === "G") {
            // Allow toggling even when overlay is closed, but it's most useful when open.
            e.preventDefault();
            togglePairingMode();
            return;
          }
          if (e.key === "c" || e.key === "C") {
            // Allow toggling even when overlay is closed.
            e.preventDefault();
            toggleReadingDir();
            return;
          }
          if (!STATE.isOpen) return;
          if (e.key === "Escape") {
            e.preventDefault();
            close();
            return;
          }
          // Paging direction depends on readingDir:
          // rtl: LeftArrow => next, RightArrow => prev
          // ltr: RightArrow => next, LeftArrow => prev
          if (e.key === "ArrowLeft") {
            e.preventDefault();
            if (STATE.readingDir === "rtl") gotoNext();
            else gotoPrev();
            return;
          }
          if (e.key === "ArrowRight") {
            e.preventDefault();
            if (STATE.readingDir === "rtl") gotoPrev();
            else gotoNext();
            return;
          }
        },
        { capture: true }
      );

      // Keep layout reserve in sync with viewport changes (zoom/orientation/resize).
      window.addEventListener(
        "resize",
        () => {
          if (!STATE.isOpen) return;
          syncHintReserve();
        },
        { passive: true }
      );
    }

    function open() {
      if (STATE.isOpen) return;
      if (!STATE.overlayEl) buildOverlay();
      STATE.isOpen = true;

      if (CONFIG.lockUnderlyingScroll) {
        // If we enable auto-drive, we must allow script scrolling; block user inputs instead.
        if (CONFIG.autoDriveUnderlyingScroll) {
          if (!STATE.cleanupInputBlockers) {
            STATE.cleanupInputBlockers = installInputBlockers();
          }
        } else {
          STATE.restoreOverflow = document.documentElement.style.overflow;
          STATE.restoreBodyOverflow = document.body.style.overflow;
          document.documentElement.style.overflow = "hidden";
          document.body.style.overflow = "hidden";
        }
      }

      STATE.overlayEl.setAttribute("data-open", "1");
      STATE.overlayEl.setAttribute("data-view", STATE.viewMode);
      STATE.overlayEl.setAttribute("data-dir", STATE.readingDir);
      STATE.overlayEl.setAttribute("aria-hidden", "false");

      ensureList();
      startObserver();
      refreshPagesNow();
      renderCurrent();
      startAutoDrive();
      // Reserve space for bottom hint based on its actual rendered height.
      requestAnimationFrame(() => syncHintReserve());
    }

    function close() {
      if (!STATE.isOpen) return;
      STATE.isOpen = false;
      if (STATE.overlayEl) {
        STATE.overlayEl.removeAttribute("data-open");
        STATE.overlayEl.setAttribute("aria-hidden", "true");
      }

      stopAutoDrive();
      if (STATE.cleanupInputBlockers) {
        try {
          STATE.cleanupInputBlockers();
        } catch {}
        STATE.cleanupInputBlockers = null;
      }

      if (CONFIG.lockUnderlyingScroll) {
        if (
          STATE.restoreOverflow !== null ||
          STATE.restoreBodyOverflow !== null
        ) {
          document.documentElement.style.overflow = STATE.restoreOverflow ?? "";
          document.body.style.overflow = STATE.restoreBodyOverflow ?? "";
          STATE.restoreOverflow = null;
          STATE.restoreBodyOverflow = null;
        }
      }

      stopObserver();
    }

    function installInputBlockers() {
      /** @param {Event} e */
      function prevent(e) {
        if (!STATE.isOpen) return;
        e.preventDefault();
      }

      /** @param {KeyboardEvent} e */
      function onKeydown(e) {
        if (!STATE.isOpen) return;
        // Allow our own global hotkeys handler to run first; avoid blocking it here.
        // Block common scroll keys to keep underlying page stable for auto-drive.
        const k = e.key;
        if (
          k === " " ||
          k === "PageDown" ||
          k === "PageUp" ||
          k === "Home" ||
          k === "End" ||
          k === "ArrowUp" ||
          k === "ArrowDown"
        ) {
          e.preventDefault();
        }
      }

      // Use capture + non-passive so preventDefault actually works.
      window.addEventListener("wheel", prevent, {
        capture: true,
        passive: false,
      });
      window.addEventListener("touchmove", prevent, {
        capture: true,
        passive: false,
      });
      window.addEventListener("keydown", onKeydown, { capture: true });

      return () => {
        window.removeEventListener("wheel", prevent, { capture: true });
        window.removeEventListener("touchmove", prevent, { capture: true });
        window.removeEventListener("keydown", onKeydown, { capture: true });
      };
    }

    function startAutoDrive() {
      if (!CONFIG.autoDriveUnderlyingScroll) return;
      if (!STATE.isOpen) return;
      if (STATE.autoDriveTimer) return;

      STATE.autoDriveStartTs = Date.now();
      STATE.autoDriveLastProgressTs = STATE.autoDriveStartTs;
      STATE.autoDriveStall = 0;
      STATE.autoDriveStartScrollY = window.scrollY || 0;
      STATE.autoDriveLastImgCount = getUnderlyingImgCount();
      STATE.autoDriveStopReason = null;

      // Tick: scroll to trigger original site to append more items.
      STATE.autoDriveTimer = window.setInterval(() => {
        try {
          autoDriveTick();
        } catch (e) {
          log("autoDriveTick error", e);
        }
      }, CONFIG.autoDriveIntervalMs);
    }

    /** @param {string=} reason */
    function stopAutoDrive(reason) {
      if (!STATE.autoDriveTimer) return;
      window.clearInterval(STATE.autoDriveTimer);
      STATE.autoDriveTimer = null;
      STATE.autoDriveStopReason =
        reason || STATE.autoDriveStopReason || "stopped";
    }

    function getUnderlyingImgCount() {
      ensureList();
      if (!STATE.listEl) return 0;
      return STATE.listEl.querySelectorAll("img").length;
    }

    function isNearBottom() {
      const doc = document.documentElement;
      const maxY = (doc?.scrollHeight || 0) - (window.innerHeight || 0);
      const y = window.scrollY || 0;
      return y >= Math.max(0, maxY - 2);
    }

    function dispatchScrollEvent() {
      try {
        window.dispatchEvent(new Event("scroll"));
      } catch {}
    }

    function autoDriveJitter() {
      const j = CONFIG.autoDriveJitterPx;
      window.scrollBy(0, -j);
      dispatchScrollEvent();
      // Let layout / observers catch up.
      setTimeout(() => {
        if (!STATE.isOpen) return;
        window.scrollBy(0, j * 2);
        dispatchScrollEvent();
      }, 60);
    }

    function autoDriveTick() {
      if (!STATE.isOpen) {
        stopAutoDrive("closed");
        return;
      }

      const now = Date.now();
      if (now - STATE.autoDriveStartTs > CONFIG.autoDriveMaxMs) {
        stopAutoDrive("timeout");
        return;
      }

      const count = getUnderlyingImgCount();
      if (count > STATE.autoDriveLastImgCount) {
        STATE.autoDriveLastImgCount = count;
        STATE.autoDriveLastProgressTs = now;
        STATE.autoDriveStall = 0;
      } else {
        STATE.autoDriveStall += 1;
      }

      // If we are stuck and already near bottom, stop to avoid busy looping.
      if (
        STATE.autoDriveStall >= CONFIG.autoDriveStallRounds &&
        isNearBottom()
      ) {
        stopAutoDrive("bottom");
        return;
      }

      // Drive scroll: normal step, or jitter when stalled.
      if (STATE.autoDriveStall >= CONFIG.autoDriveStallRounds) {
        autoDriveJitter();
        STATE.autoDriveStall = 0; // reset after jitter attempt
      } else {
        window.scrollBy(0, CONFIG.autoDriveStepPx);
        dispatchScrollEvent();
      }

      // Keep A1's internal page list fresh even if MutationObserver misses.
      refreshPagesNow();
    }

    function getAutoDriveStatusText() {
      if (!CONFIG.autoDriveUnderlyingScroll) return "";
      const total = STATE.pages.length || 0;
      const imgCount = getUnderlyingImgCount();
      const stalledForMs = Math.max(
        0,
        Date.now() - (STATE.autoDriveLastProgressTs || 0)
      );
      const stalledForS = Math.floor(stalledForMs / 1000);

      if (STATE.autoDriveTimer) {
        // Example: 后台驱动中(DOM图 12,已发现 10,停滞 3s)
        return `后台驱动中(DOM图 ${imgCount},已发现 ${total},停滞 ${stalledForS}s)`;
      }
      if (STATE.autoDriveStopReason) {
        const reason =
          STATE.autoDriveStopReason === "timeout"
            ? "超时停止"
            : STATE.autoDriveStopReason === "bottom"
            ? "到底停止"
            : STATE.autoDriveStopReason === "closed"
            ? "已关闭"
            : "已停止";
        return `后台驱动:${reason}(DOM图 ${imgCount},已发现 ${total})`;
      }
      return `后台驱动:未启动(DOM图 ${imgCount},已发现 ${total})`;
    }

    function toggle() {
      if (STATE.isOpen) close();
      else open();
    }

    function ensureList() {
      if (STATE.listEl && document.contains(STATE.listEl)) return;
      const el = /** @type {HTMLUListElement | null} */ (
        document.querySelector(".comicContent-list")
      );
      STATE.listEl = el;
    }

    function startObserver() {
      if (STATE.observer) return;
      if (!STATE.listEl) return;
      const observer = new MutationObserver(() => {
        scheduleRefresh();
      });
      observer.observe(STATE.listEl, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["data-src", "src", "class"],
      });
      STATE.observer = observer;
    }

    function stopObserver() {
      if (!STATE.observer) return;
      STATE.observer.disconnect();
      STATE.observer = null;
      STATE.refreshQueued = false;
    }

    function scheduleRefresh() {
      if (STATE.refreshQueued) return;
      STATE.refreshQueued = true;
      requestAnimationFrame(() => {
        STATE.refreshQueued = false;
        refreshPagesNow();
      });
    }

    /** @param {string | null | undefined} url */
    function isLoadingUrl(url) {
      if (!url) return true;
      return (
        /\/loading(\.|_).*(png|webp|jpg|jpeg)$/.test(url) ||
        url.includes("loading.png")
      );
    }

    /** @param {string} url */
    function isValidPageUrl(url) {
      // loosened rules: accept common cdn domains; exclude obvious placeholders
      if (!/^https?:\/\//.test(url)) return false;
      if (isLoadingUrl(url)) return false;
      return (
        url.includes("sl.mangafunb.fun/") ||
        url.includes("hi77-overseas.mangafunb.fun/") ||
        url.includes("mangafunb.fun/")
      );
    }

    /** @param {HTMLImageElement} img */
    function extractUrl(img) {
      const ds = img.getAttribute("data-src") || img.dataset?.src;
      const src = img.currentSrc || img.getAttribute("src") || img.src;
      const url = ds || src || "";
      return url;
    }

    function refreshPagesNow() {
      ensureList();
      if (!STATE.listEl) {
        if (STATE.hintEl)
          STATE.hintEl.textContent = "未找到漫画列表(.comicContent-list)";
        return;
      }
      const imgs = Array.from(STATE.listEl.querySelectorAll("img"));
      const pages = [];
      const seen = new Set();
      const map = new Map();
      for (const img of imgs) {
        const url = extractUrl(img);
        if (!url || !isValidPageUrl(url)) continue;
        if (seen.has(url)) continue;
        seen.add(url);
        pages.push(url);
        map.set(url, img);
      }

      const changed =
        pages.length !== STATE.pages.length ||
        pages.some((u, i) => STATE.pages[i] !== u);
      STATE.pages = pages;
      STATE.imgByUrl = map;

      const prevIndex = STATE.index;
      const loadedCount = computeLoadedCount();
      buildSpreads();
      clampIndex();
      updateTopBar(loadedCount);
      updateHint(loadedCount);
      // Do NOT re-render current spread just because new pages are appended.
      // Only re-render when navigation changes index or current spread becomes invalid.
      if (
        STATE.isOpen &&
        (prevIndex !== STATE.index || !STATE.spreads[STATE.index])
      ) {
        renderCurrent();
      }
      if (changed) log("pages updated", pages.length);
    }

    /** @param {string} url */
    function isUnderlyingLoaded(url) {
      const img = STATE.imgByUrl.get(url);
      if (!img) return false;
      if (img.classList.contains("lazyloaded")) return true;
      const src = img.currentSrc || img.getAttribute("src") || img.src || "";
      if (isValidPageUrl(src) && !isLoadingUrl(src)) return true;
      if (img.complete && img.naturalWidth > 0 && isValidPageUrl(url))
        return true;
      return false;
    }

    function computeLoadedCount() {
      // Count unique loaded pages among discovered URLs (STATE.pages)
      let n = 0;
      for (const url of STATE.pages) {
        if (STATE.loadedUrls.has(url) || isUnderlyingLoaded(url)) n += 1;
      }
      return n;
    }

    function buildSpreads() {
      const pages = STATE.pages;
      /** @type {Array<{right?: string; left?: string; rightNo?: number; leftNo?: number}>} */
      const spreads = [];
      if (STATE.pairingMode === 0) {
        // default: first page single (on the right), then pair from page2
        if (pages.length >= 1) {
          spreads.push({ right: pages[0], rightNo: 1 });
        }
        for (let i = 1; i < pages.length; i += 2) {
          const right = pages[i];
          const left = pages[i + 1];
          const rightNo = i + 1;
          const leftNo = i + 2;
          spreads.push({
            right,
            left,
            rightNo,
            leftNo: left ? leftNo : undefined,
          });
        }
      } else {
        // alt: pair from start (1-2)(3-4)...
        for (let i = 0; i < pages.length; i += 2) {
          const right = pages[i];
          const left = pages[i + 1];
          const rightNo = i + 1;
          const leftNo = i + 2;
          spreads.push({
            right,
            left,
            rightNo,
            leftNo: left ? leftNo : undefined,
          });
        }
      }
      STATE.spreads = spreads;
    }

    function getPairingModeText() {
      return STATE.pairingMode === 0 ? "首单" : "双起";
    }

    function togglePairingMode() {
      // Anchor to current right page so toggling feels like BC <-> AB.
      const cur = STATE.spreads[STATE.index];
      const oldRight = cur?.right;
      STATE.pairingMode = STATE.pairingMode === 0 ? 1 : 0;

      buildSpreads();
      clampIndex();

      if (oldRight) {
        // Prefer spread where oldRight becomes the left page (shift-back),
        // otherwise fall back to spread where oldRight is on the right.
        let idx = STATE.spreads.findIndex((s) => s.left === oldRight);
        if (idx < 0) idx = STATE.spreads.findIndex((s) => s.right === oldRight);
        if (idx >= 0) STATE.index = idx;
      }

      // Update UI immediately
      updateTopBar(computeLoadedCount());
      updateHint(computeLoadedCount());
      if (STATE.isOpen) renderCurrent();
      log("pairingMode", STATE.pairingMode);
    }

    function clampIndex() {
      const max = Math.max(0, STATE.spreads.length - 1);
      if (STATE.index > max) STATE.index = max;
      if (STATE.index < 0) STATE.index = 0;
    }

    /** @param {number} loadedCount */
    function updateTopBar(loadedCount) {
      if (!STATE.topTitleEl) return;
      const total = STATE.pages.length;
      const spread = STATE.spreads[STATE.index];
      if (!spread) {
        STATE.topTitleEl.textContent = `A1 阅读器(等待加载…)`;
        return;
      }
      const pageLabel = spread.leftNo
        ? `${spread.rightNo}-${spread.leftNo}`
        : `${spread.rightNo}`;
      const driveTag = STATE.autoDriveTimer ? "|后台驱动中" : "";
      STATE.topTitleEl.textContent = `A1 阅读器(第 ${pageLabel} / ${total} 页,已加载 ${loadedCount})|配对:${getPairingModeText()}|模式:${getViewModeText()}|方向:${getReadingDirText()}${driveTag}`;
    }

    /** @param {number} loadedCount */
    function updateHint(loadedCount) {
      if (!STATE.hintEl) return;
      // Keep bottom hint minimal (operations only). Status stays in top bar.
      void loadedCount;
      const arrowTip =
        STATE.readingDir === "rtl" ? "← 下一|→ 上一" : "→ 下一|← 上一";
      const clickTip =
        STATE.readingDir === "rtl"
          ? "左半屏 下一|右半屏 上一"
          : "右半屏 下一|左半屏 上一";
      STATE.hintEl.textContent = `操作:${arrowTip}|${clickTip}|F 单/双页|G 配对(首单/双起)|C 方向(日式/现代)|R 进入/退出|Esc 退出`;
      syncHintReserve();
    }

    function setImg(el, url) {
      if (!el) return;
      if (!url) {
        el.removeAttribute("src");
        el.style.visibility = "hidden";
        return;
      }
      el.style.visibility = "visible";
      if (el.getAttribute("src") !== url) el.setAttribute("src", url);
    }

    /** @param {string | undefined} url */
    function triggerUnderlyingLazyload(url) {
      if (!CONFIG.useUnderlyingLazyloadTrigger) return;
      if (!url) return;
      const img = STATE.imgByUrl.get(url);
      if (!img) return;
      const ds = img.getAttribute("data-src") || img.dataset?.src;
      if (!ds) return;
      if (img.classList.contains("lazyload")) {
        img.setAttribute("src", ds);
      }
    }

    function preload(url) {
      if (!url) return;
      const i = new Image();
      i.decoding = "async";
      i.onload = () => {
        if (isValidPageUrl(url)) STATE.loadedUrls.add(url);
      };
      i.src = url;
    }

    function showLoadingPlaceholder() {
      if (!STATE.rightImg || !STATE.leftImg) return;
      // Keep current images but make it obvious when next page is still loading
      STATE.rightImg.style.opacity = "0.92";
      STATE.leftImg.style.opacity = "0.92";
    }

    function clearLoadingPlaceholder() {
      if (!STATE.rightImg || !STATE.leftImg) return;
      STATE.rightImg.style.opacity = "1";
      STATE.leftImg.style.opacity = "1";
    }

    function renderCurrent() {
      if (!STATE.isOpen) return;
      const spread = STATE.spreads[STATE.index];
      if (!spread) return;
      // Pane assignment depends on reading direction:
      // rtl: spread.right -> right pane, spread.left -> left pane
      // ltr: spread.right -> left pane, spread.left -> right pane
      const paneRight =
        (STATE.readingDir === "rtl" ? spread.right : spread.left) || null;
      const paneLeft =
        (STATE.readingDir === "rtl" ? spread.left : spread.right) || null;
      const isSameSpread =
        paneRight === STATE.lastRenderedRight &&
        paneLeft === STATE.lastRenderedLeft;

      // Only show placeholder/fade when switching to a new spread.
      if (!isSameSpread) showLoadingPlaceholder();
      syncUnderlyingProgress(spread);
      // ensure underlying loads (best-effort)
      triggerUnderlyingLazyload(spread.right);
      triggerUnderlyingLazyload(spread.left);
      setImg(STATE.rightImg, paneRight);
      setImg(STATE.leftImg, paneLeft);

      const next = STATE.spreads[STATE.index + 1];
      if (next) {
        preload(next.right);
        preload(next.left);
      }
      // Clear placeholder once current spread loads (best effort),
      // but only when switching spread to avoid repeated "refresh" flicker.
      if (!isSameSpread) {
        const urls = [spread.right, spread.left].filter(Boolean);
        let pending = urls.length;
        if (pending === 0) {
          clearLoadingPlaceholder();
        } else {
          for (const url of urls) {
            const img = new Image();
            img.decoding = "async";
            img.onload = () => {
              if (isValidPageUrl(url)) STATE.loadedUrls.add(url);
              pending -= 1;
              if (pending <= 0) clearLoadingPlaceholder();
            };
            img.onerror = () => {
              pending -= 1;
              if (pending <= 0) clearLoadingPlaceholder();
            };
            img.src = url;
          }
        }
      }

      STATE.lastRenderedRight = paneRight;
      STATE.lastRenderedLeft = paneLeft;

      // Update labels with latest loaded count estimate (cheap recompute)
      updateTopBar(computeLoadedCount());
      updateHint(computeLoadedCount());
    }

    /** @param {{right?: string; left?: string; rightNo?: number; leftNo?: number}} spread */
    function syncUnderlyingProgress(spread) {
      if (!CONFIG.syncUnderlyingScroll) return;
      if (!STATE.listEl) return;
      if (!spread?.right) return;

      const now = Date.now();
      if (now - STATE.lastSyncTs < CONFIG.syncThrottleMs) return;
      if (STATE.lastSyncUrl === spread.right) return;
      STATE.lastSyncTs = now;
      STATE.lastSyncUrl = spread.right;

      // Sync original site UI counter (if present)
      const idxEl = /** @type {HTMLElement | null} */ (
        document.querySelector(".comicIndex")
      );
      const countEl = /** @type {HTMLElement | null} */ (
        document.querySelector(".comicCount")
      );
      if (idxEl && spread.rightNo) idxEl.textContent = String(spread.rightNo);
      if (countEl) countEl.textContent = String(STATE.pages.length || 0);

      // Sync original list scroll position (best-effort)
      const img = STATE.imgByUrl.get(spread.right);
      if (!img) return;

      try {
        img.scrollIntoView({ block: "center", inline: "nearest" });
      } catch {
        img.scrollIntoView(true);
      }
    }

    function gotoNext() {
      if (STATE.spreads.length === 0) return;
      const next = Math.min(STATE.spreads.length - 1, STATE.index + 1);
      if (next === STATE.index) return;
      STATE.index = next;
      renderCurrent();
    }

    function gotoPrev() {
      if (STATE.spreads.length === 0) return;
      const prev = Math.max(0, STATE.index - 1);
      if (prev === STATE.index) return;
      STATE.index = prev;
      renderCurrent();
    }

    return { install, open, close, toggle };
  }
})();