☰

Ex Fast Reader

With the goal of being light, discreet, and non-invasive, a QoL script for Exhentai and E-hentai. It adds features like horizontal and vertical reading, infinite scrolling, pre-fetching images, and more to make browsing extremely smooth.

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         Ex Fast Reader
// @namespace    https://tampermonkey.net/
// @version      4.7.0
// @description  With the goal of being light, discreet, and non-invasive, a QoL script for Exhentai and E-hentai. It adds features like horizontal and vertical reading, infinite scrolling, pre-fetching images, and more to make browsing extremely smooth.
// @author       ethyr
// @match        *://e-hentai.org/*
// @match        *://exhentai.org/*
// @match        *://*.e-hentai.org/*
// @match        *://*.exhentai.org/*
// @icon         https://e-hentai.org/favicon.ico
// @exclude      *://forums.e-hentai.org/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  // =========================================================================
  // 0. CORE & SEAMLESS STRIP STYLES
  // =========================================================================
  const CORE_CSS = `
    /* Failure Toast */
    .eh-toast-fail {
      position: fixed;
      bottom: 24px;
      right: 24px;
      background: rgba(26, 18, 18, 0.96);
      color: #fca5a5;
      border: 1px solid #ef4444;
      padding: 9px 16px;
      border-radius: 6px;
      font-size: 12px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
      box-shadow: 0 4px 14px rgba(0,0,0,0.5);
      z-index: 999999;
      cursor: pointer;
    }

    /* Seamless Native-Themed Controls */
    .eh-strip-controls {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      margin: 3px 8px;
      vertical-align: middle;
    }

.eh-strip-controls.single-mode-bottom {
      display: flex !important;
      justify-content: center !important;
      align-items: center !important;
      margin: 1px auto 2px auto !important;
      padding: 0 !important;
      text-align: center !important;
      clear: both !important;
      width: 100% !important;
    }

    /* In single-page mode, strictly hide every control except the strip mode toggle button */
    .eh-strip-controls.single-mode-bottom > :not(#eh-strip-mode-toggle) {
      display: none !important;
    }

    .eh-strip-btn {
      display: inline-flex;
      align-items: center !important;
      justify-content: center !important;
      font-family: arial, helvetica, sans-serif !important;
      font-size: 9pt !important;
      font-weight: bold !important;
      min-height: 26px !important;
      line-height: 20px !important;
      padding: 1px 12px 2px !important;
      margin: 0 2px !important;
      border-radius: 3px !important;
      cursor: pointer !important;
      text-decoration: none !important;
      user-select: none !important;
      white-space: nowrap !important;
      box-sizing: border-box !important;
      -webkit-text-stroke: none !important;
      text-shadow: none !important;
      outline: none !important;
      vertical-align: middle !important;
      transition: background-color 0.1s ease, border-color 0.1s ease !important;
      border: 2px solid #B5A4A4 !important;
      background-color: #EDEADA !important;
      color: #5C0D12 !important;
    }
    .eh-strip-btn b,
    .eh-strip-btn span {
      font-weight: normal;
      -webkit-text-stroke: none !important;
      text-shadow: none !important;
    }
    .eh-strip-btn.active b {
      font-weight: bold;
    }

    .eh-strip-page-counter {
      font-family: arial, helvetica, sans-serif;
      font-size: 9pt;
      font-weight: normal;
      padding: 2px 6px;
      user-select: none;
      white-space: nowrap;
      line-height: normal;
    }

    /* E-Hentai Theme (Light) - Exact Match to Native Site Buttons */
    .eh-theme-light .eh-strip-btn,
    .eh-strip-btn {
      background-color: #EDEADA !important;
      color: #5C0D12 !important;
      border: 2px solid #B5A4A4 !important;
      border-radius: 3px !important;
      -webkit-text-stroke: none !important;
      text-shadow: none !important;
    }
    .eh-theme-light .eh-strip-btn:hover {
      background-color: #F3F0E0 !important;
      border-color: #977273 !important;
      color: #5C0D12 !important;
    }
    .eh-theme-light .eh-strip-btn.active {
      background-color: #D7D3C2 !important;
      border-color: #5C0D12 !important;
      color: #5C0D12 !important;
      box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.25) !important;
    }
    .eh-theme-light .eh-strip-page-counter,
    .eh-theme-light #eh-topbar-info {
      color: #5C0D12 !important;
    }

    /* ExHentai Theme (Dark) - Exact Match to Native Site Buttons */
    .eh-theme-dark .eh-strip-btn {
      background-color: #34353b !important;
      color: #dddddd !important;
      border: 2px solid #8d8d8d !important;
      border-radius: 3px !important;
      -webkit-text-stroke: none !important;
      text-shadow: none !important;
    }
    .eh-theme-dark .eh-strip-btn:hover {
      background-color: #43464e !important;
      border-color: #aeaeae !important;
      color: #ffffff !important;
    }
    .eh-theme-dark .eh-strip-btn.active {
      background-color: #1a1a1a !important;
      border-color: #aeaeae !important;
      color: #ffffff !important;
      box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4) !important;
    }
    .eh-theme-dark .eh-strip-page-counter,
    .eh-theme-dark #eh-topbar-info {
      color: #dddddd !important;
    }

    /* Hide title bar (h1, #nb, #gn), original switchers, bottom bars, footers, and redundant links cleanly via CSS */
    html.eh-strip-mode-active h1,
    html.eh-strip-mode-active #nb,
    html.eh-strip-mode-active #gn,
    html.eh-strip-mode-active #gj,
    html.eh-strip-mode-active .sb,
    html.eh-strip-mode-active .sn,
    html.eh-strip-mode-active #i3,
    html.eh-strip-mode-active #i4,
    html.eh-strip-mode-active #i5,
    html.eh-strip-mode-active #i6,
    html.eh-strip-mode-active #i7,
    html.eh-strip-mode-active .c,
    html.eh-strip-mode-active .c1,
    html.eh-strip-mode-active .c2,
    html.eh-strip-mode-active .c3,
    html.eh-strip-mode-active #footer,
    html.eh-strip-mode-active .dp,
    html.eh-strip-mode-active #dp,
    html.eh-strip-mode-active .ip,
    html.eh-strip-mode-active #ip,
    html.eh-strip-mode-active .ft,
    html.eh-strip-mode-active #ft,
    html.eh-strip-mode-active .gpc,
    html.eh-strip-mode-active #gpc,
    html.eh-strip-mode-active div.d,
    html.eh-strip-mode-active #i2 > div:not(.sn):not(#eh-strip-controls):not(#eh-topbar-info),
    html.eh-strip-mode-active #i2 > p,
    html.eh-strip-mode-active #i2 > span:not(.sn),
    html.eh-strip-mode-active #i2 > a:not(#eh-strip-controls a),
    html.eh-strip-mode-active .eh-strip-hidden-bottom,
    body.eh-strip-mode-active h1,
    body.eh-strip-mode-active #nb,
    body.eh-strip-mode-active #gn,
    body.eh-strip-mode-active #gj,
    body.eh-strip-mode-active .sb,
    body.eh-strip-mode-active .sn,
    body.eh-strip-mode-active #i3,
    body.eh-strip-mode-active #i4,
    body.eh-strip-mode-active #i5,
    body.eh-strip-mode-active #i6,
    body.eh-strip-mode-active #i7,
    body.eh-strip-mode-active .c,
    body.eh-strip-mode-active .c1,
    body.eh-strip-mode-active .c2,
    body.eh-strip-mode-active .c3,
    body.eh-strip-mode-active #footer,
    body.eh-strip-mode-active .dp,
    body.eh-strip-mode-active #dp,
    body.eh-strip-mode-active .ip,
    body.eh-strip-mode-active #ip,
    body.eh-strip-mode-active .ft,
    body.eh-strip-mode-active #ft,
    body.eh-strip-mode-active .gpc,
    body.eh-strip-mode-active #gpc,
    body.eh-strip-mode-active div.d,
    body.eh-strip-mode-active #i2 > div:not(.sn):not(#eh-strip-controls):not(#eh-topbar-info),
    body.eh-strip-mode-active #i2 > p,
    body.eh-strip-mode-active #i2 > span:not(.sn),
    body.eh-strip-mode-active #i2 > a:not(#eh-strip-controls a),
    body.eh-strip-mode-active .eh-strip-hidden-bottom {
      display: none !important;
    }

    /* Show topbar info cleanly in both strip and single-page modes with stable height */
    body:not(.eh-strip-mode-active) #eh-topbar-info {
      display: inline-flex !important;
      height: 24px !important;
      line-height: 24px !important;
      vertical-align: middle !important;
    }
    body:not(.eh-strip-mode-active) #i2 {
      min-height: 28px !important;
      box-sizing: border-box !important;
    }

    /* Hide strip container when strip mode is inactive */
    html:not(.eh-strip-mode-active) #eh-strip-container,
    body:not(.eh-strip-mode-active) #eh-strip-container {
      display: none !important;
    }

    /* TOP BAR: Fully extends to both edges; auto-hides unless hovered or focused */
    html.eh-strip-mode-active #i2,
    body.eh-strip-mode-active #i2 {
      position: fixed !important;
      top: 0 !important;
      left: 0 !important;
      right: 0 !important;
      width: 100vw !important;
      width: 100% !important;
      max-width: 100vw !important;
      margin: 0 !important;
      padding: 6px 12px !important;
      box-sizing: border-box !important;
      z-index: 100000 !important;
      display: flex !important;
      justify-content: center !important;
      align-items: center !important;
      transform: translateY(-100%) !important;
      opacity: 0 !important;
      visibility: hidden !important;
      box-shadow: none !important;
      border: none !important;
      pointer-events: none !important;
      transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.22s ease, visibility 0.22s !important;
    }

    body.eh-theme-light #i2 {
      background-color: #edeada !important;
    }

    body.eh-theme-dark #i2 {
      background-color: rgba(52, 53, 59, 0.97) !important;
      backdrop-filter: blur(8px) !important;
    }

    body.eh-theme-light.eh-topbar-visible #i2 {
      border-bottom: 1px solid #5c0d12 !important;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
    }

    body.eh-theme-dark.eh-topbar-visible #i2 {
      border-bottom: 1px solid #202020 !important;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important;
    }

    /* Top-bar text in strip modes: one rule per theme, keyed on the <html> theme class so the
       colour and stroke are identical on every load (the theme class is never set on <body>). */
    html.eh-theme-light.eh-strip-mode-active #i2,
    html.eh-theme-light.eh-strip-mode-active #i2 a:not(.eh-strip-btn),
    html.eh-theme-light.eh-strip-mode-active #i2 span:not(.eh-strip-btn):not(#eh-strip-controls *),
    html.eh-theme-light.eh-strip-mode-active #i2 b:not(#eh-strip-controls *),
    html.eh-theme-light.eh-strip-mode-active #eh-topbar-info,
    html.eh-theme-light.eh-strip-mode-active .eh-strip-page-counter {
      color: #5c0d12 !important;
      paint-order: stroke fill !important;
      -webkit-text-stroke: 0.6px #ffffff !important;
      text-shadow: none !important;
    }
    html.eh-theme-dark.eh-strip-mode-active #i2,
    html.eh-theme-dark.eh-strip-mode-active #i2 a:not(.eh-strip-btn),
    html.eh-theme-dark.eh-strip-mode-active #i2 span:not(.eh-strip-btn):not(#eh-strip-controls *),
    html.eh-theme-dark.eh-strip-mode-active #i2 b:not(#eh-strip-controls *),
    html.eh-theme-dark.eh-strip-mode-active #eh-topbar-info,
    html.eh-theme-dark.eh-strip-mode-active .eh-strip-page-counter {
      color: #dddddd !important;
      paint-order: stroke fill !important;
      -webkit-text-stroke: 0.6px #000000 !important;
      text-shadow: none !important;
    }

    body.eh-strip-mode-active.eh-topbar-visible #i2 {
      transform: translateY(0) !important;
      opacity: 1 !important;
      visibility: visible !important;
      pointer-events: auto !important;
    }

    /* Container constraints in horizontal mode: maximize vertical space */
    html.eh-strip-mode-active.strip-horizontal,
    body.eh-strip-mode-active.strip-horizontal {
      overflow: hidden !important;
      height: 100vh !important;
      max-height: 100vh !important;
      margin: 0 !important;
      padding: 0 !important;
    }

    html.eh-strip-mode-active.strip-horizontal #i1,
    body.eh-strip-mode-active.strip-horizontal #i1 {
      width: 100% !important;
      max-width: 100vw !important;
      height: 100vh !important;
      max-height: 100vh !important;
      margin: 0 !important;
      padding: 0 !important;
      border: none !important;
      outline: none !important;
      box-sizing: border-box !important;
      overflow: hidden !important;
      position: relative !important;
    }

    /* Vertical mode: Clean layout with no excessive bottom padding */
    html.eh-strip-mode-active.strip-vertical #i1,
    body.eh-strip-mode-active.strip-vertical #i1 {
      padding-bottom: 0 !important;
      margin-bottom: 0 !important;
    }

    /* COMPLETELY SEAMLESS RIGHT-TO-LEFT (RTL) HORIZONTAL STRIP */
    #eh-strip-container.layout-horizontal {
      display: flex;
      flex-direction: row;
      direction: rtl; /* RIGHT TO LEFT (Manga standard) */
      align-items: center;
      overflow-x: auto;
      overflow-y: hidden;
      height: 100vh !important;
      max-height: 100vh !important;
      width: 100vw;
      box-sizing: border-box;
      padding: 0;
      margin: 0;
      gap: 0;
      border: none;
      scroll-behavior: auto;
      overscroll-behavior-x: contain;
      overflow-anchor: none !important;
      -webkit-overflow-scrolling: touch;
      scrollbar-width: thin;
      scrollbar-color: #4f535b rgba(0, 0, 0, 0.22);
    }

    .eh-theme-light #eh-strip-container.layout-horizontal {
      scrollbar-color: #5c0d12 rgba(0, 0, 0, 0.22);
    }

    /* Items are sized via aspect-ratio and width calculated to match rendered 100vh height */
    #eh-strip-container.layout-horizontal .eh-strip-item {
      display: flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
      flex: 0 0 auto;
      height: 100vh !important;
      max-height: 100vh !important;
      width: calc(100vh * var(--eh-ar, 0.707));
      aspect-ratio: var(--eh-ar, 0.707);
      margin: 0;
      padding: 0;
      border: none;
      position: relative;
      box-sizing: border-box;
      overflow: hidden;
      overflow-anchor: none !important;
    }

    #eh-strip-container.layout-horizontal .eh-strip-item img.eh-main-img {
      /* Fill the item exactly: the item's width is derived from this same image's aspect ratio,
         so there is no sub-pixel/aspect mismatch left over to show up as a gap between pages. */
      height: 100% !important;
      max-height: 100vh !important;
      width: 100% !important;
      max-width: none;
      object-fit: fill;
      display: block;
      margin: 0;
      padding: 0;
      border: none;
      box-shadow: none;
      vertical-align: middle;
      position: relative;
      z-index: 2;
    }

    /* End Card at the end of horizontal strip: generous leeway allows the last image to be centered */
    .eh-strip-item.eh-strip-end-card {
      display: inline-flex !important;
      align-items: center !important;
      justify-content: center !important;
      height: 100vh !important;
      width: auto !important;
      min-width: max(200px, calc(50vw - (100vh * var(--eh-ar, 0.707)) / 2 + 60px)) !important;
      aspect-ratio: auto !important;
      padding: 0 40px !important;
      flex: 0 0 auto;
      z-index: 3;
      box-sizing: border-box !important;
    }

    /* Start Spacer for Horizontal RTL mode: provides generous leeway on the right so Page 1 can be centered */
    .eh-strip-start-spacer {
      display: inline-flex !important;
      align-items: center !important;
      justify-content: center !important;
      height: 100vh !important;
      max-height: 100vh !important;
      width: max(40px, calc(50vw - (100vh * var(--eh-ar, 0.707)) / 2)) !important;
      margin: 0 !important;
      padding: 0 !important;
      border: none !important;
      box-sizing: border-box !important;
      flex: 0 0 auto !important;
      pointer-events: none !important;
      overflow-anchor: none !important;
      background: transparent !important;
    }

    #eh-strip-container.layout-vertical .eh-strip-start-spacer {
      display: none !important;
    }

    /* COMPLETELY SEAMLESS VERTICAL STRIP */
    #eh-strip-container.layout-vertical {
      display: flex;
      flex-direction: column;
      align-items: center;
      overflow-y: visible;
      overflow-x: hidden;
      width: 100%;
      margin: 0 auto;
      padding: 0;
      gap: 0;
      border: none;
      overflow-anchor: none !important;
    }

    #eh-strip-container.layout-vertical .eh-strip-item {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      width: 100%;
      max-width: var(--eh-max-w, 100%);
      aspect-ratio: var(--eh-ar, 0.707);
      margin: 0 auto;
      padding: 0;
      border: none;
      position: relative;
      box-sizing: border-box;
      overflow: hidden;
      overflow-anchor: none !important;
    }

    #eh-strip-container.layout-vertical .eh-strip-item img.eh-main-img {
      max-width: 100%;
      width: 100%;
      height: auto;
      aspect-ratio: var(--eh-ar, 0.707);
      display: block;
      margin: 0 auto;
      padding: 0;
      border: none;
      box-shadow: none;
      position: relative;
      z-index: 2;
    }

    /* Top Bar Image Info */
    #eh-topbar-info {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      font-family: arial, helvetica, sans-serif;
      font-size: 9pt;
      font-weight: normal;
      margin: 0 10px;
      white-space: nowrap;
      user-select: none;
      -webkit-text-stroke: none !important;
      text-shadow: none !important;
    }
    #eh-topbar-info a {
      text-decoration: underline;
    }

    /* Thumbnail placeholders: one small <canvas> per page holding just that page's region of the
       gallery sprite sheet. object-fit: cover means a rounding mismatch crops, it never stretches. */
    .eh-placeholder-thumb-wrap {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      pointer-events: none;
      z-index: 1;
    }

    .eh-placeholder-thumb-img {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      display: block;
      object-fit: fill; /* same fit as the final image, so nothing shifts when it replaces the thumbnail */
      opacity: 1;
      pointer-events: none;
      z-index: 1;
    }

    /* "Page N..." text only appears if the thumbnail is still missing after a moment (no flash) */
    .eh-placeholder-content.eh-delayed {
      opacity: 0;
      animation: eh-fade-in 0.2s ease 0.45s forwards;
    }
    @keyframes eh-fade-in {
      to { opacity: 1; }
    }

    /* Clean, Theme-Consistent Solid Neutral Placeholder */
    .eh-strip-item.eh-placeholder {
      box-sizing: border-box;
      position: relative;
    }

    /* Only show & animate the spinner when the page is actively loading */
    .eh-placeholder:not(.eh-loading) .eh-spinner {
      display: none !important;
    }

    .eh-theme-light .eh-strip-item.eh-placeholder,
    .eh-theme-dark .eh-strip-item.eh-placeholder {
      background-color: transparent !important;
      outline: none !important;
      border: none !important;
    }

    /* Discreet, elegant frosted loading capsule */
    .eh-placeholder-content {
      display: inline-flex;
      flex-direction: row;
      align-items: center;
      justify-content: center;
      gap: 7px;
      user-select: none;
      position: relative;
      z-index: 2;
      background: rgba(0, 0, 0, 0.42);
      padding: 5px 12px;
      border-radius: 14px;
      backdrop-filter: blur(3px);
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
      pointer-events: none;
    }

    .eh-placeholder-content.eh-placeholder-retry {
      flex-direction: column;
      gap: 8px;
      padding: 12px 18px;
      border-radius: 8px;
      pointer-events: auto;
    }

    .eh-spinner {
      width: 14px;
      height: 14px;
      border: 2px solid rgba(255, 255, 255, 0.22);
      border-top-color: #ffffff;
      border-radius: 50%;
      animation: eh-spin 0.7s linear infinite;
      flex-shrink: 0;
    }

    .eh-theme-light .eh-spinner {
      border-top-color: #5c0d12;
      border-color: rgba(92, 13, 18, 0.2);
    }
    .eh-theme-dark .eh-spinner {
      border-top-color: #ffffff;
      border-color: rgba(255, 255, 255, 0.22);
    }

    @keyframes eh-spin {
      to { transform: rotate(360deg); }
    }

    .eh-placeholder-text {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, arial, sans-serif;
      font-size: 8.5pt;
      font-weight: 600;
      color: #ffffff;
      opacity: 0.9;
      letter-spacing: 0.3px;
      line-height: 1;
      white-space: nowrap;
    }

    .eh-theme-light .eh-placeholder-text {
      color: #5c0d12;
    }
    .eh-theme-dark .eh-placeholder-text {
      color: #eeeeee;
    }

    /* Scrollbar styling with comfortable leeway for horizontal sliding */
    #eh-strip-container.layout-horizontal::-webkit-scrollbar {
      height: 12px;
    }
    #eh-strip-container.layout-horizontal::-webkit-scrollbar-track {
      background: rgba(0, 0, 0, 0.22);
    }
    .eh-theme-light #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb {
      background: #5c0d12;
      border-radius: 6px;
    }
    .eh-theme-light #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb:hover {
      background: #87141b;
    }
    .eh-theme-dark #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb {
      background: #4f535b;
      border-radius: 6px;
    }
    .eh-theme-dark #eh-strip-container.layout-horizontal::-webkit-scrollbar-thumb:hover {
      background: #6a6f7b;
    }

    /* Native-Themed Single Bottom Return to Gallery Button (Compact, No Extra Space) */
    .eh-bottom-gallery-bar {
      display: flex !important;
      justify-content: center !important;
      align-items: center !important;
      margin: 15px auto 18px auto !important;
      padding: 6px 0 !important;
      text-align: center !important;
      clear: both !important;
      width: 100% !important;
    }

    .eh-bottom-gallery-bar a {
      display: inline-block;
      padding: 6px 20px;
      font-family: arial, helvetica, sans-serif;
      font-size: 10pt;
      font-weight: bold;
      text-decoration: none;
      border-radius: 3px;
      cursor: pointer;
      transition: all 0.15s ease-in-out;
    }

    .eh-theme-light .eh-bottom-gallery-bar a {
      background-color: #edeada;
      color: #5c0d12;
      border: 1px solid #5c0d12;
    }

    .eh-theme-light .eh-bottom-gallery-bar a:hover {
      background-color: #f2efdf;
      color: #9b4e03;
      border-color: #9b4e03;
    }

    .eh-theme-dark .eh-bottom-gallery-bar a {
      background-color: #34353b;
      color: #dddddd;
      border: 1px solid #4f535b;
    }

    .eh-theme-dark .eh-bottom-gallery-bar a:hover {
      background-color: #43464e;
      color: #ffffff;
      border-color: #727783;
    }

/* Infinite Scroll Button */
#eh-infinite-scroll-wrap {
  display: inline-flex !important;
  align-items: center !important;
  margin-left: 8px !important;
  vertical-align: middle !important;
}

#eh-infinite-scroll-toggle {
  font-family: inherit;
  font-size: 9pt;
  line-height: normal;
  box-sizing: border-box;
  vertical-align: middle;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 10px;
  min-height: 22px !important;
  height: 22px;
}

/* Light Theme: keep the hover background (#F3F0E0) and border (#977273) when active */
.eh-theme-light #eh-infinite-scroll-toggle.active {
  background-color: #F3F0E0 !important;
  border-color: #977273 !important;
  color: #5C0D12 !important;
  box-shadow: none !important;
}

/* Dark Theme: keep the hover background (#43464e) and border (#aeaeae) when active */
.eh-theme-dark #eh-infinite-scroll-toggle.active {
  background-color: #43464e !important;
  border-color: #aeaeae !important;
  color: #ffffff !important;
  box-shadow: none !important;
}

    html:not(.eh-strip-mode-active),
    body:not(.eh-strip-mode-active) {
      scroll-behavior: auto !important;
      /* Chrome's scroll anchoring nudges the scroll position by a few px whenever the image above
         the fold changes size on a page turn; we align the page ourselves. */
      overflow-anchor: none !important;
    }
    #i1, #i3 {
      overflow-anchor: none !important;
    }

    /* Single page thumbnail placeholder & stable viewport */
    #i3 {
      position: relative !important;
      text-align: center !important;
      display: flex !important;
      flex-direction: column !important;
      align-items: center !important;
      justify-content: flex-start !important;
      width: 100% !important;
      margin: 0 auto !important;
      box-sizing: border-box !important;
    }
    #i3 a {
      position: relative !important;
      display: inline-flex !important;
      align-items: center !important;
      justify-content: center !important;
      max-width: 100% !important;
      margin: 0 auto !important;
    }
    #eh-single-thumb-placeholder {
      display: flex !important;
      align-items: center !important;
      justify-content: center !important;
      position: absolute !important;
      top: 0 !important;
      left: 0 !important;
      width: 100% !important;
      height: 100% !important;
      margin: 0 auto !important;
      overflow: hidden !important;
      box-sizing: border-box !important;
      max-width: 100% !important;
      background: transparent !important;
      z-index: 5 !important;
      pointer-events: none !important;
    }
    .eh-theme-light #eh-single-thumb-placeholder {
      background-color: transparent !important;
    }
    .eh-theme-dark #eh-single-thumb-placeholder {
      background-color: transparent !important;
    }
    #eh-single-thumb-placeholder .eh-placeholder-thumb-wrap {
      position: absolute !important;
      top: 0 !important;
      left: 0 !important;
      width: 100% !important;
      height: 100% !important;
      overflow: hidden !important;
      pointer-events: none !important;
      z-index: 1 !important;
    }
    #eh-single-thumb-placeholder .eh-placeholder-thumb-img {
      position: absolute !important;
      top: 0 !important;
      left: 0 !important;
      width: 100% !important;
      height: 100% !important;
      pointer-events: none !important;
    }
    #i1,
    #i3,
    #i3 a {
      overflow: visible !important;
      max-height: none !important;
    }
    #i3,
    #i3 a {
      height: auto !important;
    }
    #i3 img#img {
      flex: 0 0 auto !important; /* never let the flex container squash width while height stays fixed */
      display: block !important;
      margin: 0 auto !important;
      object-fit: contain !important;
      box-sizing: border-box !important;
    }

    /* Completely eliminate focus rings and selection boxes around single page images and anchors */
    #i3,
    #i3:focus,
    #i3:focus-visible,
    #i3 a,
    #i3 a:focus,
    #i3 a:focus-visible,
    #i3 a:active,
    #i3 img,
    #i3 img:focus,
    #i3 img:focus-visible,
    #i3 img:active,
    #img,
    #img:focus,
    #img:focus-visible,
    #img:active,
    #eh-single-thumb-placeholder,
    #eh-single-thumb-placeholder:focus {
      outline: none !important;
      outline-width: 0 !important;
      box-shadow: none !important;
      -webkit-tap-highlight-color: transparent !important;
      user-select: none !important;
    }


    /* Inline Favorites Menu (Replaces #taglist in #gd4) */
    #gd4.eh-fav-active {
      height: auto !important;
      max-height: 100% !important;
      overflow: visible !important;
    }

    #gd4.eh-fav-active > :not(#eh-inline-favorites-container) {
      display: none !important;
    }

    #eh-inline-favorites-container {
      width: 100% !important;
      max-width: 100% !important;
      box-sizing: border-box !important;
      padding: 0 4px 4px 4px !important;
      margin: 0 !important;
      font-family: arial, helvetica, sans-serif !important;
      font-size: 9pt !important;
      line-height: normal !important;
      color: inherit !important;
      overflow: visible !important;
    }

    #eh-inline-favorites-container p.eh-fav-header {
      margin: 0 0 6px 0 !important;
      padding: 0 !important;
      text-align: center !important;
      font-weight: normal !important;
      font-size: 9pt !important;
      line-height: 1.25 !important;
    }

    #eh-inline-favorites-container .eh-fav-body {
      display: flex !important;
      flex-direction: row !important;
      justify-content: space-between !important;
      align-items: flex-start !important;
      gap: 12px !important;
      width: 100% !important;
      max-width: 100% !important;
      box-sizing: border-box !important;
    }

    #eh-inline-favorites-container .eh-fav-cats {
      display: flex !important;
      flex-direction: column !important;
      flex: 0 0 auto !important;
      width: auto !important;
      min-width: 140px !important;
      max-width: 165px !important;
      box-sizing: border-box !important;
      margin: 0 !important;
      padding: 0 !important;
      gap: 2px !important;
    }

    #eh-inline-favorites-container .eh-fav-cat-item {
      display: flex !important;
      align-items: center !important;
      cursor: pointer !important;
      user-select: none !important;
      padding: 1px 4px !important;
      border-radius: 3px !important;
      gap: 6px !important;
      height: 20px !important;
      box-sizing: border-box !important;
    }

    #eh-inline-favorites-container .eh-fav-cat-item:hover {
      background: rgba(255, 255, 255, 0.08) !important;
    }
    .eh-theme-light #eh-inline-favorites-container .eh-fav-cat-item:hover {
      background: rgba(0, 0, 0, 0.06) !important;
    }

    #eh-inline-favorites-container .eh-fav-cat-item input[type="radio"] {
      margin: 0 !important;
      cursor: pointer !important;
      flex-shrink: 0 !important;
    }

    #eh-inline-favorites-container .eh-fav-cat-diamond,
    #eh-inline-favorites-container .i {
      width: 19px !important;
      height: 19px !important;
      min-width: 19px !important;
      min-height: 19px !important;
      background-image: url('https://ehgt.org/g/fav.png') !important;
      background-repeat: no-repeat !important;
      display: inline-block !important;
      vertical-align: middle !important;
      flex-shrink: 0 !important;
      box-sizing: border-box !important;
    }

    #eh-inline-favorites-container .eh-fav-cat-label {
      font-size: 9pt !important;
      font-weight: normal !important;
      white-space: nowrap !important;
      overflow: hidden !important;
      text-overflow: ellipsis !important;
    }

    #eh-inline-favorites-container .eh-fav-note-pane {
      display: flex !important;
      flex-direction: column !important;
      flex: 1 1 0% !important;
      min-width: 0 !important;
      max-width: 480px !important;
      margin-right: 35px !important; /* Space on the right so it doesn't touch the panel edge */
      box-sizing: border-box !important;
    }

    #eh-inline-favorites-container .eh-fav-note-title {
      font-weight: bold !important;
      text-align: center !important;
      margin-bottom: 4px !important;
      font-size: 9pt !important;
    }

    #eh-inline-favorites-container textarea.eh-fav-note-textarea {
      width: 100% !important;
      height: 125px !important;
      min-height: 110px !important;
      max-height: 135px !important;
      box-sizing: border-box !important;
      resize: none !important;
      font-family: inherit !important;
      font-size: 9pt !important;
      padding: 6px 8px !important;
      border-radius: 3px !important;
      outline: none !important;
    }

    .eh-theme-dark #eh-inline-favorites-container textarea.eh-fav-note-textarea {
      background-color: #27282c !important;
      color: #eeeeee !important;
      border: 1px solid #4f535b !important;
    }

    .eh-theme-light #eh-inline-favorites-container textarea.eh-fav-note-textarea {
      background-color: #fcfbfa !important;
      color: #333333 !important;
      border: 1px solid #b5a4a4 !important;
    }

    #eh-inline-favorites-container .eh-fav-slots {
      margin: 4px 0 0 0 !important;
      text-align: right !important;
      font-size: 8pt !important;
      opacity: 0.9 !important;
    }

    #eh-inline-favorites-container .eh-fav-actions {
      display: flex !important;
      align-items: center !important;
      justify-content: center !important;
      gap: 10px !important;
      margin-top: 8px !important;
      width: 100% !important;
    }

    #eh-inline-favorites-container input[type="submit"],
    #eh-inline-favorites-container input[type="button"],
    #eh-inline-favorites-container .eh-fav-submit-btn,
    #eh-inline-favorites-container .eh-fav-remove-btn,
    #eh-inline-favorites-container .eh-fav-cancel-btn {
      font-family: arial, helvetica, sans-serif !important;
      font-size: 9pt !important;
      font-weight: bold !important;
      min-height: 23px !important;
      line-height: 17px !important;
      padding: 2px 14px !important;
      border-radius: 3px !important;
      cursor: pointer !important;
      box-sizing: border-box !important;
      outline: none !important;
      transition: background-color 0.1s ease, border-color 0.1s ease !important;
    }

    .eh-theme-light #eh-inline-favorites-container input[type="submit"],
    .eh-theme-light #eh-inline-favorites-container input[type="button"],
    .eh-theme-light #eh-inline-favorites-container .eh-fav-submit-btn,
    .eh-theme-light #eh-inline-favorites-container .eh-fav-remove-btn,
    .eh-theme-light #eh-inline-favorites-container .eh-fav-cancel-btn {
      background-color: #EDEADA !important;
      color: #5C0D12 !important;
      border: 2px solid #B5A4A4 !important;
    }
    .eh-theme-light #eh-inline-favorites-container input[type="submit"]:hover,
    .eh-theme-light #eh-inline-favorites-container input[type="button"]:hover,
    .eh-theme-light #eh-inline-favorites-container .eh-fav-submit-btn:hover,
    .eh-theme-light #eh-inline-favorites-container .eh-fav-remove-btn:hover,
    .eh-theme-light #eh-inline-favorites-container .eh-fav-cancel-btn:hover {
      background-color: #F3F0E0 !important;
      border-color: #977273 !important;
      color: #5C0D12 !important;
    }

    .eh-theme-dark #eh-inline-favorites-container input[type="submit"],
    .eh-theme-dark #eh-inline-favorites-container input[type="button"],
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-submit-btn,
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-remove-btn,
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-cancel-btn {
      background-color: #34353b !important;
      color: #dddddd !important;
      border: 2px solid #8d8d8d !important;
    }
    .eh-theme-dark #eh-inline-favorites-container input[type="submit"]:hover,
    .eh-theme-dark #eh-inline-favorites-container input[type="button"]:hover,
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-submit-btn:hover,
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-remove-btn:hover,
    .eh-theme-dark #eh-inline-favorites-container .eh-fav-cancel-btn:hover {
      background-color: #43464e !important;
      border-color: #aeaeae !important;
      color: #ffffff !important;
    }

    /* Suppress foreign/ComicRead quick-favorite popup injected into #gd3 */
    #gd3 > :not(#gdc):not(#gdn):not(#gdd):not(#gdr):not(#gdf):not(.c) {
      display: none !important;
      visibility: hidden !important;
      pointer-events: none !important;
      opacity: 0 !important;
      height: 0 !important;
      width: 0 !important;
      overflow: hidden !important;
    }

        .ido,
    div.ido {
      position: relative !important;
    }

    /* Relocated #favact to the top right of .ido (light gray box) */
    #eh-favact-top,
    #favact.eh-favact-top,
    .eh-favact-top {
      display: inline-flex !important;
      justify-content: flex-end !important;
      gap: 6px !important;
      position: absolute !important;
      top: 6px !important;
      right: 0px !important;
      float: none !important;
      clear: none !important;
      margin: 0 !important;
      padding: 0 !important;
      text-align: right !important;
      box-sizing: border-box !important;
      white-space: nowrap !important;
      width: auto !important;
      height: auto !important;
      line-height: normal !important;
      z-index: 100 !important;
    }

    #favact.eh-favact-top select,
    .eh-favact-top select {
      font-family: inherit !important;
      font-size: 9pt !important;
      box-sizing: border-box !important;
      margin: 0 !important;
      vertical-align: middle !important;
      height: 26px !important;
      min-height: 26px !important;
      line-height: 20px !important;
      border-radius: 3px !important;
    }

    #favact.eh-favact-top input[type="submit"],
    #favact.eh-favact-top input[type="button"],
    #favact.eh-favact-top input[value="Apply"],
    #favact.eh-favact-top input[value="Confirm"],
    #favact.eh-favact-top button,
    .eh-favact-top input[type="submit"],
    .eh-favact-top input[type="button"],
    .eh-favact-top input[value="Apply"],
    .eh-favact-top input[value="Confirm"],
    .eh-favact-top button {
      display: inline-flex !important;
      align-items: center !important;
      justify-content: center !important;
      font-family: arial, helvetica, sans-serif !important;
      font-size: 9pt !important;
      font-weight: bold !important;
      padding: 1px 12px 2px !important;
      margin: 0 0 0 2px !important;
      border-radius: 3px !important;
      cursor: pointer !important;
      box-sizing: border-box !important;
      vertical-align: middle !important;
      height: 26px !important;
      min-height: 26px !important;
      line-height: 20px !important;
      white-space: nowrap !important;
      transition: background-color 0.1s ease, border-color 0.1s ease !important;
    }

    /* Checkbox aligned horizontally with the dropdown and button */
    #alltoggle,
    [name="alltoggle"],
    .alltoggle,
    #favact input[type="checkbox"],
    .eh-favact-top input[type="checkbox"],
    #favact.eh-favact-top input[type="checkbox"],
    #eh-favact-top input[type="checkbox"] {
      margin: 0 5px 0 0 !important;
      padding: 0 !important;
      vertical-align: middle !important;
      position: static !important;
      top: 0 !important;
      cursor: pointer !important;
      align-self: center !important;
    }

    #favact.eh-favact-top label,
    #favact.eh-favact-top span,
    .eh-favact-top label,
    .eh-favact-top span,
    #eh-favact-top label,
    #eh-favact-top span {
      display: inline-flex !important;
      align-items: center !important;
      vertical-align: middle !important;
      font-size: 9pt !important;
      line-height: normal !important;
    }

    #favact.eh-favact-top div,
    .eh-favact-top div,
    #eh-favact-top div {
      display: inline-flex !important;
      vertical-align: middle !important;
      font-size: 9pt !important;
      line-height: normal !important;
    }

    /* Light Theme (E-Hentai) */
    .eh-theme-light #favact.eh-favact-top select,
    .eh-theme-light .eh-favact-top select {
      background-color: #EDEADA !important;
      color: #5C0D12 !important;
      border: 2px solid #B5A4A4 !important;
    }

    .eh-theme-light #favact.eh-favact-top input[type="submit"],
    .eh-theme-light #favact.eh-favact-top input[type="button"],
    .eh-theme-light #favact.eh-favact-top input[value="Apply"],
    .eh-theme-light #favact.eh-favact-top input[value="Confirm"],
    .eh-theme-light #favact.eh-favact-top button,
    .eh-theme-light .eh-favact-top input[type="submit"],
    .eh-theme-light .eh-favact-top input[type="button"],
    .eh-theme-light .eh-favact-top input[value="Apply"],
    .eh-theme-light .eh-favact-top input[value="Confirm"],
    .eh-theme-light .eh-favact-top button {
      background-color: #EDEADA !important;
      color: #5C0D12 !important;
      border: 2px solid #B5A4A4 !important;
    }
    .eh-theme-light #favact.eh-favact-top input[type="submit"]:hover,
    .eh-theme-light #favact.eh-favact-top input[type="button"]:hover,
    .eh-theme-light #favact.eh-favact-top input[value="Apply"]:hover,
    .eh-theme-light #favact.eh-favact-top input[value="Confirm"]:hover,
    .eh-theme-light #favact.eh-favact-top button:hover,
    .eh-theme-light .eh-favact-top input[type="submit"]:hover,
    .eh-theme-light .eh-favact-top input[type="button"]:hover,
    .eh-theme-light .eh-favact-top input[value="Apply"]:hover,
    .eh-theme-light .eh-favact-top input[value="Confirm"]:hover,
    .eh-theme-light .eh-favact-top button:hover {
      background-color: #F3F0E0 !important;
      border-color: #977273 !important;
      color: #5C0D12 !important;
    }

    /* Dark Theme (ExHentai) */
    .eh-theme-dark #favact.eh-favact-top select,
    .eh-theme-dark .eh-favact-top select {
      background-color: #34353b !important;
      color: #dddddd !important;
      border: 2px solid #8d8d8d !important;
    }

    .eh-theme-dark #favact.eh-favact-top input[type="submit"],
    .eh-theme-dark #favact.eh-favact-top input[type="button"],
    .eh-theme-dark #favact.eh-favact-top input[value="Apply"],
    .eh-theme-dark #favact.eh-favact-top input[value="Confirm"],
    .eh-theme-dark #favact.eh-favact-top button,
    .eh-theme-dark .eh-favact-top input[type="submit"],
    .eh-theme-dark .eh-favact-top input[type="button"],
    .eh-theme-dark .eh-favact-top input[value="Apply"],
    .eh-theme-dark .eh-favact-top input[value="Confirm"],
    .eh-theme-dark .eh-favact-top button {
      background-color: #34353b !important;
      color: #dddddd !important;
      border: 2px solid #8d8d8d !important;
    }
    .eh-theme-dark #favact.eh-favact-top input[type="submit"]:hover,
    .eh-theme-dark #favact.eh-favact-top input[type="button"]:hover,
    .eh-theme-dark #favact.eh-favact-top input[value="Apply"]:hover,
    .eh-theme-dark #favact.eh-favact-top input[value="Confirm"]:hover,
    .eh-theme-dark #favact.eh-favact-top button:hover,
    .eh-theme-dark .eh-favact-top input[type="submit"]:hover,
    .eh-theme-dark .eh-favact-top input[type="button"]:hover,
    .eh-theme-dark .eh-favact-top input[value="Apply"]:hover,
    .eh-theme-dark .eh-favact-top input[value="Confirm"]:hover,
    .eh-theme-dark .eh-favact-top button:hover {
      background-color: #43464e !important;
      border-color: #aeaeae !important;
      color: #ffffff !important;
    }
  `;

function injectStyles() {
    const style = document.createElement('style');
    style.id = 'eh-fast-reader-core-css';
    style.textContent = CORE_CSS;

    const root = document.head || document.documentElement;
    if (root) {
      root.appendChild(style);
    } else {
      const observer = new MutationObserver(() => {
        const el = document.head || document.documentElement;
        if (el) {
          el.appendChild(style);
          applyEarlyClasses(document.documentElement);
          observer.disconnect();
        }
      });
      observer.observe(document, { childList: true });
    }
  }

  injectStyles();

  const isReaderUrl = location.pathname.startsWith('/s/');
  let earlyStripActive = true;
  try {
    earlyStripActive = localStorage.getItem('eh_strip_mode_enabled') !== '0';
  } catch {}
  let earlyLayout = 'horizontal';
  try {
    earlyLayout = localStorage.getItem('eh_strip_layout') || 'horizontal';
  } catch {}
  const isEarlyDark = location.hostname.includes('exhentai');
  function applyEarlyClasses(el) {
    if (!el) return;
    el.classList.add(isEarlyDark ? 'eh-theme-dark' : 'eh-theme-light');
    if (isReaderUrl && earlyStripActive) {
      el.classList.add('eh-strip-mode-active', 'strip-' + earlyLayout);
    }
  }
  applyEarlyClasses(document.documentElement);

  // =========================================================================
  // 1. MAIN-WORLD INJECTION
  // =========================================================================
  function injectMainWorldEngine() {
    const run = () => {
      let injected = false;
      try {
        const script = document.createElement('script');
        script.textContent = `(${mainWorldCode.toString()})();`;
        (document.head || document.documentElement).appendChild(script);
        script.remove();
        injected = true;
      } catch (e) {
        console.warn('ExFastReader: Script injection failed, falling back to direct execution:', e);
      }
      if (!injected && typeof mainWorldCode === 'function') {
        mainWorldCode();
      }
    };

    if (document.head || document.documentElement) {
      run();
    } else {
      const observer = new MutationObserver(() => {
        if (document.head || document.documentElement) {
          observer.disconnect();
          run();
        }
      });
      observer.observe(document, { childList: true });
    }
  }

  injectMainWorldEngine();

// =========================================================================
  // 2. INCOGNITO SETTINGS AUTO-SYNC (FAST-LANE)
  // =========================================================================
  function getCookie(name) {
    const matches = document.cookie.match(
      new RegExp('(?:^|; )' + name.replace(/([\.$?*|{}\(\)\[\]\/\+^])/g, '\\$1') + '=([^;]*)')
    );
    return matches ? decodeURIComponent(matches[1]) : undefined;
  }

  function getSavedUconfig() {
    try {
      if (typeof GM_getValue === 'function') {
        const v = GM_getValue('eh_saved_uconfig');
        if (v && typeof v === 'string') return v;
      }
    } catch {}
    try {
      const v = localStorage.getItem('eh_saved_uconfig');
      if (v && typeof v === 'string') return v;
    } catch {}
    return null;
  }

  function setSavedUconfig(val) {
    if (!val || typeof val !== 'string') return;
    try {
      if (typeof GM_setValue === 'function') {
        GM_setValue('eh_saved_uconfig', val);
      }
    } catch {}
    try {
      localStorage.setItem('eh_saved_uconfig', val);
    } catch {}
  }

async function checkAndSyncSettings() {
    // 1. AUTO-ADVANCE FROM PONI TOKEN:
    // When landing on exhentai.org/?poni=..., cookies have already been set by the response headers.
    // Strip ?poni= and immediately replace with the clean URL so you don't get stuck waiting for F5.
    if (location.search.includes('poni=')) {
      const params = new URLSearchParams(location.search);
      const poni = params.get('poni');
      if (poni) {
        const key = 'eh_poni_' + poni.slice(0, 24);
        if (!sessionStorage.getItem(key)) {
          sessionStorage.setItem(key, '1');
          const clean = new URL(location.href);
          clean.searchParams.delete('poni');
          location.replace(clean.href);
          return;
        }
      }
      return;
    }

    // 2. If stuck on an interstitial bounce page, wait for the DOM and auto-click the redirect link
    if (location.pathname.includes('bounce') || location.pathname.includes('login')) {
      const followBounce = () => {
        const link = document.querySelector('a[href*="poni="], a[href*="exhentai.org"]');
        if (link && link.href) {
          location.replace(link.href);
        }
      };
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', followBounce, { once: true });
      } else {
        followBounce();
      }
      return;
    }

    // If currently on the settings page, capture and save the latest uconfig cookie
    if (location.pathname.startsWith('/uconfig')) {
      const cur = getCookie('uconfig');
      if (cur) setSavedUconfig(cur);
      return;
    }

    const isReader = location.pathname.startsWith('/s/');
    const syncCookieName = 'eh_uconfig_synced';
    const syncKey = 'eh_uconfig_synced_' + location.hostname;

    let memberId, uconfig, syncAttempted;
    try {
      memberId = getCookie('ipb_member_id');
      uconfig = getCookie('uconfig');
      syncAttempted = getCookie(syncCookieName) || sessionStorage.getItem(syncKey);
    } catch {
      return;
    }

    // Always update saved cache if uconfig is present
    if (uconfig) {
      setSavedUconfig(uconfig);
      return;
    }

    if (!memberId || memberId === '0' || syncAttempted) {
      return;
    }

    // FAST LANE: If we have a cached uconfig string in storage, inject it instantly without fetching!
    const saved = getSavedUconfig();
    const cookieDomain = '.' + location.hostname.replace(/^.*?(e-hentai\.org|exhentai\.org)$/, '$1');
    if (saved) {
      document.cookie = `uconfig=${saved}; path=/; domain=${cookieDomain}; SameSite=Lax; max-age=31536000`;
      document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
      try { sessionStorage.setItem(syncKey, 'done'); } catch {}
      if (!isReader) {
        location.replace(location.href);
      }
      return;
    }

    // On reader pages, silently sync in background without reloading
    if (isReader) {
      try {
        sessionStorage.setItem(syncKey, 'attempted');
        document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
        fetch(location.origin + '/uconfig.php', {
          method: 'GET',
          credentials: 'include',
          priority: 'high'
        }).then((res) => {
          if (res.ok) {
            sessionStorage.setItem(syncKey, 'done');
            document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
            const newU = getCookie('uconfig');
            if (newU) setSavedUconfig(newU);
          }
        }).catch(() => {});
      } catch {}
      return;
    }

    // COLD SYNC (First time with no cached uconfig):
    // Prevent unstyled flash while fetching
    let blocker = null;
    try {
      blocker = document.createElement('style');
      blocker.id = 'eh-fast-sync-blocker';
      blocker.textContent = 'html { opacity: 0 !important; }';
      (document.head || document.documentElement).appendChild(blocker);
      setTimeout(() => { if (blocker && blocker.parentNode) blocker.remove(); }, 1800);
    } catch {}

    try {
      sessionStorage.setItem(syncKey, 'attempted');
      document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;

      const res = await fetch(location.origin + '/uconfig.php', {
        method: 'GET',
        credentials: 'include',
        priority: 'high'
      });

      if (res.ok) {
        sessionStorage.setItem(syncKey, 'done');
        document.cookie = `${syncCookieName}=1; path=/; SameSite=Lax`;
        const newU = getCookie('uconfig');
        if (newU) setSavedUconfig(newU);
        location.replace(location.href);
      } else if (blocker) {
        blocker.remove();
      }
    } catch {
      if (blocker) blocker.remove();
    }
  }

  checkAndSyncSettings();

  // =========================================================================
  // 3. CORE BACKGROUND ENGINE & BIDIRECTIONAL STRIP READER
  // =========================================================================
function mainWorldCode() {
    if (window.__exFastReaderLoaded) return;
    window.__exFastReaderLoaded = true;

    if ('scrollRestoration' in history) {
      try { history.scrollRestoration = 'manual'; } catch {}
    }

    // Skip all reader/index logic entirely on auth, bounce, and handshake URLs
    if (
      location.search.includes('poni=') ||
      location.pathname.includes('bounce') ||
      location.pathname.includes('login')
    ) {
      return;
    }

    const isReaderPage = location.pathname.startsWith('/s/');
    const isGalleryPage = location.pathname.startsWith('/g/');
    const isIndexPage = !isReaderPage && !isGalleryPage && (
      location.pathname === '/' ||
      location.pathname === '' ||
      location.pathname.startsWith('/watched') ||
      location.pathname.startsWith('/favorites') ||
      location.pathname.startsWith('/popular') ||
      location.pathname.startsWith('/tag/')
    );

    const PREFETCH_DEPTH = 3;
    const IMAGE_TIMEOUT_MS = 10000;
    const MAX_RETRIES = 3;
    const MAX_CONCURRENT_LOADS = 2;

    function safeStorageGet(storage, key, fallback = null) {
      try {
        const val = storage.getItem(key);
        return val !== null ? val : fallback;
      } catch {
        return fallback;
      }
    }

    function safeStorageSet(storage, key, val) {
      try {
        storage.setItem(key, val);
      } catch {}
    }

    function safeStorageRemove(storage, key) {
      try {
        storage.removeItem(key);
      } catch {}
    }

    const pageRespCache = new Map();
    const pageImgKeyCache = new Map();
    const pageImgSrcCache = new Map();
    const pageNlCodes = new Map();
    const preloadedImages = new Map();
    const inFlightFetches = new Map();
    const inFlightGalleryFetches = new Map();
    const fetchedGalleryPages = new Map();

    const pageDimensions = new Map();
    const pageItemMap = new Map();

    const loadedImages = new Set();
    const loadingPages = new Map(); // pageNo -> startTime
    const failedPages = new Map();
    const reloadingPages = new Set();
    const activeRetryTimers = new Map();
    let prefetchThumbTimer = null;
    let prefetchMetaTimer = null;
    let pumpObserverTimer = null;

    function isPageInActiveWindow(pageNo, margin = 6) {
      if (!stripActive || !pageNo) return false;
      const cur = currentPageNum || 1;
      const visible = getVisiblePageNums();
      if (visible.includes(pageNo)) return true;
      const minP = Math.min(...visible, cur) - margin;
      const maxP = Math.max(...visible, cur) + margin;
      return pageNo >= minP && pageNo <= maxP;
    }

    function abortPageLoad(pageNo) {
      if (!pageNo) return;
      if (activeRetryTimers.has(pageNo)) {
        clearTimeout(activeRetryTimers.get(pageNo));
        activeRetryTimers.delete(pageNo);
      }
      reloadingPages.delete(pageNo);
      loadingPages.delete(pageNo);

      const item = pageItemMap.get(pageNo);
      if (item) {
        item.classList.remove('eh-loading');
        if (typeof item._abortImg === 'function') {
          item._abortImg();
          item._abortImg = null;
        } else if (item._currentImgToken) {
          const img = item._currentImgToken;
          img.onload = null;
          img.onerror = null;
          img.removeAttribute('src');
          img.src = '';
          item._currentImgToken = null;
        }
      }
    }

    // ---- Request discipline -------------------------------------------------
    // Fast scrolling used to start a download for every page that flew past, which hammered the
    // server (errors / image limits) and left the page you finally stopped on waiting in line.
    // Global Circuit Breaker & Anti-Flood Manager to prevent request/retry storms
    const apiCircuitBreaker = {
      backoffUntil: 0,
      backoffDelay: 2000,
      consecutiveErrors: 0,
      lastErrorMsg: '',
      isBlocked(isPriority = false) {
        const now = Date.now();
        if (now < this.backoffUntil) {
          if (!isPriority) return true;
          // Priority requests wait or bypass only if remaining backoff is small (< 3.5s)
          return (this.backoffUntil - now) > 3500;
        }
        return false;
      },
      recordSuccess() {
        this.consecutiveErrors = 0;
        this.backoffDelay = 2000;
        this.lastErrorMsg = '';
      },
      recordError(status, errorText = '') {
        this.consecutiveErrors++;
        const now = Date.now();
        const errStr = String(errorText || '');
        const isRateLimit = status === 429 || status === 509 ||
          /too fast|limit exceeded|rate limit|excessive|banned|quota|flood/i.test(errStr);
        const isServerDown = status === 503 || status === 502 || status === 504;

        if (isRateLimit) {
          this.backoffDelay = Math.min(60000, Math.max(8000, this.backoffDelay * 2));
          this.backoffUntil = now + this.backoffDelay;
          this.lastErrorMsg = errStr || (status === 509 ? 'Image/Bandwidth Limit Exceeded (509)' : 'Rate limit exceeded (429)');
          if (typeof showFailureToast === 'function') {
            showFailureToast(`[Ex Fast Reader] ${this.lastErrorMsg}. Requests paused for ${Math.round(this.backoffDelay / 1000)}s.`);
          }
          console.warn(`[ExFastReader] Rate limit encountered (${status}, "${errStr}"). Backoff: ${this.backoffDelay}ms`);
        } else if (isServerDown) {
          this.backoffDelay = Math.min(30000, Math.max(4000, this.backoffDelay * 1.5));
          this.backoffUntil = now + this.backoffDelay;
          console.warn(`[ExFastReader] Server unavailable (${status}). Backoff: ${this.backoffDelay}ms`);
        } else if (this.consecutiveErrors >= 3) {
          this.backoffDelay = Math.min(20000, 2500 * Math.pow(1.5, this.consecutiveErrors - 3));
          this.backoffUntil = now + this.backoffDelay;
          console.warn(`[ExFastReader] ${this.consecutiveErrors} consecutive errors. Backoff: ${Math.round(this.backoffDelay)}ms`);
        }
      },
      reset() {
        this.backoffUntil = 0;
        this.consecutiveErrors = 0;
        this.backoffDelay = 2000;
        this.lastErrorMsg = '';
      }
    };

    function makeLimiter(max, minInterval = 40) {
      let active = 0;
      let lastTime = 0;
      let timer = null;
      const queue = [];

      function pump() {
        timer = null;
        while (active < max && queue.length > 0) {
          const now = Date.now();
          const elapsed = now - lastTime;
          if (elapsed < minInterval) {
            if (!timer) {
              timer = setTimeout(pump, minInterval - elapsed);
            }
            return;
          }
          lastTime = Date.now();
          active++;
          const next = queue.shift();
          if (next) next();
        }
      }

      return {
        acquire(priority) {
          if (active < max && queue.length === 0) {
            const now = Date.now();
            if (now - lastTime >= minInterval) {
              lastTime = now;
              active++;
              return Promise.resolve();
            }
          }
          return new Promise((resolve) => {
            if (priority) queue.unshift(resolve);
            else queue.push(resolve);
            pump();
          });
        },
        release() {
          active = Math.max(0, active - 1);
          pump();
        }
      };
    }
    const apiLimiter = makeLimiter(3, 50);
    const galleryLimiter = makeLimiter(2, 60);

    const SETTLE_MS = 180;
    let lastPageChangeAt = 0;
    const pageChangeTimes = [];
    let settleTimer = null;
    function notePageChange() {
      const now = Date.now();
      lastPageChangeAt = now;
      pageChangeTimes.push(now);
      if (pageChangeTimes.length > 12) pageChangeTimes.shift();
    }
    // True while the user is flicking through pages and hasn't stopped yet
    function isFastScrolling() {
      const now = Date.now();
      if (now - lastPageChangeAt > SETTLE_MS) return false;
      let n = 0;
      for (const t of pageChangeTimes) if (now - t <= 600) n++;
      return n >= 3;
    }
    function scheduleSettleCheck() {
      if (settleTimer) clearTimeout(settleTimer);
      settleTimer = setTimeout(() => {
        settleTimer = null;
        if (stripActive) {
          checkAndLoadNearbyPages();
        } else {
          prefetchSingleMode(currentPageNum);
        }
      }, SETTLE_MS + 30);
    }
    // Tight "still wanted" window used to cancel stale downloads
    function isPageNeeded(p, visible, cur) {
      if (visible.includes(p)) return true;
      const ahead = PREFETCH_DEPTH + 1;
      const lo = Math.min(...visible, cur) - (scrollDirection === 'backward' ? ahead : 2);
      const hi = Math.max(...visible, cur) + (scrollDirection === 'backward' ? 2 : ahead);
      return p >= lo && p <= hi;
    }

    const pageTopBarInfoCache = new Map();
    const pageFileSizeCache = new Map();
    const downloadSpeedSamples = [];
    const MAX_SPEED_SAMPLES = 8;
    const pageThumbnailCache = new Map();

    const pageDisplayDims = new Map();
    // Diagnostics: which code path last wrote a page's size (shown in the "size miss" log line)
    const dimWriters = new Map();
    function noteDimWriter(kind, page) {
      try {
        const frames = (new Error().stack || '').split('\n').slice(2, 8)
          .map((s) => s.trim().replace(/^at\s+/, '').replace(/\s*\(.*[\/:]([^\/:]+:\d+):\d+\)$/, ' ($1)'));
        dimWriters.set(kind + ':' + page, frames.join(' < '));
      } catch {}
    }
    const pageFailedSrc = new Map(); // page -> last image URL that failed to load (so failover never re-serves it)
    const knownDisplaySizes = [];
    // "originalW x originalH" -> served display size. Pages with the same original size are served at
    // exactly the same size, so a placeholder can reuse it with no rounding at all.
    const displayByOriginal = new Map();
    // The server resizes originals down to the resolution cap; whether it floors, rounds or ceils the
    // height is unknown, so learn it from every page where original and served sizes are both known.
    const roundVotes = { floor: 0, round: 0, ceil: 0 };
    function serverRound(v) {
      let mode = 'round';
      let best = roundVotes.round;
      if (roundVotes.floor > best) { mode = 'floor'; best = roundVotes.floor; }
      if (roundVotes.ceil > best) { mode = 'ceil'; best = roundVotes.ceil; }
      return mode === 'floor' ? Math.floor(v) : (mode === 'ceil' ? Math.ceil(v) : Math.round(v));
    }
    // What each placeholder was sized with, so a wrong guess is logged when the real size arrives
    const placeholderGuess = new Map();
    const pageBottomCache = new Map();
    // =======================================================================
    // THUMBNAIL SUBSYSTEM
    // The gallery gives us either sprite sheets (many thumbs per image; each page is a region
    // x/y/w/h inside it) or one small image per page. Either way the "sheet" image is loaded ONCE,
    // kept in a small LRU, and every placeholder gets its own tiny <canvas> with only its region
    // drawn into it. Nothing ever scales a whole sheet by a huge percentage, and nothing depends on
    // knowing the sheet's size up front (which is what used to produce stretched placeholders).
    // =======================================================================
    const THUMB_SHEET_LIMIT = 90;
    const thumbSheets = new Map(); // url -> { url, img, ready, failedAt, tries, waiters:Set<page> }

    const thumbLimiter = makeLimiter(4);

    function startThumbSheetLoad(entry, high) {
      entry.ready = false;
      entry.failedAt = 0;
      if (entry.queued) return;
      entry.queued = true;

      // Bounded concurrency; the newest high-priority request (the page you are on) goes first
      thumbLimiter.acquire(high).then(() => {
        let freed = false;
        const free = () => {
          if (!freed) { freed = true; entry._free = null; thumbLimiter.release(); }
        };
        entry.queued = false;
        if (thumbSheets.get(entry.url) !== entry) { free(); return; } // evicted while waiting

        const img = new Image();
        img.decoding = 'async';
        img.fetchPriority = high ? 'high' : 'low';
        entry.img = img;
        entry._free = free;

        img.onload = () => {
          img.onload = null;
          img.onerror = null;
          free();
          if (thumbSheets.get(entry.url) !== entry || entry.img !== img) return;
          if (!(img.naturalWidth > 0 && img.naturalHeight > 0)) {
            failThumbSheet(entry);
            return;
          }
          entry.ready = true;
          entry.tries = 0;
          const pages = Array.from(entry.waiters);
          entry.waiters.clear();
          for (const p of pages) notifyThumbReady(p);
        };
        img.onerror = () => {
          img.onload = null;
          img.onerror = null;
          free();
          if (thumbSheets.get(entry.url) !== entry || entry.img !== img) return;
          failThumbSheet(entry);
        };
        img.src = entry.url;
      });
    }

    // A thumbnail URL that 404s is a dead H@H node/file; asking the same URL again (this used to
    // happen 3x, then again every 15s) only repeats the 404. Instead drop it, remember it is dead,
    // and re-read the gallery page, which hands out a fresh thumbnail URL (usually another node).
    const deadThumbUrls = new Map();       // url -> time it failed
    const galleryThumbRefresh = new Map(); // gallery page index -> { count, last }

    function failThumbSheet(entry) {
      const url = entry.url;
      entry.ready = false;
      entry.failedAt = Date.now();
      deadThumbUrls.set(url, Date.now());

      const gIdxs = new Set();
      for (const [p, info] of Array.from(pageThumbnailCache.entries())) {
        if (info && info.url === url) {
          pageThumbnailCache.delete(p);
          gIdxs.add(Math.max(0, Math.floor((p - 1) / thumbsPerPage)));
        }
      }
      for (const p of entry.waiters) {
        gIdxs.add(Math.max(0, Math.floor((p - 1) / thumbsPerPage)));
      }
      entry.waiters.clear();
      releaseThumbSheet(entry);
      thumbSheets.delete(url);

      for (const g of gIdxs) {
        const st = galleryThumbRefresh.get(g) || { count: 0, last: 0 };
        if (st.count >= 2 || Date.now() - st.last < 4000) continue;
        st.count += 1;
        st.last = Date.now();
        galleryThumbRefresh.set(g, st);
        fetchedGalleryPages.delete(g);
        setTimeout(() => { fetchGalleryPage(g, false, true); }, 1500 * st.count);
      }
    }

    function releaseThumbSheet(entry) {
      if (!entry) return;
      if (entry._free) entry._free();
      if (!entry.img) return;
      entry.img.onload = null;
      entry.img.onerror = null;
      entry.img.removeAttribute('src');
      entry.img.src = '';
      entry.ready = false;
    }

    function getThumbSheet(url, high = false) {
      if (!url) return null;
      const deadAt = deadThumbUrls.get(url);
      if (deadAt && Date.now() - deadAt < 120000) return null;
      let entry = thumbSheets.get(url);
      if (entry) {
        // refresh LRU position
        thumbSheets.delete(url);
        thumbSheets.set(url, entry);
        if (!entry.ready && entry.failedAt && Date.now() - entry.failedAt > 15000) {
          entry.tries = 0;
          startThumbSheetLoad(entry, high);
        }
        return entry;
      }
      entry = { url, img: null, ready: false, failedAt: 0, tries: 0, queued: false, _free: null, waiters: new Set() };
      thumbSheets.set(url, entry);
      startThumbSheetLoad(entry, high);
      while (thumbSheets.size > THUMB_SHEET_LIMIT) {
        const oldKey = thumbSheets.keys().next().value;
        releaseThumbSheet(thumbSheets.get(oldKey));
        thumbSheets.delete(oldKey);
      }
      return entry;
    }

    let lastWarmPage = -1;
    function warmThumbsAround(page, force = false) {
      if (!isReaderPage || !page) return;
      if (stripActive && isFastScrolling()) return; // flicking past: only load what is actually shown
      if (!force && page === lastWarmPage) return;
      lastWarmPage = page;
      const lo = Math.max(1, page - 8);
      const hi = page + 24;
      for (let p = lo; p <= hi; p++) {
        const info = pageThumbnailCache.get(p);
        if (info && info.url) getThumbSheet(info.url, Math.abs(p - page) <= 3);
      }
    }

    function createThumbCanvas(info, entry) {
      const img = entry.img;
      if (!img || !(img.naturalWidth > 0 && img.naturalHeight > 0)) return null;

      let sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight;
      if (info.type === 'sprite') {
        sx = info.x || 0;
        sy = info.y || 0;
        sw = info.w;
        sh = info.h;
        if (!(sw > 0 && sh > 0)) return null;
        if (sx >= img.naturalWidth || sy >= img.naturalHeight) return null;
        // Never read outside the sheet: a bad offset must not smear into a neighbour
        sw = Math.min(sw, img.naturalWidth - sx);
        sh = Math.min(sh, img.naturalHeight - sy);
        if (!(sw > 0 && sh > 0)) return null;
      }

      const canvas = document.createElement('canvas');
      canvas.width = Math.max(1, Math.round(sw));
      canvas.height = Math.max(1, Math.round(sh));
      canvas.className = 'eh-placeholder-thumb-img eh-thumb-canvas';
      try {
        const ctx = canvas.getContext('2d');
        ctx.imageSmoothingEnabled = true;
        ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
      } catch {
        return null;
      }
      return canvas;
    }

    // Puts the page's thumbnail into `host` (a strip item or the single-page placeholder wrapper).
    // Idempotent. Returns true when the thumbnail is on screen, false if it is not available yet
    // (in which case the page is queued and repainted as soon as its sheet has loaded).
    function paintThumb(host, page) {
      if (!host || !page) return false;
      const info = pageThumbnailCache.get(page);
      if (!info || !info.url) return false;

      const existing = host.querySelector(':scope > .eh-placeholder-thumb-wrap');
      const keyOf = () => [page, info.url, info.x || 0, info.y || 0, info.w || 0, info.h || 0].join('|');
      if (existing && host._thumbKey === keyOf()) return true;

      const entry = getThumbSheet(info.url, Math.abs(page - (currentPageNum || page)) <= 3);
      if (!entry) return false;
      if (!entry.ready) {
        entry.waiters.add(page);
        return false;
      }

      const canvas = createThumbCanvas(info, entry);
      if (!canvas) return false;

      // Single-image thumbnails only learn their size once loaded
      let learnedAr = 0;
      if (info.type === 'url') {
        if (entry.img.naturalWidth > 0 && entry.img.naturalHeight > 0) {
          info.w = entry.img.naturalWidth;
          info.h = entry.img.naturalHeight;
          learnedAr = info.w / info.h;
        }
      }

      // Fill the placeholder exactly (identical to how the final image is fitted). Only if the
      // thumbnail's ratio is clearly off (> 6%) crop instead of stretching it.
      const boxAr = (pageDimensions.get(page) || {}).aspectRatio || 0;
      const thumbAr = canvas.width / canvas.height;
      canvas.style.objectFit = (boxAr > 0 && Math.abs(thumbAr / boxAr - 1) > 0.06) ? 'cover' : 'fill';

      const wrap = document.createElement('div');
      wrap.className = 'eh-placeholder-thumb-wrap';
      wrap.appendChild(canvas);
      if (existing) {
        existing.replaceWith(wrap);
      } else {
        const content = host.querySelector(':scope > .eh-placeholder-content');
        host.insertBefore(wrap, content || null);
      }
      host._thumbKey = keyOf();

      if (learnedAr > 0) updateThumbnailAspectRatio(page, learnedAr);
      return true;
    }

    // A thumbnail (or its sheet) just became available for this page: paint it wherever it is shown.
    function notifyThumbReady(page) {
      const item = pageItemMap.get(page);
      if (item && item.classList.contains('eh-placeholder') && !item.querySelector('img.eh-main-img')) {
        applyItemDimensions(item, page);
        paintThumb(item, page);
      }
      if (!stripActive && page === currentPageNum) {
        showSinglePageThumbnailPlaceholder(page);
      }
    }
    let maxDisplayW = 0;
    let maxDisplayH = 0;
    let lastExactW = parseInt(safeStorageGet(sessionStorage, 'eh_last_exact_w', '0'), 10) || 0;
    if (lastExactW < 400) lastExactW = 0;
    let lastExactH = parseInt(safeStorageGet(sessionStorage, 'eh_last_exact_h', '0'), 10) || 0;
    if (lastExactH < 400) lastExactH = 0;
    // Width cap the site applies to served images (your "image resolution" setting).
    // Remembered for the session so even the very first placeholder is the right size.
    let displayCapW = parseInt(safeStorageGet(sessionStorage, 'eh_display_cap_w', '0'), 10) || 0;
    if (displayCapW < 400) displayCapW = 0;

    let cachedGid = null;
    let cachedShowkey = null;
    let cachedApiUrl = null;
    let lastSyncedIndicatorPage = -1;

    let thumbsPerPage = parseInt(safeStorageGet(localStorage, 'eh_thumbs_per_page', '0'), 10) || 40;
    let defaultAspectRatio = 0.707;
    let detectedDisplayLimit = 0;
    let cachedTotalPages = 0;
    let stallTimer = null;
    let isReloading = false;
    let currentPageNum = (function () {
      if (typeof isReaderPage !== 'undefined' && isReaderPage) {
        const m = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/\d+-(\d+)/i);
        if (m) return parseInt(m[1], 10) || 1;
        if (typeof window.currentpage === 'number' && window.currentpage > 0) return window.currentpage;
        if (typeof window.startpage === 'number' && window.startpage > 0) return window.startpage;
      }
      return 1;
    })();
    let lastSingleTurnTime = 0;

    function parseFileSizeInBytes(rawStr) {
      if (!rawStr || typeof rawStr !== 'string') return 0;
      const parts = rawStr.split('::');
      const targetStr = parts.length >= 3 ? parts[2] : rawStr;
      const m = targetStr.match(/(\d+(?:\.\d+)?)\s*([kKMmGg]?[iI]?[bB]+)/i) ||
                rawStr.match(/(\d+(?:\.\d+)?)\s*([kKMmGg][iI]?[bB]+)/i);
      if (!m) return 0;
      const num = parseFloat(m[1]);
      if (isNaN(num) || num <= 0) return 0;
      const unit = m[2].toUpperCase();
      if (unit.startsWith('G')) return Math.round(num * 1024 * 1024 * 1024);
      if (unit.startsWith('M')) return Math.round(num * 1024 * 1024);
      if (unit.startsWith('K')) return Math.round(num * 1024);
      if (unit.startsWith('B')) return Math.round(num);
      return 0;
    }

    function getPageFileSize(pageNo) {
      if (!pageNo) return 1.5 * 1024 * 1024;
      if (pageFileSizeCache.has(pageNo)) {
        const cached = pageFileSizeCache.get(pageNo);
        if (cached > 0) return cached;
      }

      const topInfo = pageTopBarInfoCache.get(pageNo);
      if (topInfo) {
        const sz = parseFileSizeInBytes(topInfo);
        if (sz > 0) {
          pageFileSizeCache.set(pageNo, sz);
          return sz;
        }
      }

      if (pageRespCache.has(pageNo)) {
        const resp = pageRespCache.get(pageNo);
        const candidates = [resp.i, resp.d, resp.i5, resp.i2, resp.i4, resp.i1, resp.i3];
        for (const c of candidates) {
          if (!c) continue;
          const sz = parseFileSizeInBytes(c);
          if (sz > 0) {
            pageFileSizeCache.set(pageNo, sz);
            return sz;
          }
        }
      }

      if (pageNo === currentPageNum) {
        const raw = (document.getElementById('i2')?.textContent || '') + ' ' +
                    (document.getElementById('i4')?.textContent || '') + ' ' +
                    (document.getElementById('i1')?.textContent || '');
        const sz = parseFileSizeInBytes(raw);
        if (sz > 0) {
          pageFileSizeCache.set(pageNo, sz);
          return sz;
        }
      }

      const dim = pageDimensions.get(pageNo);
      if (dim && dim.width > 0 && dim.height > 0) {
        const estimated = Math.round(dim.width * dim.height * 0.35);
        if (estimated > 100 * 1024) {
          return estimated;
        }
      }

      return 1.5 * 1024 * 1024;
    }

    function recordDownloadSpeedSample(pageNo, durationSec) {
      if (!durationSec || durationSec <= 0.05) return;
      const sizeBytes = getPageFileSize(pageNo);
      if (!sizeBytes || sizeBytes <= 0) return;
      const speed = sizeBytes / durationSec;
      if (speed <= 0 || !isFinite(speed)) return;

      downloadSpeedSamples.push({ speed, timestamp: Date.now() });
      while (downloadSpeedSamples.length > MAX_SPEED_SAMPLES) {
        downloadSpeedSamples.shift();
      }

      const currentSpeed = getCurrentDownloadSpeed();
      safeStorageSet(sessionStorage, 'eh_last_speed', String(Math.round(currentSpeed)));
    }

    function getCurrentDownloadSpeed() {
      const now = Date.now();
      while (downloadSpeedSamples.length > 0 && now - downloadSpeedSamples[0].timestamp > 300000) {
        downloadSpeedSamples.shift();
      }

      if (downloadSpeedSamples.length > 0) {
        const sorted = downloadSpeedSamples.map(s => s.speed).sort((a, b) => a - b);
        const mid = Math.floor(sorted.length / 2);
        const medianSpeed = sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
        return Math.max(100 * 1024, medianSpeed);
      }

      const saved = parseInt(safeStorageGet(sessionStorage, 'eh_last_speed', '0'), 10);
      if (saved > 50 * 1024 && saved < 500 * 1024 * 1024) {
        return saved;
      }

      if (typeof navigator !== 'undefined' && navigator.connection && typeof navigator.connection.downlink === 'number' && navigator.connection.downlink > 0) {
        const bps = (navigator.connection.downlink * 1000 * 1000) / 8;
        return Math.max(200 * 1024, bps);
      }

      return 1.5 * 1024 * 1024;
    }

    function calculateImageTimeout(pageNo) {
      const fileSize = getPageFileSize(pageNo);
      const speed = getCurrentDownloadSpeed();

      const expectedTransferSec = fileSize / speed;
      const baseLatencySec = 4.0;

      const slowTimeoutMs = Math.max(6000, Math.round((baseLatencySec + expectedTransferSec * 2.2) * 1000));
      const hardTimeoutMs = Math.max(12000, Math.round((baseLatencySec * 1.5 + expectedTransferSec * 4.0) * 1000));
      const watchdogTimeoutMs = hardTimeoutMs + 5000;

      return {
        fileSize,
        speed,
        expectedTransferSec,
        slowTimeoutMs,
        hardTimeoutMs,
        watchdogTimeoutMs
      };
    }

      function normalizeApiUrl(url) {
          const defaultApi = location.origin + '/api.php';

          if (!url) return defaultApi;
          try {
              const parsed = new URL(url, location.origin);
              const isAllowed =
                    parsed.origin === location.origin ||
                    parsed.hostname === 'api.e-hentai.org' ||
                    parsed.hostname === 'e-hentai.org' ||
                    parsed.hostname === 'exhentai.org' ||
                    parsed.hostname.endsWith('.e-hentai.org') ||
                    parsed.hostname.endsWith('.exhentai.org');

              if (!isAllowed) {
                  return defaultApi;
              }
              return parsed.href;
          } catch {
              return defaultApi;
          }
      }

    function safeParseJson(str) {
      if (!str || typeof str !== 'string') return null;
      const trimmed = str.trim();
      if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
      try {
        return JSON.parse(trimmed);
      } catch {
        return null;
      }
    }

      let _currentApiUrl = location.origin + '/api.php';

    if (isReaderPage) {
      try {
        if (window.api_url) {
          _currentApiUrl = normalizeApiUrl(window.api_url);
        }
        Object.defineProperty(window, 'api_url', {
          configurable: true,
          enumerable: true,
          get() {
            return _currentApiUrl;
          },
          set(val) {
            _currentApiUrl = normalizeApiUrl(val);
          }
        });
      } catch (e) {
        window.api_url = _currentApiUrl;
      }

      if (typeof window.api_response === 'function' && !window.api_response.__eh_safe) {
        const origApiResponse = window.api_response;
        window.api_response = function (xhr) {
          try {
            if (xhr && typeof xhr.responseText === 'string') {
              const parsed = safeParseJson(xhr.responseText);
              if (parsed) return parsed;
              return { error: xhr.responseText.trim() || 'Invalid response' };
            }
            return origApiResponse.apply(this, arguments);
          } catch {
            return { error: 'Invalid JSON response' };
          }
        };
        window.api_response.__eh_safe = true;
      }
    }

    function pruneGalleryLocalStorage(newGid) {
      try {
        let list = JSON.parse(safeStorageGet(localStorage, 'eh_gdata_recent', '[]') || '[]');
        list = list.filter(id => id !== newGid);
        list.unshift(newGid);
        while (list.length > 8) {
          const oldGid = list.pop();
          safeStorageRemove(localStorage, 'eh_gdata_' + oldGid);
          safeStorageRemove(localStorage, 'eh_gallery_' + oldGid);
          safeStorageRemove(sessionStorage, 'eh_gdata_' + oldGid);
          safeStorageRemove(sessionStorage, 'eh_gallery_' + oldGid);
        }
        safeStorageSet(localStorage, 'eh_gdata_recent', JSON.stringify(list));
      } catch {}
    }

    // Bumped (3 -> 4): sizes saved by earlier versions could contain another page's size
    const GDATA_VERSION = 4;

    function saveGalleryDataToSession(gid) {
      if (!gid) return;
      try {
        const prevStr = safeStorageGet(sessionStorage, 'eh_gdata_' + gid) || safeStorageGet(localStorage, 'eh_gdata_' + gid);
        const prev = prevStr ? JSON.parse(prevStr) : {
          gid,
          totalPages: cachedTotalPages || 0,
          thumbsPerPage: thumbsPerPage || 40,
          keys: {},
          dims: {},
          info: {},
          thumbs: {},
          spriteDims: {}
        };

        if (prev.v !== GDATA_VERSION) {
          // Older data may hold guessed thumbnail sizes or corrupted dims: start those parts clean
          prev.v = GDATA_VERSION;
          prev.dims = {};
          prev.thumbs = {};
          prev.spriteDims = {};
        }
        if (cachedTotalPages) prev.totalPages = cachedTotalPages;
        if (thumbsPerPage) prev.thumbsPerPage = thumbsPerPage;

        for (const [p, k] of pageImgKeyCache.entries()) {
          prev.keys[p] = k;
        }
        for (const [p, d] of pageDimensions.entries()) {
          prev.dims[p] = { w: d.width, h: d.height, ar: d.aspectRatio };
        }
        for (const [p, inf] of pageTopBarInfoCache.entries()) {
          prev.info[p] = inf;
        }
        if (!prev.fileSizes) prev.fileSizes = {};
        for (const [p, sz] of pageFileSizeCache.entries()) {
          prev.fileSizes[p] = sz;
        }
        if (displayCapW) prev.displayCapW = displayCapW;
        if (lastExactW) prev.lastExactW = lastExactW;
        if (lastExactH) prev.lastExactH = lastExactH;
        for (const [p, th] of pageThumbnailCache.entries()) {
          if (!th || !th.url) continue;
          if (th.type === 'sprite' && !(th.w > 0 && th.h > 0)) continue;
          prev.thumbs[p] = th;
        }
        delete prev.spriteDims;

        const serialized = JSON.stringify(prev);
        safeStorageSet(sessionStorage, 'eh_gdata_' + gid, serialized);
        safeStorageSet(localStorage, 'eh_gdata_' + gid, serialized);
        pruneGalleryLocalStorage(gid);
      } catch (err) {
        console.warn('ExFastReader: Failed to save gallery session data', err);
      }
    }

    function loadGalleryDataFromSession(gid) {
      if (!gid) return false;
      try {
        let raw = safeStorageGet(sessionStorage, 'eh_gdata_' + gid);
        if (!raw) {
          raw = safeStorageGet(localStorage, 'eh_gdata_' + gid);
        }
        if (!raw) return false;
        const data = JSON.parse(raw);
        if (!data) return false;
        if (data.v !== GDATA_VERSION) {
          // Legacy data: thumbnails/sheet sizes or corrupted dims: start clean
          data.dims = null;
          data.thumbs = null;
          data.spriteDims = null;
        }

        if (data.totalPages && data.totalPages > cachedTotalPages) {
          cachedTotalPages = data.totalPages;
        }
        if (data.thumbsPerPage) {
          thumbsPerPage = data.thumbsPerPage;
        }
        if (data.keys) {
          for (const [p, k] of Object.entries(data.keys)) {
            pageImgKeyCache.set(parseInt(p, 10), k);
          }
        }
        // Saved per-page sizes are no longer loaded: a wrong one survives across visits and
        // poisons every placeholder guess. Real sizes arrive from the API a few pages ahead.
        if (false && data.dims) {
          for (const [p, d] of Object.entries(data.dims)) {
            const page = parseInt(p, 10);
            const w = (d.w && d.w >= 400) ? d.w : 0;
            const h = (d.h && d.h >= 400) ? d.h : 0;
            if (!pageDimensions.has(page) || pageDimensions.get(page).width === 0) {
              pageDimensions.set(page, { width: w, height: h, aspectRatio: d.ar });
            }
          }
        }
        if (data.info) {
          for (const [p, inf] of Object.entries(data.info)) {
            pageTopBarInfoCache.set(parseInt(p, 10), inf);
          }
        }
        if (data.fileSizes) {
          for (const [p, sz] of Object.entries(data.fileSizes)) {
            pageFileSizeCache.set(parseInt(p, 10), sz);
          }
        }
        if (data.displayCapW && (!displayCapW || displayCapW < 400)) {
          if (data.displayCapW >= 400) {
            displayCapW = data.displayCapW;
            safeStorageSet(sessionStorage, 'eh_display_cap_w', String(displayCapW));
          }
        }
        if (data.lastExactW && (!lastExactW || lastExactW < 400)) {
          if (data.lastExactW >= 400) {
            lastExactW = data.lastExactW;
            lastExactH = data.lastExactH;
            safeStorageSet(sessionStorage, 'eh_last_exact_w', String(lastExactW));
            safeStorageSet(sessionStorage, 'eh_last_exact_h', String(lastExactH));
          }
        }
        if (data.thumbs) {
          for (const [p, th] of Object.entries(data.thumbs)) {
            const page = parseInt(p, 10);
            if (!th || !th.url) continue;
            if (th.type === 'sprite' && !(th.w > 0 && th.h > 0)) continue;
            pageThumbnailCache.set(page, th);
          }
        }

        if (isReaderPage) {
          try { warmThumbsAround(getCurrentPageMeta().page || 1, true); } catch {}
        }
        return true;
      } catch (err) {
        console.warn('ExFastReader: Failed to load gallery session data', err);
        return false;
      }
    }

    let stripActive = safeStorageGet(localStorage, 'eh_strip_mode_enabled', '1') !== '0';
    let currentLayout = safeStorageGet(localStorage, 'eh_strip_layout', 'horizontal');

    function setStripClasses(active, layout) {
      const root = document.documentElement;
      const body = document.body;
      const targets = body ? [root, body] : [root];

      for (const el of targets) {
        if (!el) continue;
        if (active) {
          el.classList.add('eh-strip-mode-active');
          el.classList.remove('strip-horizontal', 'strip-vertical');
          el.classList.add('strip-' + layout);
        } else {
          el.classList.remove('eh-strip-mode-active', 'strip-horizontal', 'strip-vertical', 'eh-topbar-visible');
        }
      }
    }

    if (isReaderPage) {
      setStripClasses(stripActive, currentLayout);
      if (!document.body && stripActive) {
        let bodyObsTimer = null;
        const bodyObs = new MutationObserver(() => {
          if (document.body) {
            if (bodyObsTimer) clearTimeout(bodyObsTimer);
            setStripClasses(stripActive, currentLayout);
            bodyObs.disconnect();
          }
        });
        bodyObs.observe(document.documentElement, { childList: true });
        bodyObsTimer = setTimeout(() => {
          bodyObs.disconnect();
        }, 10000);
      }
    }

    const mInitialGidMatch = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/(\d+)-/i) || location.pathname.match(/\/g\/(\d+)\//i);
    if (mInitialGidMatch) {
      cachedGid = parseInt(mInitialGidMatch[1], 10);
      loadGalleryDataFromSession(cachedGid);
      if (isReaderPage) {
        const initMeta = getCurrentPageMeta();
        currentPageNum = initMeta.page || 1;
        if (initMeta.imgKey) {
          pageImgKeyCache.set(currentPageNum, initMeta.imgKey);
        }
        const pDim = pageDimensions.get(currentPageNum) || (pageDimensions.size > 0 ? pageDimensions.values().next().value : null);
        if (pDim && pDim.aspectRatio) {
          defaultAspectRatio = pDim.aspectRatio;
          if (stripActive) applyGlobalAspectRatio(defaultAspectRatio);
        }

      }
    }
    let lastStripTurnTime = 0;

    let scrollRafId = null;
    let lastHistoryPage = -1;
    let historyDebounceTimer = null;
    let lastUpdatePage = -1;
    let isPumpingQueue = false;
    let isTransitioningLayout = false;
    let isAnchoring = false;

    let targetScrollLeft = null;
    let animScrollRaf = null;
    let targetScrollTop = null;
    let animScrollTopRaf = null;

    let cachedFrontUrl = location.origin + '/';
    let cachedOnionUrl = '';
    let cachedGalleryUrl = '';

    let scrollDirection = 'forward';
    let lastScrollY = typeof window !== 'undefined' ? window.scrollY : 0;
    let lastScrollLeft = 0;
    let stripItemObserver = null;

    function schedulePumpNearbyPages() {
      if (pumpObserverTimer) return;
      pumpObserverTimer = setTimeout(() => {
        pumpObserverTimer = null;
        if (stripActive) checkAndLoadNearbyPages();
      }, 40);
    }

    function getStripItemObserver() {
      if (!stripItemObserver && typeof IntersectionObserver !== 'undefined') {
        stripItemObserver = new IntersectionObserver((entries) => {
          let shouldPump = false;
          for (const entry of entries) {
            if (entry.isIntersecting) {
              const p = parseInt(entry.target.dataset.page, 10);
              if (p) {
                if (entry.target.classList.contains('eh-placeholder') && !entry.target.querySelector('.eh-placeholder-thumb-wrap')) {
                  applyThumbnailToPlaceholder(entry.target, p);
                }
                if (isPageEligibleToLoad(p)) {
                  shouldPump = true;
                }
              }
            }
          }
          if (shouldPump) {
            schedulePumpNearbyPages();
          }
        }, {
          root: currentLayout === 'horizontal' ? document.getElementById('eh-strip-container') : null,
          rootMargin: '350px'
        });
      }
      return stripItemObserver;
    }

    function cancelKeyboardAnimation() {
      if (animScrollRaf) {
        cancelAnimationFrame(animScrollRaf);
        animScrollRaf = null;
      }
      if (animScrollTopRaf) {
        cancelAnimationFrame(animScrollTopRaf);
        animScrollTopRaf = null;
      }
      targetScrollLeft = null;
      targetScrollTop = null;
    }

    function withScrollAnchor(fn, targetPageNo = null) {
      if (!stripActive || isAnchoring || isTransitioningLayout) {
        fn();
        return;
      }

      const container = document.getElementById('eh-strip-container');
      if (!container) {
        fn();
        return;
      }

      if (targetPageNo !== null && targetPageNo > currentPageNum) {
        fn();
        return;
      }

      // If user is actively scrolling, don't intervene with scroll modifications
      if (Date.now() - lastScrollTime < 350 || isFastScrolling()) {
        fn();
        return;
      }

      isAnchoring = true;
      try {
        let anchorEl = null;
        let anchorRightBefore = 0;
        let anchorTopBefore = 0;

        const vpX = window.innerWidth / 2;
        const vpY = window.innerHeight / 2;

        let closestDist = Infinity;
        for (const [p, item] of pageItemMap.entries()) {
          if (Math.abs(p - currentPageNum) > 3) continue;
          if (!item || !item.isConnected) continue;
          const r = item.getBoundingClientRect();
          if (currentLayout === 'horizontal') {
            if (r.right > 0 && r.left < window.innerWidth) {
              const center = (r.left + r.right) / 2;
              const dist = Math.abs(center - vpX);
              if (dist < closestDist) {
                closestDist = dist;
                anchorEl = item;
              }
            }
          } else {
            if (r.bottom > 0 && r.top < window.innerHeight) {
              const center = (r.top + r.bottom) / 2;
              const dist = Math.abs(center - vpY);
              if (dist < closestDist) {
                closestDist = dist;
                anchorEl = item;
              }
            }
          }
        }

        if (!anchorEl) {
          anchorEl = pageItemMap.get(currentPageNum);
        }

        if (anchorEl && anchorEl.isConnected) {
          const r = anchorEl.getBoundingClientRect();
          anchorRightBefore = r.right;
          anchorTopBefore = r.top;
        }

        fn();

        if (anchorEl && anchorEl.isConnected && (anchorRightBefore || anchorTopBefore)) {
          const rAfter = anchorEl.getBoundingClientRect();
          if (currentLayout === 'horizontal') {
            const deltaX = rAfter.right - anchorRightBefore;
            if (Math.abs(deltaX) >= 2.0) {
              const roundX = Math.round(deltaX);
              container.scrollLeft += roundX;
              lastScrollLeft = container.scrollLeft;
              if (targetScrollLeft !== null) {
                targetScrollLeft += roundX;
                const maxScroll = 0;
                const minScroll = -(container.scrollWidth - container.clientWidth);
                targetScrollLeft = Math.max(minScroll, Math.min(maxScroll, targetScrollLeft));
              }
            }
          } else {
            const deltaY = rAfter.top - anchorTopBefore;
            if (Math.abs(deltaY) >= 2.0) {
              const roundY = Math.round(deltaY);
              window.scrollBy(0, roundY);
              lastScrollY = window.scrollY;
              if (targetScrollTop !== null) {
                targetScrollTop += roundY;
                const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
                targetScrollTop = Math.max(0, Math.min(maxScroll, targetScrollTop));
              }
            }
          }
        }
      } finally {
        isAnchoring = false;
      }
    }

    function scrollByEvenIncrement(delta) {
      if (typeof disarmSingleAlign === 'function') disarmSingleAlign();
      if (delta < 0) {
        scrollDirection = 'backward';
      } else if (delta > 0) {
        scrollDirection = 'forward';
      }

      if (stripActive && currentLayout === 'horizontal') {
        const container = document.getElementById('eh-strip-container');
        if (!container) return;

        const maxScroll = 0;
        const minScroll = -(container.scrollWidth - container.clientWidth);

        if (targetScrollLeft === null || isNaN(targetScrollLeft)) {
          targetScrollLeft = container.scrollLeft;
        }

        targetScrollLeft -= delta;
        if (targetScrollLeft > maxScroll) targetScrollLeft = maxScroll;
        if (targetScrollLeft < minScroll) targetScrollLeft = minScroll;

        animateHorizontalScroll(container);
      } else {
        const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
        let targetTop = (targetScrollTop !== null && !isNaN(targetScrollTop)) ? targetScrollTop : window.scrollY;
        targetTop += delta;
        if (targetTop < 0) targetTop = 0;
        if (targetTop > maxScroll) targetTop = maxScroll;
        targetScrollTop = targetTop;

        animateVerticalScroll();
      }
    }

    function animateHorizontalScroll(container) {
      if (animScrollRaf) return;

      function step() {
        if (targetScrollLeft === null) {
          animScrollRaf = null;
          return;
        }

        const current = container.scrollLeft;
        const diff = targetScrollLeft - current;

        if (Math.abs(diff) <= 1) {
          container.scrollLeft = targetScrollLeft;
          targetScrollLeft = null;
          animScrollRaf = null;
          scheduleScrollUpdate();
          return;
        }

        const move = diff * 0.28;
        const nextVal = current + (Math.abs(move) < 1 ? Math.sign(diff) : move);
        container.scrollLeft = nextVal;
        if (container.scrollLeft === current) {
          targetScrollLeft = null;
          animScrollRaf = null;
          scheduleScrollUpdate();
          return;
        }
        scheduleScrollUpdate();
        animScrollRaf = requestAnimationFrame(step);
      }

      animScrollRaf = requestAnimationFrame(step);
    }

    function animateVerticalScroll() {
      if (animScrollTopRaf) return;

      function step() {
        if (targetScrollTop === null) {
          animScrollTopRaf = null;
          return;
        }

        const current = window.scrollY;
        const diff = targetScrollTop - current;

        if (Math.abs(diff) <= 1) {
          window.scrollTo(0, targetScrollTop);
          targetScrollTop = null;
          animScrollTopRaf = null;
          scheduleScrollUpdate();
          return;
        }

        const move = diff * 0.28;
        const nextVal = current + (Math.abs(move) < 1 ? Math.sign(diff) : move);
        window.scrollTo(0, nextVal);
        if (window.scrollY === current) {
          targetScrollTop = null;
          animScrollTopRaf = null;
          scheduleScrollUpdate();
          return;
        }
        scheduleScrollUpdate();
        animScrollTopRaf = requestAnimationFrame(step);
      }

      animScrollTopRaf = requestAnimationFrame(step);
    }

    function extractAndHideBottomLinks() {
      let frontUrl = location.origin + '/';
      let onionUrl = '';

      if (location.hostname.includes('exhentai')) {
        onionUrl = 'http://exhentai55ld2wyap5juskbm67czulomrouspvlunwfuhqack263eccd.onion';
      } else {
        onionUrl = 'http://e-hentai.org';
      }

      const footerLinks = document.querySelectorAll('#footer a, .ft a, .dp a, #dp a');
      for (const a of footerLinks) {
        const href = a.getAttribute('href') || a.href || '';
        const txt = a.textContent.trim().toLowerCase();
        if (href.includes('wiki') || href.includes('forum')) continue;
        if (txt === 'front page' || txt === 'front' || txt.startsWith('front page')) {
          try {
            const u = new URL(href, location.origin);
            if (u.origin === location.origin) frontUrl = u.href;
          } catch {}
        }
        if (txt.includes('onion') || txt.includes('tor') || href.includes('.onion')) {
          if (href) onionUrl = href;
        }
      }

      cachedFrontUrl = frontUrl;
      cachedOnionUrl = onionUrl;
      return { frontUrl, onionUrl };
    }

    function syncThemeClass() {
      const isDark =
        location.hostname.includes('exhentai') ||
        (document.body &&
          window.getComputedStyle(document.body).backgroundColor.includes('52') &&
          window.getComputedStyle(document.body).backgroundColor.includes('53'));
      document.documentElement.classList.remove('eh-theme-dark', 'eh-theme-light');
      document.documentElement.classList.add(isDark ? 'eh-theme-dark' : 'eh-theme-light');
    }

    function clearFailureToast() {
      const t = document.getElementById('eh-toast-fail');
      if (t) t.remove();
    }

function showFailureToast(msg) {
      // Disabled: do not display error toast
      return;
    }

    function parseTemplate(html) {
      const t = document.createElement('template');
      t.innerHTML = html;
      return t.content;
    }

    function getBaseGalleryUrl() {
      if (cachedGalleryUrl && /\/g\/\d+\/[0-9a-fA-F]{10}/i.test(cachedGalleryUrl)) {
        return cachedGalleryUrl.replace(/\?.*$/, '').replace(/\/?$/, '/');
      }
      const mPath = location.pathname.match(/\/s\/[0-9a-zA-Z]+\/(\d+)-/i);
      const curGid = mPath ? mPath[1] : (getPageParams()?.gid || '');

      if (curGid) {
        const stored = safeStorageGet(sessionStorage, 'eh_gallery_' + curGid) || safeStorageGet(localStorage, 'eh_gallery_' + curGid);
        if (stored && /\/g\/\d+\/[0-9a-fA-F]{10}/i.test(stored)) {
          cachedGalleryUrl = stored.replace(/\?.*$/, '').replace(/\/?$/, '/');
          return cachedGalleryUrl;
        }
      }

      // 1. Check all <a> tags for exact /g/{gid}/{token}/ link
      const links = document.querySelectorAll('a[href*="/g/"]');
      for (const a of links) {
        const href = a.getAttribute('href') || a.href || '';
        const m = href.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        if (m) {
          if (!curGid || m[1] === String(curGid)) {
            cachedGalleryUrl = `${location.origin}/g/${m[1]}/${m[2]}/`;
            if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
            return cachedGalleryUrl;
          }
        }
      }

      // 2. Check document.referrer
      if (document.referrer) {
        const mRef = document.referrer.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        if (mRef && (!curGid || mRef[1] === String(curGid))) {
          cachedGalleryUrl = `${location.origin}/g/${mRef[1]}/${mRef[2]}/`;
          if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
          return cachedGalleryUrl;
        }
      }

      // 3. Scan scripts without whole-DOM serialization
      for (const s of document.scripts) {
        const txt = s.textContent || '';
        if (curGid) {
          const rGid = new RegExp('/g/' + curGid + '/([0-9a-fA-F]{10})', 'i');
          const mGid = txt.match(rGid);
          if (mGid) {
            cachedGalleryUrl = `${location.origin}/g/${curGid}/${mGid[1]}/`;
            safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl);
            return cachedGalleryUrl;
          }
        }
        const mAny = txt.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        if (mAny) {
          cachedGalleryUrl = `${location.origin}/g/${mAny[1]}/${mAny[2]}/`;
          if (curGid) { safeStorageSet(sessionStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); safeStorageSet(localStorage, 'eh_gallery_' + curGid, cachedGalleryUrl); }
          return cachedGalleryUrl;
        }
      }

      return '';
    }

    function detectInitialThumbsPerPage() {
      const curMeta = getCurrentPageMeta();
      const page = curMeta.page;
      const refUrl = document.referrer || '';
      let foundP = null;

      const returnLinks = document.querySelectorAll('a[href*="/g/"]');
      for (const a of returnLinks) {
        const href = a.getAttribute('href') || a.href || '';
        const m = href.match(/[?&]p=(\d+)/);
        if (m) {
          foundP = parseInt(m[1], 10);
          break;
        }
      }
      if (foundP === null && refUrl) {
        const m = refUrl.match(/[?&]p=(\d+)/);
        if (m) foundP = parseInt(m[1], 10);
      }

      if (foundP !== null && page > 1) {
        for (const count of [20, 40, 100]) {
          if (Math.floor((page - 1) / count) === foundP) {
            thumbsPerPage = count;
            safeStorageSet(localStorage, 'eh_thumbs_per_page', String(count));
            break;
          }
        }
      }
    }

    function getGalleryUrl(pageNo = null) {
      const base = getBaseGalleryUrl();
      if (!base) return '';
      const targetPage = pageNo || currentPageNum || 1;
      const count = (typeof thumbsPerPage === 'number' && thumbsPerPage > 0) ? thumbsPerPage : 40;
      const pageIndex = Math.max(0, Math.floor((targetPage - 1) / count));
      try {
        const u = new URL(base, location.origin);
        if (pageIndex > 0) {
          u.searchParams.set('p', String(pageIndex));
        } else {
          u.searchParams.delete('p');
        }
        return u.href;
      } catch {
        const cleanBase = base.replace(/[?&]p=\d+/g, '').replace(/\?$/, '');
        return pageIndex > 0 ? (cleanBase + (cleanBase.includes('?') ? '&' : '?') + 'p=' + pageIndex) : cleanBase;
      }
    }

    function getCurrentPageMeta() {
      const m = location.pathname.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
      let page = m ? parseInt(m[2], 10) : 0;
      let key = m ? m[1] : '';

      if (!page && typeof window.currentpage === 'number' && window.currentpage > 0) {
        page = window.currentpage;
      }
      if (!page && typeof window.startpage === 'number' && window.startpage > 0) {
        page = window.startpage;
      }
      if (!key && typeof window.currentkey === 'string' && window.currentkey) {
        key = window.currentkey;
      }
      if (!key && typeof window.startkey === 'string' && window.startkey) {
        key = window.startkey;
      }

      if (!page || isNaN(page)) {
        const curSpan = document.querySelector('#i2 .sn span:first-child, #i4 .sn span:first-child');
        if (curSpan) {
          const p = parseInt(curSpan.textContent.trim(), 10);
          if (!isNaN(p)) page = p;
        }
      }
      return { page: Math.max(1, page || 1), imgKey: key };
    }

    function applyGlobalAspectRatio(ar) {
      if (!ar || isNaN(ar) || ar <= 0) return;
      document.documentElement.style.setProperty('--eh-ar', String(ar));
      const container = document.getElementById('eh-strip-container');
      if (container) {
        container.style.setProperty('--eh-ar', String(ar));
      }
    }

    function extractNlCode(source = document) {
      if (!source) return null;
      if (typeof source === 'object' && !(source instanceof Node)) {
        if (typeof source.nl === 'string' && source.nl.trim()) return source.nl.trim();
        const searchSources = [source.i6, source.i5, source.i7, source.i3, source.i2, source.i4, source.i1, source.n];
        for (const s of searchSources) {
          if (!s || typeof s !== 'string') continue;
          const m = s.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i) ||
                    s.match(/id=["']loadfail["'][^>]*onclick=["']return\s*nl\(['"]([^'"]+)['"]\)/i) ||
                    s.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
          if (m) return m[1].trim();
        }
        try {
          const raw = JSON.stringify(source);
          const mRaw = raw.match(/nl\s*(?:\\(|\()\s*['"\ ]*([0-9a-zA-Z\-_]+)['"\ ]*\s*(?:\\)|\))/i) ||
                       raw.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
          if (mRaw) return mRaw[1].trim();
        } catch {}
        return null;
      }

      const doc = source;
      const loadfail = doc.getElementById ? doc.getElementById('loadfail') : null;
      if (loadfail) {
        const m = (loadfail.getAttribute('onclick') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
        if (m) return m[1].trim();
        const mHref = (loadfail.getAttribute('href') || '').match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
        if (mHref) return mHref[1].trim();
      }
      const img = doc.getElementById ? doc.getElementById('img') : null;
      if (img) {
        const m = (img.getAttribute('onerror') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
        if (m) return m[1].trim();
      }
      const allNlLinks = doc.querySelectorAll ? doc.querySelectorAll('a[onclick*="nl("], a[href*="nl="]') : [];
      for (const a of allNlLinks) {
        const m = (a.getAttribute('onclick') || '').match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
        if (m) return m[1].trim();
        const mHref = (a.getAttribute('href') || '').match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
        if (mHref) return mHref[1].trim();
      }
      const i3 = doc.getElementById ? doc.getElementById('i3') : null;
      if (i3) {
        const m = i3.innerHTML.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
        if (m) return m[1].trim();
      }
      const i6 = doc.getElementById ? doc.getElementById('i6') : null;
      if (i6) {
        const m = i6.innerHTML.match(/nl\s*\(\s*['"]\s*([^'"]+?)\s*['"]\s*\)/i);
        if (m) return m[1].trim();
      }
      if (typeof location !== 'undefined' && location.search) {
        const mUrl = location.search.match(/[?&]nl=([0-9a-zA-Z\-_]+)/i);
        if (mUrl) return mUrl[1].trim();
      }
      return null;
    }
    const extractInitialNlCode = extractNlCode;

    // xres/yres, #img's inline size and the on-page info text only describe the page the tab was
    // OPENED on. initImageMonitor() calls this on every page turn, and by then they still describe
    // the PREVIOUS page (our own syncNativeExtents even writes xres/yres), so the previous page's
    // size was being stored as the new page's size and marked exact. That is what produced
    // "guessed 1280x1856, actual 1280x1995" (and the reverse) in single-page mode. Trust them once,
    // for the initial page only; every later size comes from the API (x/y) or the loaded image.
    let initialDimsPage = 0;
    let initialDimsDone = false;
    function detectInitialPageDimensions() {
      if (!initialDimsPage) initialDimsPage = currentPageNum;
      if (initialDimsDone || currentPageNum !== initialDimsPage) return;
      let foundW = 0, foundH = 0;
      if (typeof window.xres === 'number' && typeof window.yres === 'number' && window.xres > 0 && window.yres > 0) {
        foundW = window.xres;
        foundH = window.yres;
      }
      if (!foundW || !foundH) {
        for (const s of document.scripts) {
          const txt = s.textContent || '';
          const mX = txt.match(/var\s+xres\s*=\s*(\d+)/);
          const mY = txt.match(/var\s+yres\s*=\s*(\d+)/);
          if (mX && mY) {
            foundW = parseInt(mX[1], 10);
            foundH = parseInt(mY[1], 10);
            break;
          }
        }
      }

      const curImg = document.getElementById('img');
      if (curImg) {
        const sw = parseInt(curImg.style?.width, 10);
        const sh = parseInt(curImg.style?.height, 10);
        if (sw > 0 && sh > 0) {
          detectedDisplayLimit = Math.max(detectedDisplayLimit, sw, sh);
          if (!foundW || !foundH) { foundW = sw; foundH = sh; }
        } else if (curImg.naturalWidth > 0 && curImg.naturalHeight > 0) {
          detectedDisplayLimit = Math.max(detectedDisplayLimit, curImg.naturalWidth, curImg.naturalHeight);
          if (!foundW || !foundH) { foundW = curImg.naturalWidth; foundH = curImg.naturalHeight; }
        }
      }

      if (!foundW || !foundH) {
        const infoText = (document.getElementById('i2')?.textContent || '') + ' ' + (document.getElementById('i4')?.textContent || '') + ' ' + (document.getElementById('i1')?.textContent || '');
        const mDim = infoText.match(/(\d{3,5})\s*x\s*(\d{3,5})/);
        if (mDim) {
          const w = parseInt(mDim[1], 10);
          const h = parseInt(mDim[2], 10);
          if (w > 0 && h > 0) {
            foundW = w;
            foundH = h;
          }
        }
      }

      if (foundW > 0 && foundH > 0) {
        const ar = foundW / foundH;
        if (ar >= 0.3 && ar <= 2.5) {
          defaultAspectRatio = ar > 1.05 ? (ar / 2) : ar;
        }
        pageDimensions.set(currentPageNum, { width: foundW, height: foundH, aspectRatio: ar });
        noteDimWriter('dim', currentPageNum);
        recordDisplayDims(currentPageNum, foundW, foundH);
        applyGlobalAspectRatio(defaultAspectRatio);
        initialDimsDone = true;
        return;
      }

      if (pageDimensions.has(currentPageNum) && pageDimensions.get(currentPageNum).aspectRatio) {
        defaultAspectRatio = pageDimensions.get(currentPageNum).aspectRatio;
        applyGlobalAspectRatio(defaultAspectRatio);
      }
    }

let isUpdatingThumbAr = false;
    function updateThumbnailAspectRatio(pageNo, ar) {
      if (isUpdatingThumbAr) return;
      if (!pageNo || !ar || isNaN(ar) || ar < 0.2 || ar > 5.0) return;
      const prevDim = pageDimensions.get(pageNo);
      if (prevDim && prevDim.width >= 400 && prevDim.height >= 400) return;

      const prevAr = prevDim?.aspectRatio || 0;
      if (prevAr && Math.abs(prevAr - ar) < 0.002) return;

      isUpdatingThumbAr = true;
      try {
        withScrollAnchor(() => {
          pageDimensions.set(pageNo, {
            width: 0,
            height: 0,
            aspectRatio: ar
          });
          if (Math.abs(defaultAspectRatio - 0.707) < 0.001 && ar >= 0.4 && ar <= 1.0) {
            defaultAspectRatio = ar;
          }
          const item = pageItemMap.get(pageNo);
          if (item) {
            applyItemDimensions(item, pageNo);
          }
        }, pageNo);

        if (!stripActive && pageNo === currentPageNum) {
          const singleWrap = document.getElementById('eh-single-thumb-placeholder');
          if (singleWrap) {
            showSinglePageThumbnailPlaceholder(pageNo);
          }
        }
      } finally {
        isUpdatingThumbAr = false;
      }
    }

    let isUpdatingPageDims = false;
    function updatePageDimensions(pageNo, width, height) {
      if (!width || !height) return;
      if (width < 400 || height < 400) {
        updateThumbnailAspectRatio(pageNo, width / height);
        return;
      }
      if (isUpdatingPageDims) return;
      isUpdatingPageDims = true;
      try {
        detectedDisplayLimit = Math.max(detectedDisplayLimit, width, height);
        const ar = width / height;
        const prevDim = pageDimensions.get(pageNo);
        const prevReal = prevDim && prevDim.width >= 400 && prevDim.height >= 400;
        // Only skip when the stored size is EXACTLY the one being reported. The old 0.2% ratio
        // tolerance swallowed 1-3px corrections (e.g. 1280x1773 stored, 1280x1775 real), so the
        // placeholder size was never fixed and the loaded image got stretched/shifted.
        if (prevReal && prevDim.width === width && prevDim.height === height) {
          return;
        }
        withScrollAnchor(() => {
          pageDimensions.set(pageNo, { width, height, aspectRatio: ar });
          noteDimWriter('dim', pageNo);
          if (Math.abs(defaultAspectRatio - 0.707) < 0.001 && ar >= 0.4 && ar <= 1.0) {
            defaultAspectRatio = ar;
          }
          const item = pageItemMap.get(pageNo);
          if (item) {
            applyItemDimensions(item, pageNo);
            if (item.classList.contains('eh-placeholder') && !item.querySelector('img.eh-main-img')) {
              applyThumbnailToPlaceholder(item, pageNo);
            }
          }
        }, pageNo);

        if (!stripActive && pageNo === currentPageNum) {
          const singleWrap = document.getElementById('eh-single-thumb-placeholder');
          if (singleWrap) {
            showSinglePageThumbnailPlaceholder(pageNo);
          }
        }

        if (currentPageNum === pageNo) {
          updateTopBarInfo(pageNo);
        }
      } finally {
        isUpdatingPageDims = false;
      }
    }
    function extractKeysFromText(text) {
      if (!text) return [];
      const found = new Set();
      for (const m of text.matchAll(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/g)) {
        const p = parseInt(m[1], 10);
        pageImgKeyCache.set(p, m[2]);
        found.add(p);
      }
      for (const m of text.matchAll(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/g)) {
        const p = parseInt(m[2], 10);
        pageImgKeyCache.set(p, m[1]);
        found.add(p);
      }
      return Array.from(found);
    }

    function extractKeysFromHtml(html) {
      if (!html) return;
      try {
        const template = document.createElement('template');
        template.innerHTML = html;
        const links = template.content.querySelectorAll('a');
        for (const a of links) {
          const oc = a.getAttribute('onclick') || '';
          const mClick = oc.match(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/i);
          if (mClick) {
            pageImgKeyCache.set(parseInt(mClick[1], 10), mClick[2]);
          }
          const href = a.getAttribute('href') || a.href || '';
          const mHref = href.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
          if (mHref) {
            pageImgKeyCache.set(parseInt(mHref[2], 10), mHref[1]);
          }
        }
      } catch {}
    }

    function extractKeysFromResp(resp, pageNo = null) {
      if (!resp) return;
      const htmlParts = [];
      if (resp.i2) htmlParts.push(resp.i2);
      if (resp.i4) htmlParts.push(resp.i4);
      if (resp.n) htmlParts.push(resp.n);
      const combined = htmlParts.join(' ');
      extractKeysFromText(combined);
      extractKeysFromHtml(combined);

      const targetP = pageNo || (resp.p ? parseInt(resp.p, 10) : null);
      if (targetP) {
        const nlCode = extractNlCode(resp);
        if (nlCode) {
          pageNlCodes.set(targetP, nlCode);
        }
        let w = resp.x ? parseInt(resp.x, 10) : 0;
        let h = resp.y ? parseInt(resp.y, 10) : 0;
        if ((!w || !h) && resp.i3) {
          const mDim = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px;\s*height:\s*(\d+)px/i) ||
                       resp.i3.match(/style=["'][^"']*height:\s*(\d+)px;\s*width:\s*(\d+)px/i);
          if (mDim) {
            w = parseInt(mDim[1], 10);
            h = parseInt(mDim[2], 10);
          }
        }
        if (w > 0 && h > 0) {
          recordDisplayDims(targetP, w, h);
          updatePageDimensions(targetP, w, h);
        }
      }

      if (!cachedGalleryUrl) {
        const combinedAll = (resp.i1 || '') + ' ' + (resp.i2 || '');
        const mG = combinedAll.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        if (mG) {
          cachedGalleryUrl = `${location.origin}/g/${mG[1]}/${mG[2]}/`;
          discoverAndFetchGallery(currentPageNum);
      prefetchNearbyThumbnails(currentPageNum);
        }
      }
    }

    function extractKeysFromDoc(doc) {
      const html =
        (doc.getElementById('i2')?.innerHTML || '') + ' ' +
        (doc.getElementById('i4')?.innerHTML || '') + ' ' +
        (doc.getElementById('i1')?.innerHTML || '');
      extractKeysFromText(html);
      extractKeysFromHtml(html);
      const nl = extractInitialNlCode(doc);
      if (nl) {
        pageNlCodes.set(currentPageNum, nl);
      }
    }

function cleanImageInfo(rawStr) {
      if (!rawStr) return '';
      let text = rawStr.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
      text = text.replace(/^(?:&lt;|<)[\s\d]+(?:&gt;|>)\s*/i, '').trim();
      text = text.replace(/^Page\s+\d+[:_\-]\s*/i, '').trim();
      if (text.includes('::')) {
        const parts = text.split('::').map(s => s.trim());
        if (parts.length >= 2) {
          let fn = parts[0].replace(/^(?:&lt;|<)[\s\d]+(?:&gt;|>)\s*/i, '').trim();
          fn = fn.replace(/^Page\s+\d+[:_\-]\s*/i, '').trim();
          fn = fn.replace(/.*?(?:Download|source)\s+(?:original\s+)?(?:\d+\s*x\s*\d+\s+)?(?:[\d\.]+\s*[kKMmGg]?[iI]?[bB]+\s+)?/i, '').trim();
          const dims = parts[1].trim();
          const sz = parts[2] ? ` :: ${parts[2].split(/\s+(?:Download|source)/i)[0].trim()}` : '';
          return fn ? `${fn} :: ${dims}${sz}` : `${dims}${sz}`;
        }
      }
      return text;
    }

function extractInfoFromResp(resp, pageNo = null) {
      if (!resp) return '';
      let dSnippet = resp.d || resp.i || '';
      if (!dSnippet && resp.i2) {
        const m = resp.i2.match(/<div[^>]*class=["']d["'][^>]*>([\s\S]*?)<\/div>/i);
        if (m) dSnippet = m[1];
      }
      if (!dSnippet && resp.i4) {
        const m = resp.i4.match(/<div[^>]*class=["']d["'][^>]*>([\s\S]*?)<\/div>/i);
        if (m) dSnippet = m[1];
      }
      const candidates = [dSnippet, resp.i, resp.d, resp.i5, resp.i2, resp.i4, resp.i1, resp.i3];
      for (const html of candidates) {
        if (!html) continue;
        const cleaned = cleanImageInfo(html);
          if (cleaned && cleaned.includes('::') && cleaned.includes('x')) {
              if (pageNo) {
                  const sz = parseFileSizeInBytes(cleaned);
                  if (sz > 0) pageFileSizeCache.set(pageNo, sz);
              }
              return cleaned;
          }
      }
      let filename = '';
      let dimStr = '';
      if (resp.i3) {
        const mImg = resp.i3.match(/<img[^>]+src=["']([^"']+)["']/i);
        const imgSrc = mImg ? mImg[1] : resp.i3;
        const mSrc = imgSrc.match(/\/([^\s"'\/]+\.[a-zA-Z0-9]+)(?:["']|\?)/i);
        if (mSrc && !mSrc[1].includes('blank') && !mSrc[1].includes('loadfail') && !mSrc[1].includes('fullimg')) {
          filename = mSrc[1];
        }
        const mDim = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px;\s*height:\s*(\d+)px/i) ||
                     resp.i3.match(/style=["'][^"']*height:\s*(\d+)px;\s*width:\s*(\d+)px/i) ||
                     resp.i3.match(/(\d{3,5})\s*x\s*(\d{3,5})/);
        if (mDim) {
          dimStr = `${mDim[1]} x ${mDim[2]}`;
        }
      }
      if (resp.x && resp.y) {
        dimStr = `${resp.x} x ${resp.y}`;
      }
      if (!dimStr && pageNo && pageDimensions.has(pageNo)) {
        const d = pageDimensions.get(pageNo);
        if (d.width && d.height) {
          dimStr = `${d.width} x ${d.height}`;
        }
      }
      if (filename && dimStr) return `${filename} :: ${dimStr}`;
      if (dimStr) return dimStr;
      return filename;
    }

    function getOrCreateTopBarInfo() {
      const i2 = document.getElementById('i2');
      if (!i2) return null;
      let infoContainer = document.getElementById('eh-topbar-info');
      if (!infoContainer) {
        infoContainer = document.createElement('div');
        infoContainer.id = 'eh-topbar-info';
        infoContainer.className = 'eh-topbar-info';

        let initialRaw = '';
        const dEl = i2.querySelector('.d');
        if (dEl) {
          initialRaw = dEl.textContent;
          dEl.remove();
        }

        for (const child of Array.from(i2.childNodes)) {
          if (child.nodeType === Node.ELEMENT_NODE) {
            if (child.classList.contains('sn') || child.id === 'eh-strip-controls' || child.id === 'eh-topbar-info') continue;
            if (!initialRaw) initialRaw += ' ' + child.textContent;
            child.remove();
          } else if (child.nodeType === Node.TEXT_NODE && child.textContent.trim()) {
            if (!initialRaw) initialRaw += ' ' + child.textContent;
            child.remove();
          }
        }

        const controls = document.getElementById('eh-strip-controls');
        if (controls && controls.parentNode === i2) {
          i2.insertBefore(infoContainer, controls);
        } else {
          i2.appendChild(infoContainer);
        }

        const cleaned = cleanImageInfo(initialRaw);
        if (cleaned) {
          pageTopBarInfoCache.set(currentPageNum, cleaned);
          infoContainer.innerHTML = `<div>${cleaned}</div>`;
        }
      }
      return infoContainer;
    }

let isUpdatingTopBar = false;
    function updateTopBarInfo(pageNo) {
      if (isUpdatingTopBar) return;
      const infoContainer = getOrCreateTopBarInfo();
      if (!infoContainer) return;

      isUpdatingTopBar = true;
      try {
        // 1. Inspect live image in DOM (strip item or single-page image)
        const item = pageItemMap.get(pageNo);
        // The native #img keeps showing the PREVIOUS page until the new one has loaded into it, so
        // only read its size once it is known to hold this page (turnSinglePage calls this
        // immediately on every turn; reading it blindly stored the previous page's size,
        // e.g. 1063x886 landscape for a 886x1063 portrait page).
        let img = null;
        if (item) {
          img = item.querySelector('img.eh-main-img');
        } else if (pageNo === currentPageNum) {
          const nat = document.getElementById('img');
          if (nat && nat._eh_page === pageNo && !nat._eh_obsolete && nat.complete) img = nat;
        }
        let currentW = 0, currentH = 0;
        if (img && img.naturalWidth > 0 && img.naturalHeight > 0) {
          currentW = img.naturalWidth;
          currentH = img.naturalHeight;
          if (!pageDimensions.has(pageNo) || pageDimensions.get(pageNo).width === 0) {
            updatePageDimensions(pageNo, currentW, currentH);
          }
        }

        // 2. Inspect pageDimensions cache
        if (!currentW || !currentH) {
          const dim = pageDimensions.get(pageNo);
          if (dim && dim.width > 0 && dim.height > 0) {
            currentW = dim.width;
            currentH = dim.height;
          }
        }

        // 3. Inspect pageRespCache
        let respInfo = '';
        if (pageRespCache.has(pageNo)) {
          const resp = pageRespCache.get(pageNo);
          respInfo = extractInfoFromResp(resp, pageNo);
          if (respInfo && respInfo.includes('x') && !currentW) {
            const m = respInfo.match(/(\d{2,5})\s*x\s*(\d{2,5})/);
            if (m) {
              currentW = parseInt(m[1], 10);
              currentH = parseInt(m[2], 10);
            }
          }
        }

        // 4. Inspect cached info string
        let cachedStr = pageTopBarInfoCache.get(pageNo) || '';
        if (cachedStr && cachedStr.includes('x') && !currentW) {
          const m = cachedStr.match(/(\d{2,5})\s*x\s*(\d{2,5})/);
          if (m) {
            currentW = parseInt(m[1], 10);
            currentH = parseInt(m[2], 10);
          }
        }

        // 5. Render (throttled to avoid layout thrashing during scroll)
        let finalStr = '';
        if (respInfo && respInfo.includes('::') && respInfo.includes('x')) {
          pageTopBarInfoCache.set(pageNo, respInfo);
          finalStr = respInfo;
        } else if (cachedStr && cachedStr.includes('::') && cachedStr.includes('x')) {
          finalStr = cachedStr;
        } else {
          const baseName = (respInfo && !respInfo.includes('x')) ? respInfo :
                           (cachedStr && !cachedStr.includes('x')) ? cachedStr : '';

          if (baseName && currentW > 0 && currentH > 0) {
            finalStr = `${baseName} :: ${currentW} x ${currentH}`;
            pageTopBarInfoCache.set(pageNo, finalStr);
          } else if (currentW > 0 && currentH > 0) {
            finalStr = `Page ${pageNo} :: ${currentW} x ${currentH}`;
          } else if (baseName) {
            finalStr = baseName;
          }
        }

        if (infoContainer._lastText !== finalStr) {
          infoContainer._lastText = finalStr;
          infoContainer.innerHTML = finalStr ? `<div>${finalStr}</div>` : '';
        }

        // Bottom info line in #i4. The API doesn't always hand it back in a field we write
        // directly, so set the text ourselves ('.d' on some layouts, a plain <div> on others).
        const i4 = document.getElementById('i4');
        if (i4 && finalStr) {
          const target = i4.querySelector('.d') ||
            Array.from(i4.children).find(c =>
              c.tagName === 'DIV' && !c.classList.contains('sn') && c.id !== 'eh-topbar-info' && !c.querySelector('.sn'));
          if (target && target.textContent !== finalStr) {
            target.textContent = finalStr;
          }
        }
      } finally {
        isUpdatingTopBar = false;
      }
    }

    function detectInitialDisplayLimit() {
      const curImg = document.getElementById('img');
      if (curImg) {
        const sw = parseInt(curImg.style?.width, 10);
        const sh = parseInt(curImg.style?.height, 10);
        if (sw > 0 && sh > 0) {
          detectedDisplayLimit = Math.max(detectedDisplayLimit, sw, sh);
          return;
        }
        if (curImg.naturalWidth > 0 && curImg.naturalHeight > 0) {
          detectedDisplayLimit = Math.max(detectedDisplayLimit, curImg.naturalWidth, curImg.naturalHeight);
          return;
        }
      }
      for (const resp of pageRespCache.values()) {
        if (resp && resp.x && resp.y) {
          const w = parseInt(resp.x, 10);
          const h = parseInt(resp.y, 10);
          if (w > 0 && h > 0) {
            detectedDisplayLimit = Math.max(detectedDisplayLimit, w, h);
            return;
          }
        }
      }
    }

let isRecordingDims = false;
    function recordDisplayDims(pageNo, w, h) {
      if (!(w >= 400 && h >= 400)) return;

      // Prevent duplicate processing if already recorded with same dimensions
      const existing = pageDisplayDims.get(pageNo);
      if (existing && existing.width === w && existing.height === h) return;

      // Re-entrancy guard to break any recursive cascade
      if (isRecordingDims) return;
      isRecordingDims = true;

      try {
        const orig = pageDimensions.get(pageNo);

        const guess = placeholderGuess.get(pageNo);
        if (guess) {
          placeholderGuess.delete(pageNo);
          // Only a wrong RATIO is a real miss; a different width at the same ratio is just the
          // image being smaller than the neighbouring pages (handled when it loads).
          if (Math.abs((guess.width / guess.height) / (w / h) - 1) > 0.01) {
            console.info('[ExFastReader] placeholder size miss, page ' + pageNo + ': guessed ' +
              guess.width + 'x' + guess.height + ' (' + guess.src + '), actual ' + w + 'x' + h, guess,
              { dimWriter: dimWriters.get('dim:' + pageNo) || null, dispWriter: dimWriters.get('disp:' + pageNo) || null, storedDims: pageDimensions.get(pageNo) || null });
          }
        }
        if (orig && orig.width >= 400 && orig.height >= 400 && orig.width > w) {
          const raw = w * orig.height / orig.width;
          if (Math.abs(raw - Math.round(raw)) > 0.02) {
            if (h === Math.round(raw)) roundVotes.round++;
            if (h === Math.floor(raw)) roundVotes.floor++;
            if (h === Math.ceil(raw)) roundVotes.ceil++;
          }
        }

        pageDisplayDims.set(pageNo, { width: w, height: h, aspectRatio: w / h });
        noteDimWriter('disp', pageNo);
        if (!knownDisplaySizes.some(s => s.width === w && s.height === h)) {
          knownDisplaySizes.push({ width: w, height: h, aspectRatio: w / h });
        }
        if (orig && orig.width >= 400 && orig.height >= 400) {
          displayByOriginal.set(orig.width + 'x' + orig.height, { width: w, height: h });
        }
        maxDisplayW = Math.max(maxDisplayW, w);
        maxDisplayH = Math.max(maxDisplayH, h);
        lastExactW = w;
        lastExactH = h;
        safeStorageSet(sessionStorage, 'eh_last_exact_w', String(w));
        safeStorageSet(sessionStorage, 'eh_last_exact_h', String(h));
        let newCap = displayCapW;
        if (orig && orig.width > w * 1.02) {
          newCap = w;
        } else if (!displayCapW || displayCapW < 400) {
          newCap = w;
        } else if (w > displayCapW) {
          newCap = w;
        }
        if (newCap !== displayCapW && newCap >= 400) {
          displayCapW = newCap;
          displayByOriginal.clear();
          if (orig && orig.width >= 400 && orig.height >= 400) {
            displayByOriginal.set(orig.width + 'x' + orig.height, { width: w, height: h });
          }
          safeStorageSet(sessionStorage, 'eh_display_cap_w', String(newCap));
        }

        // (Neighbour-size propagation removed: ratios from thumbnails cannot tell a 2071px page
        // from a 2076px one, so it just moved dozens of placeholders at once, each by a wrong amount.)
      } finally {
        isRecordingDims = false;
      }
    }

    function parseRespDisplayDims(resp) {
      if (!resp) return null;
      let w = resp.x ? parseInt(resp.x, 10) : 0;
      let h = resp.y ? parseInt(resp.y, 10) : 0;
      if ((!w || !h) && resp.i3) {
        const mW = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px/i);
        const mH = resp.i3.match(/style=["'][^"']*height:\s*(\d+)px/i);
        if (mW) w = parseInt(mW[1], 10);
        if (mH) h = parseInt(mH[1], 10);
      }
      return (w > 0 && h > 0) ? { width: w, height: h, aspectRatio: w / h } : null;
    }

function getPageTargetDimensions(pageNo) {
      // 1. Previously confirmed size for this page (return immediately without re-recording)
      const known = pageDisplayDims.get(pageNo);
      if (known) return { ...known, exact: true };

      // 2. The server's own x/y for this page: exactly what the site's native code uses
      const fromResp = parseRespDisplayDims(pageRespCache.get(pageNo));
      if (fromResp) {
        if (!pageDisplayDims.has(pageNo)) {
          recordDisplayDims(pageNo, fromResp.width, fromResp.height);
        }
        return { ...fromResp, exact: true };
      }

      // 3. Preloaded image: natural size is the served (display) size
      const preloaded = preloadedImages.get(pageNo);
      if (preloaded && preloaded.img && preloaded.img.naturalWidth > 0 && preloaded.img.naturalHeight > 0) {
        const nw = preloaded.img.naturalWidth;
        const nh = preloaded.img.naturalHeight;
        if (!pageDisplayDims.has(pageNo)) {
          recordDisplayDims(pageNo, nw, nh);
        }
        return { width: nw, height: nh, aspectRatio: nw / nh, exact: true };
      }

      // 4. Estimate (placeholder only).
      // A full width/height stored in pageDimensions for a page that is NOT confirmed (step 1 above
      // already returned for confirmed pages) cannot be trusted: logs showed one page's size
      // (e.g. 1280x800) sitting on a whole run of other pages, so every guess built from it missed
      // by hundreds of pixels. Estimate from this page's own thumbnail ratio instead.
      const dim = pageDimensions.get(pageNo);
      const ow = 0;
      const oh = 0;
      const th = pageThumbnailCache.get(pageNo);
      const thumbAr = (th && th.w > 0 && th.h > 0) ? (th.w / th.h) : 0;
      const dimArOnly = (dim && !(dim.width >= 400) && dim.aspectRatio) ? dim.aspectRatio : 0;
      const ar = thumbAr || dimArOnly || dim?.aspectRatio || 0;
      const near = nearestKnownDisplay(pageNo);
      const refW = near ? near.width : 0;
      const refH = near ? near.height : 0;
      const refAr = (refW >= 400 && refH >= 400) ? refW / refH : 0;

      if (ar > 0) {
        const w = ow > 0 ? (displayCapW > 0 ? Math.min(ow, displayCapW) : ow) : (refW || displayCapW);
        if (w > 0) {
          const thumbInfo = th ? { w: th.w, h: th.h } : null;
          const guessed = (r, src) => {
            placeholderGuess.set(pageNo, { width: r.width, height: r.height, src, ow, oh, thumb: thumbInfo, displayCapW });
            return r;
          };
          const haveOriginal = ow > 0 && oh > 0;
          if (haveOriginal) {
            const hit = displayByOriginal.get(ow + 'x' + oh);
            if (hit) {
              return guessed({ width: hit.width, height: hit.height, aspectRatio: hit.width / hit.height, exact: false }, 'same original size');
            }
            const hh = w < ow ? serverRound(w * oh / ow) : oh;
            return guessed({ width: Math.round(w), height: hh, aspectRatio: w / hh, exact: false }, 'original size, scaled');
          }

          const targetW = Math.round(w);
          let bestMatch = null;
          let bestDiff = Infinity;
          for (const k of knownDisplaySizes) {
            if (k.width === targetW) {
              const diffR = Math.abs(ar / k.aspectRatio - 1);
              const tol = (th && th.w > 0 && th.h > 0) ? Math.max(0.005, 1.2 * (0.5 / th.w + 0.5 / th.h)) : 0.007;
              if (diffR <= tol && diffR < bestDiff) {
                bestDiff = diffR;
                bestMatch = k;
              }
            }
          }
          if (bestMatch) {
            return guessed({ width: bestMatch.width, height: bestMatch.height, aspectRatio: bestMatch.aspectRatio, exact: false }, 'known gallery size');
          }

          if (refAr > 0 && refW === targetW) {
            const tol = (th && th.w > 0 && th.h > 0) ? Math.max(0.005, 1.2 * (0.5 / th.w + 0.5 / th.h)) : 0.007;
            if (Math.abs(ar / refAr - 1) <= tol) {
              return guessed({ width: refW, height: refH, aspectRatio: refAr, exact: false }, 'neighbour size');
            }
          }
          return guessed({ width: targetW, height: Math.round(targetW / ar), aspectRatio: ar, exact: false }, 'thumbnail ratio');
        }
      }

      if (refAr > 0 && (!ar || Math.abs(ar - refAr) < 0.05)) {
        return { width: refW, height: refH, aspectRatio: refAr, exact: false };
      }

      const fallbackAr = ar || defaultAspectRatio || 0.707;
      const fallbackW = refW || displayCapW || (typeof window !== 'undefined' ? Math.min(1280, Math.max(480, window.innerWidth - 20)) : 1000);
      return { width: fallbackW, height: Math.round(fallbackW / fallbackAr), aspectRatio: fallbackAr, exact: false };
    }
    function nearestKnownDisplay(pageNo) {
      let best = null;
      let bestDist = Infinity;
      for (const [p, d] of pageDisplayDims) {
        const dist = Math.abs(p - pageNo);
        if (dist < bestDist || (dist === bestDist && p < best.page)) {
          bestDist = dist;
          best = { page: p, width: d.width, height: d.height };
        }
      }
      if (best) return best;
      if (lastExactW >= 400 && lastExactH >= 400) return { page: 0, width: lastExactW, height: lastExactH };
      return null;
    }

    // Mirrors the site's own update_window_extents(): fit to (innerWidth - 20), min 480.
    function fitDimsToWindow(w, h) {
      const maxW = Math.max(480, window.innerWidth - 20);
      if (w > maxW) {
        h = Math.round(h * maxW / w);
        w = maxW;
      }
      return { width: w, height: h };
    }

    // The native routine reads the globals xres/yres, which only apply_json_state updates.
    // We bypass apply_json_state in single mode, so keep them in sync ourselves.
    function syncNativeExtents(w, h, exact) {
      if (!(w > 0 && h > 0)) return { width: w, height: h };
      if (exact) {
        try { window.xres = w; window.yres = h; } catch {}
      }
      const f = fitDimsToWindow(w, h);
      const i1 = document.getElementById('i1');
      if (i1) {
        const curW = parseInt(i1.style.width, 10);
        if (curW !== f.width) {
          i1.style.width = f.width + 'px';
        }
      }
      return f;
    }

    function applySingleImageSizing(img, pageNo) {
      if (!img || stripActive) return;
      let d = parseRespDisplayDims(pageRespCache.get(pageNo)) || pageDisplayDims.get(pageNo);
      const loaded = img._eh_page === pageNo && !img._eh_obsolete && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
      if (!d && loaded) {
        d = { width: img.naturalWidth, height: img.naturalHeight };
      }
      if (!d) return;
      recordDisplayDims(pageNo, d.width, d.height);
      const f = syncNativeExtents(d.width, d.height, true);
      const curW = parseInt(img.style.width, 10);
      const curH = parseInt(img.style.height, 10);
      // Exact comparison: a 2px tolerance here left the final image up to 2px away from the true
      // size whenever the placeholder had been an estimate.
      if (curW !== f.width) {
        img.style.width = f.width + 'px';
      }
      if (curH !== f.height) {
        img.style.height = f.height + 'px';
      }
      img.style.aspectRatio = String(d.width / d.height);
      alignIfPending();
      if (loaded) {
        requestAnimationFrame(() => {
          alignIfPending();
          disarmSingleAlign();
        });
      }
    }

    function applyCurrentSingleSizing() {
      if (!isReaderPage || stripActive) return;
      const img = document.getElementById('img');
      if (img) applySingleImageSizing(img, currentPageNum);
    }

    // Safety net: whatever happened during a page turn, once the current page's image has
    // loaded make sure it is sized correctly, visible, and no placeholder is left over it.
    function reconcileSinglePage() {
      if (!isReaderPage || stripActive) return;
      const img = document.getElementById('img');
      if (!img) return;
      const ready = img._eh_page === currentPageNum && !img._eh_obsolete && img.complete && img.naturalWidth > 0;
      if (!ready) return;
      applySingleImageSizing(img, currentPageNum);
      hideSinglePageThumbnailPlaceholder();
    }
    function scheduleSingleReconcile() {
      [120, 500, 1500].forEach((ms) => setTimeout(reconcileSinglePage, ms));
    }
    document.addEventListener('load', (e) => {
      if (e.target && e.target.id === 'img') reconcileSinglePage();
    }, true);

    let singleResizeRaf = null;
    window.addEventListener('resize', () => {
      if (!isReaderPage || stripActive || singleResizeRaf) return;
      singleResizeRaf = requestAnimationFrame(() => {
        singleResizeRaf = null;
        applyCurrentSingleSizing();
      });
    }, { passive: true });

    // After a page turn the layout keeps changing (placeholder -> exact size -> loaded image).
    // A single scrollTo gets clamped by the old, shorter document, so keep re-aligning to the
    // top of the image until it has loaded or the user scrolls on their own.
    let singleAlignPending = false;
    let singleAlignTimer = null;
    function armSingleAlign() {
      singleAlignPending = true;
      if (singleAlignTimer) clearTimeout(singleAlignTimer);
      singleAlignTimer = setTimeout(() => { singleAlignPending = false; }, 2000);
    }
    function disarmSingleAlign() {
      singleAlignPending = false;
      if (singleAlignTimer) { clearTimeout(singleAlignTimer); singleAlignTimer = null; }
    }
    ['pointerdown', 'touchstart', 'wheel'].forEach((evt) => {
      window.addEventListener(evt, (e) => {
        if (!stripActive && evt !== 'wheel') {
          if (!e.target?.closest?.('#i3 a, #next, #prev, #i2, .sn')) {
            disarmSingleAlign();
          }
        } else if (evt === 'wheel') {
          disarmSingleAlign();
        }
        cancelKeyboardAnimation();
      }, { passive: true, capture: true });
    });
    function alignIfPending() {
      if (singleAlignPending && !stripActive) scrollSinglePageToImageTop();
    }

    function scrollSinglePageToImageTop() {
      if (stripActive) return;
      const target = document.getElementById('i3') || document.getElementById('img');
      if (!target) return;
      const rect = target.getBoundingClientRect();
      const targetTop = Math.max(0, Math.round(window.scrollY + rect.top));
      if (Math.abs(window.scrollY - targetTop) >= 1) {
        window.scrollTo(0, targetTop);
      }
    }

    function applyThumbnailToSinglePlaceholder(wrap, pageNo) {
      if (!wrap || wrap.dataset.page !== String(pageNo)) return;

      // The wrapper is reused across page turns: never leave the previous page's thumbnail behind
      if (wrap._thumbPage !== pageNo) {
        wrap.textContent = '';
        wrap._thumbKey = '';
        wrap._thumbPage = pageNo;
      }

      const painted = paintThumb(wrap, pageNo);
      let content = wrap.querySelector(':scope > .eh-placeholder-content');
      if (painted) {
        if (content) content.remove();
        return;
      }

      if (!content) {
        content = document.createElement('div');
        content.className = 'eh-placeholder-content eh-delayed';
        content.innerHTML = `<div class="eh-placeholder-text">Page ${pageNo}...</div>`;
        wrap.appendChild(content);
      }
      if (!pageThumbnailCache.has(pageNo)) prefetchNearbyThumbnails(pageNo);
    }

    function showSinglePageThumbnailPlaceholder(pageNo) {
      if (stripActive) return;
      const i3 = document.getElementById('i3');
      if (!i3) return;

      const imgEl = i3.querySelector('img#img') || i3.querySelector('img');
      if (imgEl && imgEl._eh_page === pageNo && !imgEl._eh_obsolete && imgEl.complete && imgEl.naturalWidth > 0) {
        return;
      }

      const phDim = getPageTargetDimensions(pageNo);
      const ar = phDim.aspectRatio;
      let targetW = phDim.width, targetH = phDim.height;
      if (targetW <= 0 || targetH <= 0) {
        const sw = (imgEl && imgEl._eh_page === pageNo) ? parseInt(imgEl.style?.width, 10) : 0;
        const sh = (imgEl && imgEl._eh_page === pageNo) ? parseInt(imgEl.style?.height, 10) : 0;
        if (sw > 0 && sh > 0) {
          targetW = sw;
          targetH = sh;
        } else {
          const effectiveAr = ar || defaultAspectRatio || 0.707;
          targetW = displayCapW || lastExactW || (typeof window !== 'undefined' ? Math.min(1280, Math.max(480, window.innerWidth - 20)) : 1000);
          targetH = Math.round(targetW / effectiveAr);
        }
      }
      const f = syncNativeExtents(targetW, targetH, phDim.exact);
      targetW = f.width;
      targetH = f.height;

      // Keep imgEl in layout flow with exact target dimensions so #i3 and #i1 never collapse or pop
      if (imgEl) {
        imgEl.style.width = targetW + 'px';
        imgEl.style.height = targetH + 'px';
        if (ar > 0) imgEl.style.aspectRatio = String(ar);
        else imgEl.style.aspectRatio = '';
        imgEl.style.opacity = '0';
        imgEl.style.position = 'relative';
        imgEl.style.display = 'block';
        imgEl.style.pointerEvents = 'none';
        imgEl.style.zIndex = '2';
      }

      let wrap = i3.querySelector('#eh-single-thumb-placeholder');
      if (!wrap) {
        wrap = document.createElement('div');
        wrap.id = 'eh-single-thumb-placeholder';
        if (imgEl && imgEl.parentNode && imgEl.parentNode.tagName.toLowerCase() === 'a') {
          imgEl.parentNode.insertBefore(wrap, imgEl);
        } else {
          i3.insertBefore(wrap, i3.firstChild);
        }
      }

      wrap.dataset.page = String(pageNo);
      wrap.style.cssText = `
        position: absolute !important;
        top: 0 !important;
        left: 0 !important;
        width: 100% !important;
        height: 100% !important;
        display: flex !important;
        align-items: center !important;
        justify-content: center !important;
        margin: 0 auto !important;
        overflow: hidden !important;
        box-sizing: border-box !important;
        z-index: 1 !important;
        pointer-events: none !important;
      `;

      applyThumbnailToSinglePlaceholder(wrap, pageNo);
      alignIfPending();
      requestAnimationFrame(alignIfPending);
    }

    function hideSinglePageThumbnailPlaceholder() {
      const i3 = document.getElementById('i3');
      const imgEl = i3 ? (i3.querySelector('img#img') || i3.querySelector('img')) : null;

      if (imgEl) {
        imgEl.style.opacity = '1';
        imgEl.style.pointerEvents = '';
        imgEl.style.position = 'relative';
        imgEl.style.display = 'block';
        imgEl.style.visibility = 'visible';
        imgEl.style.zIndex = '2';
      }

      const wraps = document.querySelectorAll('#eh-single-thumb-placeholder');
      wraps.forEach(w => w.remove());
    }

    function applyThumbnailToPlaceholder(item, pageNo) {
      if (!item || !item.classList.contains('eh-placeholder')) return;
      if (item.querySelector('img.eh-main-img')) return;
      paintThumb(item, pageNo);
    }

    async function drainGalleryQueue(queue) {
      if (!queue || queue._draining) return;
      queue._draining = true;
      const worker = async () => {
        while (queue.length > 0) {
          const pIndex = queue.shift();
          await fetchGalleryPage(pIndex);
          if (queue.length > 0) {
            await new Promise(r => setTimeout(r, 60));
          }
        }
      };
      await Promise.all([worker(), worker()]);
      queue._draining = false;
    }

    function schedulePrefetchNearbyThumbnails(curPage) {
      if (!isReaderPage) return;
      if (prefetchThumbTimer) clearTimeout(prefetchThumbTimer);
      prefetchThumbTimer = setTimeout(() => {
        prefetchNearbyThumbnails(curPage);
      }, 100);
    }

    function prefetchNearbyThumbnails(curPage) {
      if (!isReaderPage) return;
      warmThumbsAround(curPage);
      if (stripActive && isFastScrolling()) return;
      const total = getTotalPages();
      const THUMB_WINDOW = 20;
      const startP = Math.max(1, curPage - 10);
      const endP = total > 0 ? Math.min(total, curPage + THUMB_WINDOW) : (curPage + THUMB_WINDOW);

      const galleryPagesToFetch = [];
      for (let p = startP; p <= endP; p++) {
        if (!pageThumbnailCache.has(p) || !pageImgKeyCache.has(p)) {
          const gIndex = Math.max(0, Math.floor((p - 1) / thumbsPerPage));
          if (!fetchedGalleryPages.has(gIndex) && !inFlightGalleryFetches.has(gIndex)) {
            if (!galleryPagesToFetch.includes(gIndex)) {
              galleryPagesToFetch.push(gIndex);
            }
          }
        }
      }

      if (galleryPagesToFetch.length === 0) return;

      const curGIndex = Math.max(0, Math.floor((curPage - 1) / thumbsPerPage));
      galleryPagesToFetch.sort((a, b) => Math.abs(a - curGIndex) - Math.abs(b - curGIndex));

      const toFetch = galleryPagesToFetch.slice(0, 2);
      (async () => {
        for (const gIndex of toFetch) {
          if (!isReaderPage) break;
          const activeGIndex = Math.max(0, Math.floor((currentPageNum - 1) / thumbsPerPage));
          if (stripActive && Math.abs(gIndex - activeGIndex) > 3) continue;
          await fetchGalleryPage(gIndex, false);
          await new Promise(r => setTimeout(r, 80));
        }
      })();
    }

    async function discoverAndFetchGallery(targetPage = 1) {
      let galleryUrl = getGalleryUrl();
      if (!galleryUrl) {
        for (let attempt = 0; attempt < 12; attempt++) {
          await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
          galleryUrl = getGalleryUrl();
          if (galleryUrl) break;
        }
        if (!galleryUrl) {
          if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => discoverAndFetchGallery(targetPage), { once: true });
          }
          return;
        }
      }

      const targetIndex = Math.max(0, Math.floor((targetPage - 1) / thumbsPerPage));
      if (pageThumbnailCache.has(targetPage) && pageImgKeyCache.has(targetPage)) {
        fetchedGalleryPages.set(targetIndex, Date.now());
      } else {
        await fetchGalleryPage(targetIndex);
      }

      const total = getTotalPages();
      const nextGalleryPage = targetIndex + 1;
      const totalGalleryPages = total > 0 ? Math.ceil(total / thumbsPerPage) : 0;
      if (nextGalleryPage < totalGalleryPages && !fetchedGalleryPages.has(nextGalleryPage) && !inFlightGalleryFetches.has(nextGalleryPage)) {
        fetchGalleryPage(nextGalleryPage);
      }
    }

    function extractDimensionsFromGallery(source) {
      if (!source) return 0;
      let count = 0;
      try {
        let doc;
        let htmlStr = '';
        if (typeof source === 'string') {
          htmlStr = source;
          doc = new DOMParser().parseFromString(source, 'text/html');
        } else if (source && (source.nodeType || source.querySelector)) {
          doc = source;
          htmlStr = (source.documentElement ? source.documentElement.innerHTML : source.innerHTML) || '';
        }
        if (!doc) return 0;

        const mGpc = htmlStr.match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i) ||
                     (doc.querySelector('.gpc, #gpc')?.textContent || '').match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i);
        if (mGpc) {
          const sNum = parseInt(mGpc[1], 10);
          const eNum = parseInt(mGpc[2], 10);
          const tot = parseInt(mGpc[3].replace(/,/g, ''), 10);
          if (eNum < tot) {
            const countPerPg = eNum - sNum + 1;
            if ([20, 40, 100].includes(countPerPg)) {
              thumbsPerPage = countPerPg;
              safeStorageSet(localStorage, 'eh_thumbs_per_page', String(countPerPg));
            }
          }
          if (tot > 0 && (!cachedTotalPages || cachedTotalPages < tot)) {
            cachedTotalPages = tot;
            if (isReaderPage && stripActive) {
              ensureAllPlaceholders(tot);
            }
          }
        }
        const links = (doc.getElementById('gdt')?.querySelectorAll('a[href*="/s/"]') || doc.querySelectorAll('#gdt a[href*="/s/"]')).length > 0
          ? (doc.getElementById('gdt')?.querySelectorAll('a[href*="/s/"]') || doc.querySelectorAll('#gdt a[href*="/s/"]'))
          : Array.from(doc.querySelectorAll('a[href*="/s/"]'));

        const changedThumbPages = [];

        withScrollAnchor(() => {
          for (const a of links) {
            if (a.closest('#gd1, #gleft, #gmid, #gd2, #gd3, #gd4, #gd5')) continue;
            const href = a.getAttribute('href') || a.href || '';
            const m = href.match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
            if (!m) continue;
            count++;
            const key = m[1];
            const page = parseInt(m[2], 10);
            pageImgKeyCache.set(page, key);

            const img = a.querySelector('img');
            const cell = a.closest('.gdtm, .gdtl') || a.parentElement;
            let thumbDiv = a.querySelector('div[style*="url("], div[style*="background"]') ||
                           (a.parentElement?.matches?.('[style*="url("], [style*="background"]') ? a.parentElement : null) ||
                           a.closest('.gdtm > div');
            if (thumbDiv && cell && thumbDiv !== cell && !cell.contains(thumbDiv)) thumbDiv = null;

            const parent = a.closest('.gdtm > div') || a.closest('.gdtm') || a.closest('.gdtl') || a.parentElement;

            const titleEl = a.querySelector('[title*="Page"]') ||
                            a.querySelector('[title]') ||
                            thumbDiv ||
                            img ||
                            a ||
                            parent;

            const title = (
              titleEl?.getAttribute('title') ||
              img?.getAttribute('title') ||
              a.getAttribute('title') ||
              parent?.getAttribute('title') ||
              img?.getAttribute('alt') ||
              ''
            ).trim();

            const cleanInfo = cleanImageInfo(title);
            if (cleanInfo) {
              const existingInfo = pageTopBarInfoCache.get(page) || '';
              if (!existingInfo || !existingInfo.includes('x') || cleanInfo.includes('x')) {
                pageTopBarInfoCache.set(page, cleanInfo);
              }
              const sz = parseFileSizeInBytes(cleanInfo);
              if (sz > 0) pageFileSizeCache.set(page, sz);
              if (currentPageNum === page) {
                updateTopBarInfo(page);
              }
            }

            let w = 0, h = 0;

            // Extract width/height strictly from the image or sprite container (never from .gdtl/.gdtm table cells)
            const getElemDims = (el) => {
              if (!el) return { ew: 0, eh: 0 };
              let ew = parseInt(el.style?.width, 10) || 0;
              let eh = parseInt(el.style?.height, 10) || 0;
              const s = el.getAttribute ? (el.getAttribute('style') || '') : '';
              if (!ew && s) {
                const mW = s.match(/(?:^|[;\s])width:\s*(\d+)px/i);
                if (mW) ew = parseInt(mW[1], 10);
              }
              if (!eh && s) {
                const mH = s.match(/(?:^|[;\s])height:\s*(\d+)px/i);
                if (mH) eh = parseInt(mH[1], 10);
              }
              if (!ew) ew = parseInt(el.getAttribute?.('width') || el.width || '0', 10);
              if (!eh) eh = parseInt(el.getAttribute?.('height') || el.height || '0', 10);
              return { ew, eh };
            };

            if (thumbDiv) {
              const td = getElemDims(thumbDiv);
              if (td.ew) w = td.ew;
              if (td.eh) h = td.eh;
            }
            if (img) {
              const id = getElemDims(img);
              if (!w && id.ew) w = id.ew;
              if (!h && id.eh) h = id.eh;
            }

            const dimMatch = (cleanInfo || title).match(/(\d{3,5})\s*x\s*(\d{3,5})/);
            let hasRealDim = false;
            if (dimMatch) {
              const realW = parseInt(dimMatch[1], 10);
              const realH = parseInt(dimMatch[2], 10);
              if (realW > 0 && realH > 0) {
                const ar = realW / realH;
                pageDimensions.set(page, { width: realW, height: realH, aspectRatio: ar });
                noteDimWriter('dim', page);
                const item = pageItemMap.get(page);
                if (item) applyItemDimensions(item, page);
                hasRealDim = true;
              }
            }

            if (!hasRealDim && w > 0 && h > 0) {
              const ar = w / h;
              if (ar >= 0.3 && ar <= 2.8) {
                const prev = pageDimensions.get(page);
                // Real dimensions always win; only touch layout when the ratio actually changed
                if (!prev || (!(prev.width >= 400) && Math.abs((prev.aspectRatio || 0) - ar) >= 0.002)) {
                  pageDimensions.set(page, { width: 0, height: 0, aspectRatio: ar });
                  const item = pageItemMap.get(page);
                  if (item) applyItemDimensions(item, page);
                  if (!stripActive && page === currentPageNum) {
                    showSinglePageThumbnailPlaceholder(page);
                  }
                }
              }
            }

            const spriteStyle = (thumbDiv?.getAttribute('style') || '') + ' ' + (a.getAttribute('style') || '');
            const mUrl = spriteStyle.match(/url\(["']?([^"')]+)["']?\)/i);
            const bgUrl = mUrl ? mUrl[1].trim() : '';

            const rawSrc = img ? (img.getAttribute('src') || img.src || '') : '';
            const isBlank = !rawSrc || rawSrc.includes('blank.gif') || rawSrc.includes('/t.png') || rawSrc.endsWith('/t.png') || rawSrc.startsWith('data:image');



            let newInfo = null;
            if (bgUrl) {
              // Sprite: the region needs its REAL w/h (never guessed) plus its offset inside the sheet
              if (w > 0 && h > 0) {
                const num = '(-?\\d+(?:\\.\\d+)?)(?:px)?';
                const mPos = spriteStyle.match(new RegExp('background-position:\\s*' + num + '\\s+' + num, 'i')) ||
                             spriteStyle.match(new RegExp('url\\([^)]+\\)\\s+' + num + '\\s+' + num, 'i')) ||
                             spriteStyle.match(new RegExp(num + '\\s+' + num + '\\s+no-repeat', 'i'));
                const posX = mPos ? Math.abs(parseFloat(mPos[1]) || 0) : 0;
                const posY = mPos ? Math.abs(parseFloat(mPos[2]) || 0) : 0;
                newInfo = { type: 'sprite', page, x: posX, y: posY, w, h, url: bgUrl };
              }
            } else if (img && !isBlank) {
              newInfo = { type: 'url', page, url: rawSrc, w, h };
            }

            if (newInfo) {
              const prevInfo = pageThumbnailCache.get(page);
              const same = prevInfo && prevInfo.url === newInfo.url &&
                           (prevInfo.x || 0) === (newInfo.x || 0) && (prevInfo.y || 0) === (newInfo.y || 0);
              if (!same) {
                pageThumbnailCache.set(page, newInfo);
                changedThumbPages.push(page);
              }
            }
          }
        });

        // If page 1 was not in the thumbnail grid (e.g. gallery page 2+), derive its thumbnail from #gd1 cover
        if (!pageThumbnailCache.has(1)) {
          for (const el of doc.querySelectorAll('#gd1 [style*="url("], #gd1 img')) {
            const m = (el.getAttribute('style') || '').match(/url\(["']?([^"')]+)["']?\)/i);
            const bgUrl = m ? m[1].trim() : '';
            const rawSrc = el.tagName === 'IMG' ? (el.getAttribute('src') || el.src || '') : '';
            const coverUrl = bgUrl || rawSrc;
            if (coverUrl && !coverUrl.includes('blank.gif') && !coverUrl.includes('/t.png') && !coverUrl.startsWith('data:image')) {
              let cw = parseInt(el.style?.width, 10) || parseInt(el.getAttribute?.('width') || el.width || '0', 10) || 0;
              let ch = parseInt(el.style?.height, 10) || parseInt(el.getAttribute?.('height') || el.height || '0', 10) || 0;
              pageThumbnailCache.set(1, { type: 'url', page: 1, url: coverUrl, w: cw, h: ch });
              changedThumbPages.push(1);
              break;
            }
          }
        }

        // Paint after the layout work above, and start loading the sheets near the reading position
        for (const p of changedThumbPages) notifyThumbReady(p);
        if (isReaderPage && changedThumbPages.length > 0) warmThumbsAround(currentPageNum || 1, true);
        if (cachedGid) {
          saveGalleryDataToSession(cachedGid);
        }
      } catch (err) {
        console.warn('ExFastReader: Failed to extract gallery dimensions/thumbnails', err);
      }
      return count;
    }

    const galleryFailures = new Map();

    async function fetchGalleryPage(pageIndex, isPriority = false, forceRetry = false) {
      if (pageIndex < 0) return;
      if (apiCircuitBreaker.isBlocked(isPriority)) return;
      const lastFetch = fetchedGalleryPages.get(pageIndex);
      if (!forceRetry && lastFetch && Date.now() - lastFetch < 30000) {
        return;
      }
      if (forceRetry && lastFetch && Date.now() - lastFetch < 3000) {
        return;
      }
      if (inFlightGalleryFetches.has(pageIndex)) {
        return inFlightGalleryFetches.get(pageIndex);
      }

      let baseGalleryUrl = getBaseGalleryUrl();
      if (!baseGalleryUrl) {
        for (let i = 0; i < 8; i++) {
          await new Promise(r => setTimeout(r, 80));
          baseGalleryUrl = getBaseGalleryUrl();
          if (baseGalleryUrl) break;
        }
      }
      if (!baseGalleryUrl) return;

      const promise = (async () => {
        await galleryLimiter.acquire(isPriority);
        try {
          if (apiCircuitBreaker.isBlocked(isPriority)) return;
          // Far from the reading position by the time it's our turn: skip it
          if (!isPriority && !forceRetry && isReaderPage) {
            const activeG = Math.max(0, Math.floor(((currentPageNum || 1) - 1) / thumbsPerPage));
            if (Math.abs(pageIndex - activeG) > 3) return;
          }
          let fetchUrl;
          try {
            const u = new URL(baseGalleryUrl, location.origin);
            if (pageIndex > 0) {
              u.searchParams.set('p', String(pageIndex));
            } else {
              u.searchParams.delete('p');
            }
            fetchUrl = u.href;
          } catch {
            const cleanBase = baseGalleryUrl.replace(/[?&]p=\d+/g, '').replace(/\?$/, '');
            fetchUrl = pageIndex > 0 ? (cleanBase + (cleanBase.includes('?') ? '&' : '?') + 'p=' + pageIndex) : cleanBase;
          }

          const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
          const timer = setTimeout(() => { if (controller) controller.abort(); }, 16000);
          const fetchOptions = {
            credentials: 'include',
            signal: controller ? controller.signal : undefined
          };
          if (isPriority) {
            fetchOptions.priority = 'high';
          }
          const res = await fetch(fetchUrl, fetchOptions).finally(() => clearTimeout(timer));

          if (res.ok) {
            fetchedGalleryPages.set(pageIndex, Date.now());
            galleryFailures.delete(pageIndex);
            const html = await res.text();
            extractKeysFromText(html);
            extractDimensionsFromGallery(html);
          } else {
            const fails = (galleryFailures.get(pageIndex) || 0) + 1;
            galleryFailures.set(pageIndex, fails);
            const cooldown = Math.min(60000, 5000 * Math.pow(2, fails - 1));
            fetchedGalleryPages.set(pageIndex, Date.now() - 30000 + cooldown);
            if (res.status === 429 || res.status === 509) {
              apiCircuitBreaker.recordError(res.status, 'Gallery rate limit');
            }
          }
        } catch (err) {
          if (err && err.name !== 'AbortError') {
            console.warn('ExFastReader: Gallery fetch failed for p=' + pageIndex, err.message || err);
          }
          const fails = (galleryFailures.get(pageIndex) || 0) + 1;
          galleryFailures.set(pageIndex, fails);
          const cooldown = Math.min(60000, 5000 * Math.pow(2, fails - 1));
          fetchedGalleryPages.set(pageIndex, Date.now() - 30000 + cooldown);
        } finally {
          galleryLimiter.release();
          inFlightGalleryFetches.delete(pageIndex);
        }
      })();

      inFlightGalleryFetches.set(pageIndex, promise);
      return promise;
    }

    const inFlightKeyLookups = new Map();

    async function ensureKeysForPage(targetPageNo, isPriority = true) {
      if (pageImgKeyCache.has(targetPageNo)) {
        return pageImgKeyCache.get(targetPageNo);
      }
      const total = getTotalPages();
      if (total > 0 && targetPageNo > total) {
        return null;
      }
      if (inFlightKeyLookups.has(targetPageNo)) {
        return inFlightKeyLookups.get(targetPageNo);
      }

      const lookupPromise = (async () => {
        const primaryIndex = Math.max(0, Math.floor((targetPageNo - 1) / thumbsPerPage));
        const lastPrimaryFetch = fetchedGalleryPages.get(primaryIndex) || 0;
        const needPrimaryFetch = (Date.now() - lastPrimaryFetch > 8000) || (!pageThumbnailCache.has(targetPageNo));

        if (needPrimaryFetch) {
          await fetchGalleryPage(primaryIndex, isPriority, false);
        }
        if (pageImgKeyCache.has(targetPageNo)) {
          return pageImgKeyCache.get(targetPageNo);
        }

        // Check alternate counts (20, 100, 40) only if primaryIndex succeeded without finding key
        for (const altCount of [20, 40, 100]) {
          if (altCount === thumbsPerPage) continue;
          const altIndex = Math.max(0, Math.floor((targetPageNo - 1) / altCount));
          if (altIndex !== primaryIndex) {
            const lastAltFetch = fetchedGalleryPages.get(altIndex) || 0;
            if (Date.now() - lastAltFetch > 8000) {
              await fetchGalleryPage(altIndex, isPriority, false);
              if (pageImgKeyCache.has(targetPageNo)) {
                thumbsPerPage = altCount;
                safeStorageSet(localStorage, 'eh_thumbs_per_page', String(altCount));
                return pageImgKeyCache.get(targetPageNo);
              }
            }
          }
        }

        return pageImgKeyCache.get(targetPageNo) || null;
      })();

      inFlightKeyLookups.set(targetPageNo, lookupPromise);
      try {
        return await lookupPromise;
      } finally {
        inFlightKeyLookups.delete(targetPageNo);
      }
    }

    function getNextMeta(doc) {
      const nextEl = doc.querySelector('a#next');
      if (!nextEl) return null;
      const oc = nextEl.getAttribute('onclick') || '';
      const mClick = oc.match(/load_image\((\d+),\s*['"]([^'"]+)['"]\)/i);
      if (mClick) return { page: parseInt(mClick[1], 10), imgKey: mClick[2] };
      const mHref = (nextEl.getAttribute('href') || nextEl.href || '').match(/\/s\/([0-9a-zA-Z]+)\/\d+-(\d+)/i);
      if (mHref) return { page: parseInt(mHref[2], 10), imgKey: mHref[1] };
      return null;
    }

    function getTotalPages() {
      if (cachedTotalPages > 0) return cachedTotalPages;
      const spans = document.querySelectorAll('#i2 .sn span, #i4 .sn span');
      for (let i = 0; i < spans.length; i++) {
        const t = parseInt(spans[i].textContent.trim(), 10);
        if (!isNaN(t) && t > 1) {
          if (i + 1 < spans.length) {
            const nextT = parseInt(spans[i + 1].textContent.trim(), 10);
            if (!isNaN(nextT) && nextT >= t) {
              cachedTotalPages = nextT;
              return nextT;
            }
          }
          cachedTotalPages = t;
          return t;
        }
      }
      const snEl = document.querySelector('#i2 .sn, #i4 .sn, .sn');
      if (snEl) {
        const match = snEl.textContent.match(/(\d+)\s*\/\s*(\d+)/);
        if (match) {
          const total = parseInt(match[2], 10);
          if (!isNaN(total) && total > 0) {
            cachedTotalPages = total;
            return total;
          }
        }
      }
      return 0;
    }

    function getPageParams() {
      if (cachedGid && cachedShowkey) {
        return { gid: cachedGid, showkey: cachedShowkey, api_url: cachedApiUrl };
      }
      let gid = window.gid || cachedGid;
      let showkey = window.showkey || cachedShowkey;
      let api_url = window.api_url || cachedApiUrl;

      if (!gid || !showkey) {
        for (const s of document.scripts) {
          const txt = s.textContent || '';
          if (!gid) {
            const mG = txt.match(/var\s+gid\s*=\s*(\d+)/);
            if (mG) gid = parseInt(mG[1], 10);
          }
          if (!showkey) {
            const mS = txt.match(/var\s+showkey\s*=\s*['"]([^'"]+)['"]/);
            if (mS) showkey = mS[1];
          }
          if (!api_url) {
            const mA = txt.match(/var\s+api_url\s*=\s*['"]([^'"]+)['"]/);
            if (mA) api_url = mA[1];
          }
          if (gid && showkey && api_url) break;
        }
      }
      if (gid) cachedGid = parseInt(gid, 10);
      if (showkey) cachedShowkey = showkey;
      if (api_url) cachedApiUrl = normalizeApiUrl(api_url);
      else cachedApiUrl = location.origin + '/api.php';

      return { gid: cachedGid || 0, showkey, api_url: cachedApiUrl };
    }

    function pruneCaches(activePage) {
      if (stripActive) {
        const UNLOAD_DISTANCE = 80;
        for (const p of Array.from(loadedImages)) {
          if (Math.abs(p - activePage) > UNLOAD_DISTANCE) {
            const item = pageItemMap.get(p);
            if (item) {
              if (typeof item._abortImg === 'function') {
                item._abortImg();
                item._abortImg = null;
              }
              const img = item.querySelector('img.eh-main-img');
              if (img) {
                img.onload = null;
                img.onerror = null;
                img.removeAttribute('src');
                img.src = '';
                img.remove();
              }
              item._currentImgToken = null;
              loadedImages.delete(p);
              renderPlaceholderContent(item, p);
            }
          }
        }

        // Immediately abort any in-flight image loads that fell outside active window
        for (const [p, startTime] of loadingPages.entries()) {
          if (Math.abs(p - activePage) > (PREFETCH_DEPTH + 3)) {
            abortPageLoad(p);
          }
        }

        // Unload distant placeholder thumbnail images (> 80 pages away) to free memory
        const THUMB_UNLOAD_DISTANCE = 80;
        for (const [p, item] of pageItemMap.entries()) {
          if (Math.abs(p - activePage) > THUMB_UNLOAD_DISTANCE && item.classList.contains('eh-placeholder')) {
            const oldThumbs = item.querySelectorAll('.eh-placeholder-thumb-img, .eh-placeholder-thumb-wrap');
            if (oldThumbs.length > 0) {
              oldThumbs.forEach(t => {
                if (t.tagName === 'IMG') {
                  t.onload = null;
                  t.onerror = null;
                  t.removeAttribute('src');
                  t.src = '';
                } else {
                  t.querySelectorAll('img').forEach(i => {
                    i.onload = null;
                    i.onerror = null;
                    i.removeAttribute('src');
                    i.src = '';
                  });
                }
                t.remove();
              });
            }
          }
        }
      }

      for (const [page, record] of preloadedImages.entries()) {
        if (stripActive || page < activePage - 4 || page > activePage + 8) {
          if (record && !record.isReady && record.img) {
            record.img.onload = null;
            record.img.onerror = null;
            record.img.removeAttribute('src');
            record.img.src = '';
          }
          preloadedImages.delete(page);
        }
      }

      const respLimit = stripActive ? 80 : 40;
      if (pageRespCache.size > respLimit) {
        for (const page of pageRespCache.keys()) {
          if (Math.abs(page - activePage) > (stripActive ? 40 : 14)) {
            pageRespCache.delete(page);
            if (pageRespCache.size <= respLimit) break;
          }
        }
      }

      if (pageImgSrcCache.size > 100) {
        for (const page of pageImgSrcCache.keys()) {
          if (Math.abs(page - activePage) > 50) {
            pageImgSrcCache.delete(page);
            if (pageImgSrcCache.size <= 100) break;
          }
        }
      }

      for (const key of inFlightFetches.keys()) {
        const p = parseInt(key, 10);
        if (!isNaN(p) && Math.abs(p - activePage) > 20) {
          inFlightFetches.delete(key);
        }
      }

      if (pageTopBarInfoCache.size > 150) {
        for (const page of pageTopBarInfoCache.keys()) {
          if (Math.abs(page - activePage) > 75) {
            pageTopBarInfoCache.delete(page);
            if (pageTopBarInfoCache.size <= 150) break;
          }
        }
      }

      if (pageFileSizeCache.size > 150) {
        for (const page of pageFileSizeCache.keys()) {
          if (Math.abs(page - activePage) > 75) {
            pageFileSizeCache.delete(page);
            if (pageFileSizeCache.size <= 150) break;
          }
        }
      }

      if (pageNlCodes.size > 80) {
        for (const page of pageNlCodes.keys()) {
          if (Math.abs(page - activePage) > 40) {
            pageNlCodes.delete(page);
            if (pageNlCodes.size <= 80) break;
          }
        }
      }

      if (failedPages.size > 50) {
        for (const page of failedPages.keys()) {
          if (Math.abs(page - activePage) > 40) {
            failedPages.delete(page);
            if (failedPages.size <= 50) break;
          }
        }
      }

      const curGalleryIdx = Math.max(0, Math.floor((activePage - 1) / thumbsPerPage));
      if (fetchedGalleryPages.size > 40) {
        for (const gIdx of fetchedGalleryPages.keys()) {
          if (Math.abs(gIdx - curGalleryIdx) > 15) {
            fetchedGalleryPages.delete(gIdx);
            if (fetchedGalleryPages.size <= 40) break;
          }
        }
      }

      if (pageImgKeyCache.size > 500) {
        for (const page of pageImgKeyCache.keys()) {
          if (Math.abs(page - activePage) > 250) {
            pageImgKeyCache.delete(page);
            if (pageImgKeyCache.size <= 500) break;
          }
        }
      }
    }

    function fetchApi(pageNo, imgKey, nlCode = null, forceFresh = false, isPriority = false) {
      const parsedPage = parseInt(pageNo, 10);
      const tot = getTotalPages();
      if (!parsedPage || isNaN(parsedPage) || parsedPage < 1 || (tot > 0 && parsedPage > tot)) {
        return Promise.resolve(null);
      }
      if (!imgKey || typeof imgKey !== 'string' || !/^[0-9a-zA-Z]+$/.test(imgKey)) {
        return Promise.resolve(null);
      }

      if (apiCircuitBreaker.isBlocked(isPriority)) {
        return Promise.resolve(null);
      }

      const cacheKey = parsedPage + (nlCode ? '_' + nlCode : '');
      if (!forceFresh && inFlightFetches.has(cacheKey)) {
        return inFlightFetches.get(cacheKey);
      }

      const { gid, showkey } = getPageParams();
      if (!gid || !showkey || !imgKey) return Promise.resolve(null);

      const requestPayload = {
        method: 'showpage',
        gid: parseInt(gid, 10),
        page: parsedPage,
        imgkey: String(imgKey).trim(),
        showkey: showkey
      };
      if (nlCode) {
        requestPayload.nl = nlCode;
      }

      const fetchPromise = (async () => {
        await apiLimiter.acquire(isPriority);
        let slotReleased = false;
        const releaseSlot = () => {
          if (!slotReleased) { slotReleased = true; apiLimiter.release(); }
        };

        const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
        const timer = setTimeout(() => {
          if (controller) controller.abort();
        }, 18000);

        try {
          if (apiCircuitBreaker.isBlocked(isPriority)) return null;

          if (stripActive && !isPageInActiveWindow(parsedPage, 6)) return null;
          if (!stripActive && !isPriority && Math.abs(parsedPage - currentPageNum) > 6) return null;
          if (!stripActive && isPriority && !forceFresh && Math.abs(parsedPage - currentPageNum) > 2) return null;

          if (isPriority && apiCircuitBreaker.backoffUntil > Date.now()) {
            const waitTime = Math.min(3000, apiCircuitBreaker.backoffUntil - Date.now());
            if (waitTime > 0) await new Promise(r => setTimeout(r, waitTime));
          }

          const fetchOptions = {
            method: 'POST',
            credentials: 'include',
            headers: {
              'Content-Type': 'application/json',
              'Accept': 'application/json'
            },
            body: JSON.stringify(requestPayload),
            signal: controller ? controller.signal : undefined
          };
          if (isPriority) {
            fetchOptions.priority = 'high';
          }

          const res = await fetch(location.origin + '/api.php', fetchOptions);

          clearTimeout(timer);

          if (!res.ok) {
            apiCircuitBreaker.recordError(res.status, `HTTP ${res.status}`);
            console.warn(`[ExFastReader] API returned HTTP ${res.status} on page ${parsedPage}`);
            return null;
          }

          const text = await res.text();
          const data = safeParseJson(text);

          if (data && !data.error) {
            apiCircuitBreaker.recordSuccess();
            cacheBottomFromResp(parsedPage, data);
            return data;
          } else {
            let errStr = data?.error || '';
            if (!errStr && typeof text === 'string') {
              const trimmed = text.trim();
              if (trimmed.length > 0 && trimmed.length < 200 && !trimmed.startsWith('<')) {
                errStr = trimmed;
              } else if (/key expired|invalid key/i.test(trimmed)) {
                errStr = 'Key expired';
              } else if (/image limit exceeded/i.test(trimmed)) {
                errStr = 'Image limit exceeded';
              } else if (/temporarily banned/i.test(trimmed)) {
                errStr = 'IP temporarily banned';
              }
            }
            if (!errStr) errStr = 'Invalid API response';

            // If key is reported expired, invalid, or invalid page, purge stale key and gallery cache
            if (/key expired|invalid key|invalid page|page not found/i.test(errStr)) {
              pageImgKeyCache.delete(parsedPage);
              const gIdx = Math.max(0, Math.floor((parsedPage - 1) / thumbsPerPage));
              fetchedGalleryPages.delete(gIdx);
              cachedShowkey = null; // showkey might be stale
            }

            apiCircuitBreaker.recordError(200, errStr);
            console.warn(`[ExFastReader] API returned error on page ${parsedPage}:`, errStr);
            return null;
          }
        } catch (err) {
          clearTimeout(timer);
          if (err && err.name === 'AbortError') {
            return null;
          }
          apiCircuitBreaker.recordError(0, err && err.message);
          console.warn(`[ExFastReader] Request failed on page ${parsedPage}:`, (err && err.message) || 'Network error');
          return null;
        } finally {
          clearTimeout(timer);
          releaseSlot();
          inFlightFetches.delete(cacheKey);
        }
      })();

      inFlightFetches.set(cacheKey, fetchPromise);
      return fetchPromise;
    }

    function prefetchSingleMode(cur) {
      if (stripActive || isFastScrolling()) return;
      if (apiCircuitBreaker.isBlocked(false)) return;
      const total = getTotalPages();
      prefetchNearbyThumbnails(cur);

      // Fetch API data for nearest pages ahead/behind, skipping failed pages
      const targets = [cur + 1, cur + 2, cur + 3, cur - 1, cur + 4, cur - 2]
        .filter(p => p >= 1 && (!total || p <= total) && !failedPages.has(p));

      let delay = 0;
      for (const p of targets) {
        if (apiCircuitBreaker.isBlocked(false)) break;
        const wantImage = p >= cur - 1 && p <= cur + 3;

        const handleResp = (resp) => {
          const d = parseRespDisplayDims(resp);
          if (d) recordDisplayDims(p, d.width, d.height);
          if (!wantImage) return;
          const i3Doc = parseTemplate(resp.i3);
          const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
          const imgSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
          if (imgSrc) {
            pageImgSrcCache.set(p, imgSrc);
            if (!preloadedImages.has(p)) preloadSingleImage(p, imgSrc);
          }
        };

        if (pageRespCache.has(p)) {
          handleResp(pageRespCache.get(p));
          continue;
        }

        const runFetch = (key) => {
          if (!key || apiCircuitBreaker.isBlocked(false)) return;
          fetchApi(p, key).then((resp) => {
            if (resp) {
              pageRespCache.set(p, resp);
              extractKeysFromResp(resp, p);
              const extracted = extractInfoFromResp(resp, p);
              if (extracted) pageTopBarInfoCache.set(p, extracted);
              handleResp(resp);
            }
          });
        };

        const targetP = p;
        setTimeout(() => {
          if (stripActive || currentPageNum !== cur || apiCircuitBreaker.isBlocked(false)) return;
          const imgKey = pageImgKeyCache.get(targetP);
          if (imgKey) {
            runFetch(imgKey);
          } else {
            ensureKeysForPage(targetP, false).then((key) => {
              if (key) runFetch(key);
            });
          }
        }, delay);
        delay += 60;
      }
    }

    function preloadSingleImage(pageNo, src, attempt = 0) {
      if (preloadedImages.has(pageNo)) return;
      const img = new Image();
      img.fetchPriority = 'low';
      img.decoding = 'async';
      const startTime = Date.now();
      const record = { img, isReady: false, promise: null, startTime };
      let settled = false;
      let stallId = null;

      const finish = () => {
        settled = true;
        if (stallId) { clearTimeout(stallId); stallId = null; }
      };

      const retry = (why) => {
        if (preloadedImages.get(pageNo) === record) preloadedImages.delete(pageNo);
        if (attempt >= MAX_RETRIES) return;
        if (apiCircuitBreaker.isBlocked(false)) return;
        console.warn(`ExFastReader: Preload of page ${pageNo} ${why}, retry ${attempt + 1}/${MAX_RETRIES}`);
        const delay = Math.min(6000, 1000 * Math.pow(1.8, attempt));
        setTimeout(async () => {
          if (stripActive || preloadedImages.has(pageNo) || apiCircuitBreaker.isBlocked(false)) return;
          const key = pageImgKeyCache.get(pageNo);
          if (!key) return;
          const fresh = await fetchApi(pageNo, key, pageNlCodes.get(pageNo) || null, true);
          if (!fresh || fresh.error || stripActive) return;
          pageRespCache.set(pageNo, fresh);
          const newNl = extractNlCode(fresh);
          if (newNl) pageNlCodes.set(pageNo, newNl);
          const d = parseRespDisplayDims(fresh);
          if (d) recordDisplayDims(pageNo, d.width, d.height);
          const i3Doc = parseTemplate(fresh.i3);
          const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
          const newSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
          if (newSrc) {
            pageImgSrcCache.set(pageNo, newSrc);
            preloadSingleImage(pageNo, newSrc, attempt + 1);
          }
        }, delay);
      };

      record.promise = new Promise((resolve) => {
        img.onload = async () => {
          img.onload = null;
          img.onerror = null;
          finish();
          if (img.naturalWidth === 0 || img.naturalHeight === 0) {
            record.isReady = false;
            resolve(null);
            retry('0x0 dimension on preload');
            return;
          }
          try {
            if ('decode' in img) await img.decode();
          } catch {}
          record.isReady = true;
          updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
          const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
          recordDownloadSpeedSample(pageNo, durationSec);
          resolve(img);
        };
        img.onerror = () => {
          img.onload = null;
          img.onerror = null;
          finish();
          record.isReady = false;
          resolve(null);
          retry('failed to load');
        };

        const { hardTimeoutMs } = calculateImageTimeout(pageNo);
        stallId = setTimeout(() => {
          if (settled || record.isReady) return;
          if (preloadedImages.get(pageNo) !== record) { finish(); return; }
          finish();
          img.onload = null;
          img.onerror = null;
          img.removeAttribute('src');
          img.src = '';
          resolve(null);
          retry(`stalled (> ${(hardTimeoutMs / 1000).toFixed(1)}s)`);
        }, hardTimeoutMs);

        img.src = src;
      });
      preloadedImages.set(pageNo, record);
    }
    function startPrefetch() {
      const nextMeta = getNextMeta(document);
      if (nextMeta) {
        pageImgKeyCache.set(nextMeta.page, nextMeta.imgKey);
      }
      prefetchSingleMode(currentPageNum);
    }

    // =========================================================================
    // 4. BIDIRECTIONAL STRIP MODE ENGINE (RTL & VERTICAL WITH PLACEHOLDERS)
    // =========================================================================
    function getOrCreateStripContainer() {
      if (!isReaderPage || !stripActive) return null;
      let container = document.getElementById('eh-strip-container');
      if (!container) {
        container = document.createElement('div');
        container.id = 'eh-strip-container';
        container.className = `eh-strip-container layout-${currentLayout}`;

        const i3 = document.getElementById('i3');
        if (i3 && i3.parentNode) {
          i3.parentNode.insertBefore(container, i3.nextSibling);
        } else {
          const i1 = document.getElementById('i1') || document.body;
          i1.appendChild(container);
        }

        container.addEventListener('scroll', () => {
          lastScrollTime = Date.now();
          scheduleScrollUpdate();
        }, { passive: true });

        container.addEventListener('pointerover', (e) => {
          if (Date.now() - lastScrollTime < 350 || isFastScrolling()) return;
          const item = e.target.closest('.eh-strip-item');
          if (item && item.dataset.page && item.classList.contains('eh-placeholder')) {
            const page = parseInt(item.dataset.page, 10);
            if (page && isPageEligibleToLoad(page)) {
              loadPage(page, 0);
            }
          }
        }, { passive: true });
      }
      return container;
    }

    function ensureStartSpacer() {
      if (!isReaderPage || !stripActive) return null;
      const container = getOrCreateStripContainer();
      if (!container) return null;
      let spacer = document.getElementById('eh-strip-start-spacer');
      if (!spacer) {
        spacer = document.createElement('div');
        spacer.id = 'eh-strip-start-spacer';
        spacer.className = 'eh-strip-start-spacer';
        if (container.firstChild) {
          container.insertBefore(spacer, container.firstChild);
        } else {
          container.appendChild(spacer);
        }
      } else if (spacer !== container.firstChild) {
        container.insertBefore(spacer, container.firstChild);
      }
      return spacer;
    }

    function ensureBottomGalleryButton() {
      if (!stripActive) return;
      const url = getGalleryUrl();

      if (currentLayout === 'vertical') {
        const existingEndCard = document.getElementById('eh-strip-end-card');
        if (existingEndCard) existingEndCard.remove();

        let bar = document.getElementById('eh-bottom-gallery-bar');
        if (!bar) {
          bar = document.createElement('div');
          bar.id = 'eh-bottom-gallery-bar';
          bar.className = 'eh-bottom-gallery-bar';
          bar.innerHTML = `<a href="${url}">← Return to Gallery</a>`;
          const container = getOrCreateStripContainer();
          if (container.parentNode) {
            container.parentNode.insertBefore(bar, container.nextSibling);
          }
        } else {
          const link = bar.querySelector('a');
          if (link) link.href = url;
        }
      } else {
        const existingBar = document.getElementById('eh-bottom-gallery-bar');
        if (existingBar) existingBar.remove();

        const container = getOrCreateStripContainer();
        let endCard = document.getElementById('eh-strip-end-card');
        if (!endCard) {
          endCard = document.createElement('div');
          endCard.id = 'eh-strip-end-card';
          endCard.className = 'eh-strip-item eh-strip-end-card';
          endCard.innerHTML = `<div class="eh-bottom-gallery-bar"><a href="${url}">← Return to Gallery</a></div>`;
          container.appendChild(endCard);
        } else {
          const link = endCard.querySelector('a');
          if (link) link.href = url;
          if (endCard.nextSibling) {
            container.appendChild(endCard);
          }
        }
      }
    }

    function applyItemDimensions(item, pageNo) {
      const dim = pageDimensions.get(pageNo);
      const th = pageThumbnailCache.get(pageNo);
      const thumbAr = (th && th.w > 0 && th.h > 0) ? (th.w / th.h) : 0;
      // One source of truth for placeholder size: the same estimate the single-page view uses
      const tgt = getPageTargetDimensions(pageNo);
      const ar = (tgt && tgt.aspectRatio > 0) ? tgt.aspectRatio : (dim?.aspectRatio || thumbAr || defaultAspectRatio);

      // Anti-shift rule. A thumbnail-based estimate is only good to a few pixels, so when the real
      // size arrives we must not resize a box that sits where resizing would move what you are
      // looking at (the page itself, or anything already scrolled past). A <1.5% difference is
      // absorbed instead: the box keeps its size and the image fills it (a 2-5px stretch on a
      // 2000px page is invisible). Boxes still ahead of you off-screen are corrected normally, and
      // a larger mismatch is always corrected (the scroll anchor compensates).
      // (The width must also match: in vertical mode a different served width at the same ratio,
      // e.g. an 886px image in a 1063px box, would otherwise be shown upscaled.)
      const newW = (tgt && tgt.width > 500) ? tgt.width : 0;
      const widthClose = !item._appliedW || !newW || Math.abs(newW / item._appliedW - 1) < 0.015;
      if (item._appliedAr && item._appliedLayout === currentLayout && item.isConnected &&
          widthClose && Math.abs(ar / item._appliedAr - 1) < 0.015) {
        const r = item.getBoundingClientRect();
        const wouldShift = currentLayout === 'horizontal' ? r.right > 0 : r.top < window.innerHeight;
        if (wouldShift) return;
      }
      item._appliedAr = ar;
      item._appliedW = newW;
      item._appliedLayout = currentLayout;

      item.style.setProperty('--eh-ar', String(ar));
      item.style.aspectRatio = String(ar);

      if (currentLayout === 'horizontal') {
        item.style.width = `calc(100vh * ${ar})`;
        item.style.maxWidth = '';
      } else {
        item.style.width = '100%';
        const maxW = (tgt && tgt.width > 500) ? tgt.width : ((dim && dim.width && dim.width > 500) ? dim.width : 0);
        if (maxW > 500) {
          item.style.setProperty('--eh-max-w', maxW + 'px');
        } else {
          item.style.setProperty('--eh-max-w', '100%');
        }
      }
    }

function renderPlaceholderContent(item, pageNo, retryCount = 0) {
      item.classList.add('eh-placeholder');
      if (loadingPages.has(pageNo)) {
        item.classList.add('eh-loading');
      } else {
        item.classList.remove('eh-loading');
      }

      const oldContent = item.querySelector('.eh-placeholder-content');
      if (oldContent) oldContent.remove();

      // Only eagerly attach thumbnails for immediate visible neighbors; others are loaded lazily by IntersectionObserver
      if (Math.abs(pageNo - currentPageNum) <= 6) {
        applyThumbnailToPlaceholder(item, pageNo);
      }

      // Only show the spinner & status text if the image is having trouble loading
      const failInfo = failedPages.get(pageNo);
      const isTroubled = (retryCount > 0) || (failInfo && failInfo.count > 0);

      if (isTroubled) {
        if (failInfo && failInfo.count >= MAX_RETRIES) {
          showRetryUI(item, pageNo);
        } else {
          const content = document.createElement('div');
          content.className = 'eh-placeholder-content';

          const spinner = document.createElement('div');
          spinner.className = 'eh-spinner';

          const text = document.createElement('div');
          text.className = 'eh-placeholder-text';
          text.textContent = `Retrying Page ${pageNo}...`;

          content.appendChild(spinner);
          content.appendChild(text);
          item.appendChild(content);
        }
      }
    }

    function ensureAllPlaceholders(tot) {
      if (!isReaderPage || !stripActive || !tot || tot <= 0) return;
      const container = getOrCreateStripContainer();
      let added = false;

      withScrollAnchor(() => {
        ensureStartSpacer();
        const obs = getStripItemObserver();
        const endCard = document.getElementById('eh-strip-end-card');

        let batchFrag = null;
        let batchNextEl = null;

        const flushBatch = () => {
          if (batchFrag && batchFrag.childNodes.length > 0) {
            if (batchNextEl && batchNextEl.isConnected) {
              container.insertBefore(batchFrag, batchNextEl);
            } else if (endCard && endCard.isConnected) {
              container.insertBefore(batchFrag, endCard);
            } else {
              container.appendChild(batchFrag);
            }
            batchFrag = null;
            batchNextEl = null;
          }
        };

        for (let p = 1; p <= tot; p++) {
          if (pageItemMap.has(p)) {
            flushBatch();
            continue;
          }

          const item = document.createElement('div');
          item.className = 'eh-strip-item eh-placeholder';
          item.dataset.page = String(p);
          applyItemDimensions(item, p);
          renderPlaceholderContent(item, p);
          pageItemMap.set(p, item);

          if (obs) obs.observe(item);

          if (!batchFrag) {
            batchFrag = document.createDocumentFragment();
            let nextEl = null;
            for (let nextP = p + 1; nextP <= tot; nextP++) {
              if (pageItemMap.has(nextP)) {
                const el = pageItemMap.get(nextP);
                if (el && el.isConnected) {
                  nextEl = el;
                  break;
                }
              }
            }
            batchNextEl = nextEl;
          }

          batchFrag.appendChild(item);
          added = true;
        }

        flushBatch();
      });

      if (added) {
        syncPageIndicator(currentPageNum);
      }
    }

    function createOrGetPlaceholder(pageNo) {
      if (!isReaderPage || !stripActive) return null;
      const container = getOrCreateStripContainer();
      if (!container) return null;
      let item = pageItemMap.get(pageNo);
      if (item) return item;

      item = document.createElement('div');
      item.className = 'eh-strip-item eh-placeholder';
      item.dataset.page = String(pageNo);

      applyItemDimensions(item, pageNo);
      renderPlaceholderContent(item, pageNo);
      pageItemMap.set(pageNo, item);

      const obs = getStripItemObserver();
      if (obs) obs.observe(item);

      withScrollAnchor(() => {
        ensureStartSpacer();
        const existing = container.querySelectorAll('.eh-strip-item:not(.eh-strip-end-card)');
        let inserted = false;
        for (const el of existing) {
          const p = parseInt(el.dataset.page, 10);
          if (p > pageNo) {
            container.insertBefore(item, el);
            inserted = true;
            break;
          }
        }
        if (!inserted) {
          const endCard = document.getElementById('eh-strip-end-card');
          if (endCard) {
            container.insertBefore(item, endCard);
          } else {
            container.appendChild(item);
          }
        }
      });

      return item;
    }

    function renderImageInItem(pageNo, imgSrc, retryCount = 0) {
      if (!isReaderPage || !stripActive) return;
      let item = pageItemMap.get(pageNo);
      if (!item) {
        item = createOrGetPlaceholder(pageNo);
      }
      if (!item) return;

      const existingImg = item.querySelector('img.eh-main-img');
      if (existingImg && existingImg.src === imgSrc && loadedImages.has(pageNo)) {
        loadingPages.delete(pageNo);
        setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
        return;
      }

      const img = document.createElement('img');
      img.alt = `Page ${pageNo}`;
      img.className = 'eh-main-img';
      img.decoding = 'async';
      const startTime = Date.now();

      const dim = pageDimensions.get(pageNo);
      if (dim && dim.width && dim.height && dim.width > 500) {
        img.width = dim.width;
        img.height = dim.height;
      }

      item._currentImgToken = img;

      let slowTimerId = null;
      let hardTimerId = null;
      let isSettled = false;

      const cleanupTimers = () => {
        if (slowTimerId) { clearTimeout(slowTimerId); slowTimerId = null; }
        if (hardTimerId) { clearTimeout(hardTimerId); hardTimerId = null; }
      };

      const cleanupImg = () => {
        cleanupTimers();
        img.onload = null;
        img.onerror = null;
      };

      const abortImg = () => {
        isSettled = true;
        cleanupImg();
        img.removeAttribute('src');
        img.src = '';
      };
      item._abortImg = abortImg;

      const handleFailure = (reason, isSlowStall = false) => {
        if (isSettled) return;
        isSettled = true;
        pageFailedSrc.set(pageNo, imgSrc);
        item._abortImg = null;
        cleanupImg();

        // Release socket connection immediately
        img.removeAttribute('src');
        img.src = '';

        loadingPages.delete(pageNo);
        if (item) item.classList.remove('eh-loading');
        setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);

        if (item._currentImgToken !== img) return;
        console.warn(`ExFastReader: Image for page ${pageNo} ${reason}, attempt ${retryCount + 1}/${MAX_RETRIES}`);

        if (retryCount < MAX_RETRIES) {
          loadingPages.set(pageNo, Date.now());
          const delay = isSlowStall ? 100 : Math.min(2000, 400 * Math.pow(1.5, retryCount));
          setTimeout(() => {
            if (!stripActive || !isPageInActiveWindow(pageNo)) {
              loadingPages.delete(pageNo);
              return;
            }
            reloadPageImage(pageNo, retryCount + 1);
          }, delay);
        } else {
          loadingPages.delete(pageNo);
          loadedImages.delete(pageNo);
          if (isPageInActiveWindow(pageNo)) {
            recordPageFailure(pageNo, reason);
            showRetryUI(item, pageNo);
          }
          setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);
        }
      };

      img.onload = async () => {
        if (!stripActive || isSettled) return;
        isSettled = true;
        item._abortImg = null;
        cleanupImg();

        loadingPages.delete(pageNo);
        if (item) item.classList.remove('eh-loading');
        setTimeout(() => { if (stripActive) checkAndLoadNearbyPages(); }, 10);

        if (item._currentImgToken !== img) return;

        if (img.naturalWidth === 0 || img.naturalHeight === 0) {
          handleFailure('0x0 dimension on arrival', false);
          return;
        }

        try {
          if ('decode' in img) await img.decode();
        } catch {}

        if (item._currentImgToken !== img) return;

        if (img.naturalWidth > 0 && img.naturalHeight > 0) {
          recordDisplayDims(pageNo, img.naturalWidth, img.naturalHeight);
          updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
        }

        if (!item.contains(img)) {
          item.appendChild(img);
        }

        item.classList.remove('eh-placeholder');

        const content = item.querySelector('.eh-placeholder-content');
        if (content) content.remove();

        const thumbs = item.querySelectorAll('.eh-placeholder-thumb-img, .eh-placeholder-thumb-wrap');
        thumbs.forEach(t => {
          if (t.tagName === 'IMG') {
            t.onload = null;
            t.onerror = null;
            t.removeAttribute('src');
            t.src = '';
          } else {
            t.querySelectorAll('img').forEach(i => { i.onload = null; i.onerror = null; i.removeAttribute('src'); i.src = ''; });
          }
          t.remove();
        });

        const oldImg = item.querySelector('img.eh-main-img');
        if (oldImg && oldImg !== img) {
          oldImg.onload = null;
          oldImg.onerror = null;
          oldImg.removeAttribute('src');
          oldImg.src = '';
          oldImg.remove();
        }

        loadedImages.add(pageNo);
        failedPages.delete(pageNo);

        const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
        recordDownloadSpeedSample(pageNo, durationSec);

        if (currentPageNum === pageNo) {
          updateTopBarInfo(pageNo);
        }
      };

      img.onerror = () => {
        handleFailure('network load error', false);
      };

      // Dynamic stall timeouts based on image file size and measured download speed
      const { fileSize, speed, slowTimeoutMs, hardTimeoutMs } = calculateImageTimeout(pageNo);

      slowTimerId = setTimeout(() => {
        if (!isPageInActiveWindow(pageNo)) {
          abortImg();
          loadingPages.delete(pageNo);
          return;
        }
        if (!img.complete || img.naturalWidth === 0) {
          console.warn(`ExFastReader: Page ${pageNo} (${(fileSize / 1024).toFixed(0)} KB) is exceptionally slow (> ${(slowTimeoutMs / 1000).toFixed(1)}s at ${(speed / (1024 * 1024)).toFixed(2)} MB/s). Triggering failover bounce.`);
          if (retryCount < MAX_RETRIES) {
            handleFailure(`exceptionally slow (> ${(slowTimeoutMs / 1000).toFixed(1)}s)`, true);
          }
        }
      }, slowTimeoutMs);

      // Hard stall timeout safeguard based on file size and download speed
      hardTimerId = setTimeout(() => {
        if (!isPageInActiveWindow(pageNo)) {
          abortImg();
          loadingPages.delete(pageNo);
          return;
        }
        if (!img.complete || img.naturalWidth === 0) {
          handleFailure(`hard stalled (> ${(hardTimeoutMs / 1000).toFixed(1)}s)`, false);
        }
      }, hardTimeoutMs);

      img.src = imgSrc;
    }

    function showRetryUI(item, pageNo) {
      if (!item) return;
      item.classList.add('eh-placeholder');
      item.classList.remove('eh-loading');
      applyThumbnailToPlaceholder(item, pageNo);

      const oldContent = item.querySelector('.eh-placeholder-content');
      if (oldContent) oldContent.remove();

      const content = document.createElement('div');
      content.className = 'eh-placeholder-content eh-placeholder-retry';
      content.style.cursor = 'pointer';
      content.innerHTML = `
        <div style="font-size: 20px;">⚠️</div>
        <div class="eh-placeholder-text">Page ${pageNo} failed to load.</div>
        <button type="button" class="eh-strip-btn active" style="margin-top: 4px;">Click to reload broken image</button>
      `;

      const handleRetry = (e) => {
        if (e) {
          e.preventDefault();
          e.stopPropagation();
        }
        content.onclick = null;
        const btn = content.querySelector('button');
        if (btn) btn.onclick = null;

        if (activeRetryTimers.has(pageNo)) {
          clearTimeout(activeRetryTimers.get(pageNo));
          activeRetryTimers.delete(pageNo);
        }
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        failedPages.delete(pageNo);

        pageImgSrcCache.delete(pageNo);
        pageRespCache.delete(pageNo);
        pageImgKeyCache.delete(pageNo); // Force fresh key lookup on manual retry!
        const gIdx = Math.max(0, Math.floor((pageNo - 1) / thumbsPerPage));
        fetchedGalleryPages.delete(gIdx);
        cachedShowkey = null;

        apiCircuitBreaker.reset();
        clearFailureToast();

        renderPlaceholderContent(item, pageNo, 1);
        reloadPageImage(pageNo, 0);
      };

      content.onclick = handleRetry;
      const retryBtn = content.querySelector('button');
      if (retryBtn) {
        retryBtn.onclick = handleRetry;
      }
      item.appendChild(content);
    }

    async function reloadPageImage(pageNo, retryCount = 0) {
      if (!stripActive) {
        reloadBrokenImageSingle(pageNo, true);
        return;
      }
      if (!isPageInActiveWindow(pageNo)) {
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        return;
      }

      if (retryCount >= MAX_RETRIES) {
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        recordPageFailure(pageNo, 'Exceeded max retries');
        const item = pageItemMap.get(pageNo);
        if (item) showRetryUI(item, pageNo);
        return;
      }

      reloadingPages.add(pageNo);
      loadingPages.set(pageNo, Date.now());
      pageImgSrcCache.delete(pageNo);
      pageRespCache.delete(pageNo);

      const item = pageItemMap.get(pageNo);
      if (item && !item.querySelector('img.eh-main-img')) {
        renderPlaceholderContent(item, pageNo, retryCount + 1);
      }

      const nl = pageNlCodes.get(pageNo) || null;
      let imgKey = pageImgKeyCache.get(pageNo);
      if (!imgKey) {
        imgKey = await ensureKeysForPage(pageNo, true);
      }

      if (!isPageInActiveWindow(pageNo)) {
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        return;
      }
      const { gid } = getPageParams();

      // 1. Try direct HTML failover fetch: works with or without nl!
      if (imgKey && gid) {
        // The server only rotates to another H@H node when asked with ?nl=<code>. On the first
        // failure we may not know the code yet; the page HTML contains it, so fetch once to learn
        // it and, if that handed back the SAME dead URL, immediately ask again with the new code
        // rather than burning a retry on a URL that is known to 404.
        const failedSrc = pageFailedSrc.get(pageNo) || null;
        let nlToUse = nl;
        try {
          for (let hop = 0; hop < 3; hop++) {
            const failoverUrl = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nlToUse ? `?nl=${encodeURIComponent(nlToUse)}` : '');
            const res = await fetch(failoverUrl, { credentials: 'include', priority: 'high' });
            if (!res.ok) {
              apiCircuitBreaker.recordError(res.status, `HTML failover HTTP ${res.status}`);
              break;
            }
            const html = await res.text();
            if (/image limit exceeded|temporarily banned|excessive pageloads/i.test(html)) {
              apiCircuitBreaker.recordError(509, 'Image limit exceeded / IP banned');
            }
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const newImg = doc.getElementById('img');
            const newSrc = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
            const nextNl = extractNlCode(doc);
            if (nextNl) pageNlCodes.set(pageNo, nextNl);
            for (const scr of doc.scripts) {
              const mS = (scr.textContent || '').match(/var\s+showkey\s*=\s*['"]([^'"]+)['"]/);
              if (mS) { cachedShowkey = mS[1]; break; }
            }
            const usable = newSrc && !newSrc.includes('blank.gif') && !newSrc.includes('509.gif');
            const sameDeadUrl = usable && failedSrc && newSrc === failedSrc;
            if (usable && !sameDeadUrl) {
              reloadingPages.delete(pageNo);
              pageImgSrcCache.set(pageNo, newSrc);
              renderImageInItem(pageNo, newSrc, retryCount);
              return;
            }
            // Same dead URL (or nothing usable): retry with a freshly learned, different nl code
            if (nextNl && nextNl !== nlToUse) {
              nlToUse = nextNl;
              continue;
            }
            break;
          }
        } catch (err) {
          console.warn(`ExFastReader: HTML failover fetch failed for page ${pageNo}:`, (err && err.message) || err);
        }
      }

      if (!isPageInActiveWindow(pageNo)) {
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        return;
      }

      // 2. Fallback: try fetchApi
      if (imgKey) {
        try {
          const freshResp = await fetchApi(pageNo, imgKey, nl || null, true, true);
          if (freshResp) {
            pageRespCache.set(pageNo, freshResp);
            extractKeysFromResp(freshResp, pageNo);
            const nextNl = extractNlCode(freshResp);
            if (nextNl) pageNlCodes.set(pageNo, nextNl);
            const extracted = extractInfoFromResp(freshResp, pageNo);
            if (extracted) pageTopBarInfoCache.set(pageNo, extracted);
            if (currentPageNum === pageNo) updateTopBarInfo(pageNo);
            if (freshResp.i3) {
              const i3Doc = parseTemplate(freshResp.i3);
              const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
              const freshSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
              if (freshSrc) {
                reloadingPages.delete(pageNo);
                pageImgSrcCache.set(pageNo, freshSrc);
                renderImageInItem(pageNo, freshSrc, retryCount);
                return;
              }
            }
          }
        } catch (err) {
          console.warn(`ExFastReader: API reload failed for page ${pageNo}:`, (err && err.message) || err);
        }
      }

      // If key failed, invalidate it so subsequent attempts re-query gallery
      pageImgKeyCache.delete(pageNo);
      const gIdx = Math.max(0, Math.floor((pageNo - 1) / thumbsPerPage));
      fetchedGalleryPages.delete(gIdx);

      recordPageFailure(pageNo, 'Image reload failed');
      if (retryCount + 1 < MAX_RETRIES) {
        const backoff = Math.min(8000, 1500 * Math.pow(1.8, retryCount));
        if (activeRetryTimers.has(pageNo)) {
          clearTimeout(activeRetryTimers.get(pageNo));
        }
        const timer = setTimeout(() => {
          activeRetryTimers.delete(pageNo);
          if (stripActive && isPageInActiveWindow(pageNo)) {
            reloadPageImage(pageNo, retryCount + 1);
          } else {
            reloadingPages.delete(pageNo);
            loadingPages.delete(pageNo);
          }
        }, backoff);
        activeRetryTimers.set(pageNo, timer);
      } else {
        reloadingPages.delete(pageNo);
        loadingPages.delete(pageNo);
        if (item && !item.querySelector('img.eh-main-img')) {
          showRetryUI(item, pageNo);
        }
      }
    }

    function isPageEligibleToLoad(pageNo) {
      if (!pageNo || isNaN(pageNo) || pageNo < 1) return false;
      const tot = getTotalPages();
      if (tot > 0 && pageNo > tot) return false;
      if (loadedImages.has(pageNo) || loadingPages.has(pageNo) || reloadingPages.has(pageNo)) return false;
      const failInfo = failedPages.get(pageNo);
      if (failInfo) {
        if (failInfo.count >= MAX_RETRIES) return false;
        const isVisible = getVisiblePageNums().includes(pageNo);
        const cooldown = Math.min(15000, (isVisible ? 1500 : 3000) * Math.pow(2, failInfo.count - 1));
        if (Date.now() - failInfo.lastAttempt < cooldown) {
          return false;
        }
      }
      return true;
    }

    function recordPageFailure(pageNo, reason) {
      const current = failedPages.get(pageNo) || { count: 0, lastAttempt: 0 };
      current.count += 1;
      current.lastAttempt = Date.now();
      failedPages.set(pageNo, current);
      console.warn(`ExFastReader: Page ${pageNo} failed (attempt ${current.count}): ${reason}`);

      if (current.count >= MAX_RETRIES) {
        const item = pageItemMap.get(pageNo);
        if (item && !item.querySelector('img.eh-main-img')) {
          showRetryUI(item, pageNo);
        }
      }
    }

    function getVisiblePagesAndCurrent() {
      const vpW = window.innerWidth;
      const vpH = window.innerHeight;
      const vpCenterX = vpW / 2;
      const vpCenterY = vpH / 2;
      const isHoriz = currentLayout === 'horizontal';

      if (pageItemMap.size === 0) {
        return { visible: [currentPageNum || 1], current: currentPageNum || 1 };
      }

      // 1. Instant O(1) probe at viewport center
      let seedPage = null;
      const centerEl = document.elementFromPoint(vpCenterX, vpCenterY)?.closest('.eh-strip-item');
      if (centerEl && centerEl.dataset.page) {
        const p = parseInt(centerEl.dataset.page, 10);
        if (p && pageItemMap.has(p)) seedPage = p;
      }

      // 2. Fast check around currentPageNum or immediate neighbors
      if (!seedPage && currentPageNum && pageItemMap.has(currentPageNum)) {
        for (const testP of [currentPageNum, currentPageNum - 1, currentPageNum + 1]) {
          const item = pageItemMap.get(testP);
          if (item && item.isConnected) {
            const r = item.getBoundingClientRect();
            const inView = isHoriz ? (r.right > 0 && r.left < vpW) : (r.bottom > 0 && r.top < vpH);
            if (inView) {
              seedPage = testP;
              break;
            }
          }
        }
      }

      // 3. Fallback: monotonic binary search over pageItemMap keys (log2(N) <= 9 probes for 500 items)
      if (!seedPage) {
        const pages = Array.from(pageItemMap.keys()).sort((a, b) => a - b);
        let low = 0, high = pages.length - 1;
        while (low <= high) {
          const mid = Math.floor((low + high) / 2);
          const p = pages[mid];
          const item = pageItemMap.get(p);
          if (!item || !item.isConnected) {
            low = mid + 1;
            continue;
          }
          const r = item.getBoundingClientRect();
          if (isHoriz) {
            // In RTL flex-row: earlier pages (page 1) are on the right (higher X).
            if (r.left >= vpW) {
              low = mid + 1;
            } else if (r.right <= 0) {
              high = mid - 1;
            } else {
              seedPage = p;
              break;
            }
          } else {
            if (r.bottom <= 0) {
              low = mid + 1;
            } else if (r.top >= vpH) {
              high = mid - 1;
            } else {
              seedPage = p;
              break;
            }
          }
        }
      }

      if (!seedPage) {
        seedPage = currentPageNum || 1;
      }

      const visible = [];
      let bestPage = seedPage;
      let minCenterDist = Infinity;
      let centerCoveredPage = null;

      function testPage(p) {
        const item = pageItemMap.get(p);
        if (!item || !item.isConnected) return false;
        const r = item.getBoundingClientRect();
        if (isHoriz) {
          if (r.right > 0 && r.left < vpW) {
            visible.push(p);
            if (r.left <= vpCenterX && r.right >= vpCenterX) centerCoveredPage = p;
            const dist = Math.abs((r.left + r.right) / 2 - vpCenterX);
            if (dist < minCenterDist) {
              minCenterDist = dist;
              bestPage = p;
            }
            return true;
          }
        } else {
          if (r.bottom > 0 && r.top < vpH) {
            visible.push(p);
            if (r.top <= vpCenterY && r.bottom >= vpCenterY) centerCoveredPage = p;
            const dist = Math.abs((r.top + r.bottom) / 2 - vpCenterY);
            if (dist < minCenterDist) {
              minCenterDist = dist;
              bestPage = p;
            }
            return true;
          }
        }
        return false;
      }

      testPage(seedPage);

      // Expand outward backwards
      for (let p = seedPage - 1; p >= 1; p--) {
        if (!testPage(p)) break;
      }
      // Expand outward forwards
      const total = getTotalPages() || (seedPage + 100);
      for (let p = seedPage + 1; p <= total; p++) {
        if (!testPage(p)) break;
      }

      if (visible.length === 0) {
        visible.push(bestPage);
      }

      visible.sort((a, b) => a - b);
      const current = centerCoveredPage !== null ? centerCoveredPage : bestPage;
      return { visible, current };
    }

    function getVisiblePageNums() {
      return getVisiblePagesAndCurrent().visible;
    }

    function getCurrentlyVisiblePageNum() {
      return getVisiblePagesAndCurrent().current;
    }

    function schedulePrefetchStripMetadata(curPage) {
      if (!stripActive) return;
      if (prefetchMetaTimer) clearTimeout(prefetchMetaTimer);
      prefetchMetaTimer = setTimeout(() => {
        prefetchStripMetadata(curPage);
      }, 150);
    }

    function prefetchStripMetadata(cur) {
      if (!stripActive) return;
      if (apiCircuitBreaker.isBlocked(false)) return;
      const total = getTotalPages();
      // The showpage JSON is tiny and carries the exact served size (x/y). Fetch it a few pages
      // ahead (in the scroll direction) so placeholders are exact before they come into view,
      // instead of guessing from the low-resolution thumbnail ratio.
      const ahead = scrollDirection === 'backward' ? [-1, -2, -3, -4, 1] : [1, 2, 3, 4, 5, -1];
      const targets = ahead.map(d => cur + d)
        .filter(p => p >= 1 && (!total || p <= total) && !failedPages.has(p));

      for (const p of targets) {
        if (apiCircuitBreaker.isBlocked(false)) break;
        if (pageRespCache.has(p)) continue;
        if (pageDisplayDims.has(p)) continue;

        const imgKey = pageImgKeyCache.get(p);
        if (imgKey) {
          fetchApi(p, imgKey).then((resp) => {
            if (resp && !resp.error) {
              pageRespCache.set(p, resp);
              extractKeysFromResp(resp, p);
              const extracted = extractInfoFromResp(resp, p);
              if (extracted) pageTopBarInfoCache.set(p, extracted);
            }
          });
        }
      }
    }

    function checkAndLoadNearbyPages(targetPage, precomputedVisible = null) {
      if (!isReaderPage || !stripActive || isPumpingQueue) return;
      isPumpingQueue = true;

      try {
        const cur = targetPage || currentPageNum;
        const total = getTotalPages();
        const visiblePages = precomputedVisible || getVisiblePageNums();

        // Flicking through pages: start nothing new, drop everything that isn't on screen, and
        // resume a moment after the scrolling stops (nearest page first).
        if (isFastScrolling()) {
          for (const p of Array.from(loadingPages.keys())) {
            if (!visiblePages.includes(p)) abortPageLoad(p);
          }
          scheduleSettleCheck();
          return;
        }

        // Abort in-flight loads for pages that are no longer near where we are
        for (const p of Array.from(loadingPages.keys())) {
          if (!isPageNeeded(p, visiblePages, cur)) {
            abortPageLoad(p);
          }
        }

        schedulePrefetchNearbyThumbnails(cur);
        schedulePrefetchStripMetadata(cur);

        const candidates = [];
        const sortedVisible = scrollDirection === 'backward' ? [...visiblePages].reverse() : visiblePages;
        for (const vp of sortedVisible) {
          if (!candidates.includes(vp)) candidates.push(vp);
        }
        if (!candidates.includes(cur)) candidates.push(cur);

        if (scrollDirection === 'backward') {
          const backDepth = PREFETCH_DEPTH + 1;
          for (let b = 1; b <= backDepth; b++) {
            const p = cur - b;
            if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
              candidates.push(p);
            }
          }
          for (let f = 1; f <= 2; f++) {
            const p = cur + f;
            if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
              candidates.push(p);
            }
          }
        } else {
          const forwardDepth = currentLayout === 'horizontal' ? (PREFETCH_DEPTH + 1) : PREFETCH_DEPTH;
          for (let f = 1; f <= forwardDepth; f++) {
            const p = cur + f;
            if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
              candidates.push(p);
            }
          }
          for (let b = 1; b <= 2; b++) {
            const p = cur - b;
            if (p >= 1 && (!total || p <= total) && !candidates.includes(p)) {
              candidates.push(p);
            }
          }
        }

        for (const pageNo of candidates) {
          const isVisible = visiblePages.includes(pageNo) || pageNo === cur;
          const limit = isVisible ? (MAX_CONCURRENT_LOADS + 2) : MAX_CONCURRENT_LOADS;
          if (loadingPages.size >= limit) {
            if (!isVisible) break;
            continue;
          }
          if (isPageEligibleToLoad(pageNo)) {
            loadPage(pageNo, 0);
          }
        }
      } finally {
        isPumpingQueue = false;
      }
    }

    async function loadPage(pageNo, retryCount = 0) {
      if (!isReaderPage || !stripActive || !isPageEligibleToLoad(pageNo)) return;
      if (!isPageInActiveWindow(pageNo)) return;

      loadingPages.set(pageNo, Date.now());

      let renderStarted = false;
      const item = pageItemMap.get(pageNo);
      if (item) item.classList.add('eh-loading');
      try {
        let imgKey = pageImgKeyCache.get(pageNo);
        if (!imgKey) {
          imgKey = await ensureKeysForPage(pageNo, true);
        }

        if (!stripActive || !isPageInActiveWindow(pageNo)) {
          return;
        }

        if (!imgKey) {
          if (retryCount < MAX_RETRIES) {
            loadingPages.delete(pageNo);
            const backoff = Math.min(6000, 1000 * Math.pow(1.8, retryCount));
            setTimeout(() => {
              if (stripActive && isPageInActiveWindow(pageNo)) {
                loadPage(pageNo, retryCount + 1);
              }
            }, backoff);
            return;
          }
          recordPageFailure(pageNo, 'Missing image key');
          const it = pageItemMap.get(pageNo);
          if (it && !it.querySelector('img.eh-main-img')) showRetryUI(it, pageNo);
          return;
        }

        let imgSrc = pageImgSrcCache.get(pageNo);
        if (!imgSrc) {
            let resp = pageRespCache.get(pageNo);
            if (!resp) {
                const isVis = getVisiblePageNums().includes(pageNo);
                resp = await fetchApi(pageNo, imgKey, null, false, isVis);
                if (resp && !resp.error) {
                    pageRespCache.set(pageNo, resp);
                }
            }

            if (!stripActive || !isPageInActiveWindow(pageNo)) {
              return;
            }

            if (!resp || resp.error || !resp.i3) {
              // Direct HTML reader fallback: pulls image source, nl code, and showkey directly from reader page!
              const { gid } = getPageParams();
              const nl = pageNlCodes.get(pageNo) || null;
              let htmlLoaded = false;
              if (imgKey && gid) {
                try {
                  const readerUrl = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nl ? `?nl=${encodeURIComponent(nl)}` : '');
                  const htmlRes = await fetch(readerUrl, { credentials: 'include', priority: 'high' });
                  if (htmlRes.ok) {
                    const htmlText = await htmlRes.text();
                    const doc = new DOMParser().parseFromString(htmlText, 'text/html');
                    const newImg = doc.getElementById('img');
                    const src = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
                    const nextNl = extractNlCode(doc);
                    if (nextNl) pageNlCodes.set(pageNo, nextNl);
                    for (const scr of doc.scripts) {
                      const mS = (scr.textContent || '').match(/var\s+showkey\s*=\s*['"]([^'"]+)['"]/);
                      if (mS) { cachedShowkey = mS[1]; break; }
                    }
                    if (src && !src.includes('blank.gif') && !src.includes('509.gif')) {
                      imgSrc = src;
                      pageImgSrcCache.set(pageNo, imgSrc);
                      htmlLoaded = true;
                    }
                  }
                } catch (htmlErr) {
                  console.warn(`[ExFastReader] HTML reader fetch fallback failed for page ${pageNo}:`, htmlErr);
                }
              }

              if (!htmlLoaded) {
                const reason = !resp ? 'API request timed out or network error' : (resp.error ? `API error: ${resp.error}` : 'API response missing i3 container');
                recordPageFailure(pageNo, reason);
                if (retryCount < MAX_RETRIES) {
                  const backoff = Math.min(8000, 1200 * Math.pow(1.8, retryCount));
                  setTimeout(() => {
                    if (stripActive && isPageInActiveWindow(pageNo)) reloadPageImage(pageNo, retryCount + 1);
                  }, backoff);
                } else {
                  const it = pageItemMap.get(pageNo);
                  if (it && !it.querySelector('img.eh-main-img')) showRetryUI(it, pageNo);
                }
                return;
              }
            }

            if (!stripActive || !isPageInActiveWindow(pageNo)) {
              return;
            }

            if (resp && resp.i3) {
              const newNl = extractNlCode(resp);
              if (newNl) {
                pageNlCodes.set(pageNo, newNl);
              }

              const i3Doc = parseTemplate(resp.i3);
              const imgEl = i3Doc.querySelector('img#img');
              imgSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
              if (imgSrc) {
                pageImgSrcCache.set(pageNo, imgSrc);
              }
            }
        }

        if (!stripActive || !isPageInActiveWindow(pageNo)) {
          return;
        }

        if (imgSrc) {
          renderStarted = true;
          renderImageInItem(pageNo, imgSrc, retryCount);
        } else {
          recordPageFailure(pageNo, 'Image element missing src');
        }
      } catch (err) {
        if (!stripActive || !isPageInActiveWindow(pageNo)) {
          return;
        }
        recordPageFailure(pageNo, (err && err.message) || 'Exception during load');
      } finally {
        if (!renderStarted) {
          loadingPages.delete(pageNo);
          const it = pageItemMap.get(pageNo);
          if (it) it.classList.remove('eh-loading');
        }
        setTimeout(() => {
          if (stripActive) checkAndLoadNearbyPages();
        }, 50);
      }
    }

    function scheduleScrollUpdate() {
      lastScrollTime = Date.now();
      if (!isReaderPage || !stripActive || scrollRafId || isTransitioningLayout) return;
      scrollRafId = requestAnimationFrame(() => {
        scrollRafId = null;
        onScrollThrottled();
      });
    }

    function onScrollThrottled() {
      if (!isReaderPage || !stripActive || isTransitioningLayout) return;
      lastScrollTime = Date.now();

      if (!animScrollRaf) targetScrollLeft = null;
      if (!animScrollTopRaf) targetScrollTop = null;

      const container = document.getElementById('eh-strip-container');
      if (currentLayout === 'horizontal' && container) {
        const curLeft = container.scrollLeft;
        if (curLeft > lastScrollLeft + 2) {
          scrollDirection = 'backward';
        } else if (curLeft < lastScrollLeft - 2) {
          scrollDirection = 'forward';
        }
        lastScrollLeft = curLeft;
      } else {
        const curY = window.scrollY;
        if (curY < lastScrollY - 2) {
          scrollDirection = 'backward';
        } else if (curY > lastScrollY + 2) {
          scrollDirection = 'forward';
        }
        lastScrollY = curY;
      }

      const { visible: visiblePages, current: activePage } = getVisiblePagesAndCurrent();
      if (activePage < currentPageNum) {
        scrollDirection = 'backward';
      } else if (activePage > currentPageNum) {
        scrollDirection = 'forward';
      }

      if (activePage !== currentPageNum) notePageChange();
      currentPageNum = activePage;
      syncPageIndicator(activePage);
      updateTopBarInfo(activePage);

      if (activePage !== lastUpdatePage) {
        lastUpdatePage = activePage;
        pruneCaches(activePage);
      }

      checkAndLoadNearbyPages(activePage, visiblePages);
      schedulePrefetchNearbyThumbnails(activePage);

      if (activePage !== lastHistoryPage) {
        lastHistoryPage = activePage;
        if (historyDebounceTimer) clearTimeout(historyDebounceTimer);
        historyDebounceTimer = setTimeout(() => {
          updatePageUrl(activePage, false);
        }, 150);
      }
    }

    function syncPageIndicator(activePage, force = false) {
      if (!force && activePage === lastSyncedIndicatorPage) return;
      lastSyncedIndicatorPage = activePage;

      const total = getTotalPages();
      const pageDisplay = document.getElementById('eh-strip-page-display');
      if (pageDisplay) {
        pageDisplay.textContent = total ? `Page ${activePage} / ${total}` : `Page ${activePage}`;
      }

      const curSpan = document.querySelector('#i2 .sn span:first-child');
      if (curSpan && curSpan.textContent !== String(activePage)) {
        curSpan.textContent = activePage;
      }

      const galUrl = getGalleryUrl(activePage);
      const galleryBtn = document.getElementById('eh-strip-gallery-btn');
      if (galleryBtn) {
        galleryBtn.href = galUrl;
      }

      const bottomLinks = document.querySelectorAll('#eh-bottom-gallery-bar a, #eh-strip-end-card a');
      for (const a of bottomLinks) {
        a.href = galUrl;
      }
    }

    function enableStripMode() {
      stripActive = true;
      safeStorageSet(localStorage, 'eh_strip_mode_enabled', '1');

      setStripClasses(true, currentLayout);

      updateToggleButtonUI();

      const container = getOrCreateStripContainer();
      container.className = `eh-strip-container layout-${currentLayout}`;

      const initialMeta = getCurrentPageMeta();
      if (!currentPageNum || currentPageNum < 1) {
        currentPageNum = initialMeta.page || 1;
      }
      const targetPage = currentPageNum;
      if (initialMeta.imgKey && !pageImgKeyCache.has(targetPage)) {
        pageImgKeyCache.set(targetPage, initialMeta.imgKey);
      }

      detectInitialPageDimensions();
      applyGlobalAspectRatio(defaultAspectRatio);
      extractKeysFromDoc(document);
      const initNl = extractInitialNlCode(document);
      if (initNl) {
        pageNlCodes.set(targetPage, initNl);
      }
      extractAndHideBottomLinks();

      ensureStartSpacer();

      const totalPages = getTotalPages();
      if (totalPages > 0) {
        const frag = document.createDocumentFragment();
        for (let p = 1; p <= totalPages; p++) {
          if (!pageItemMap.has(p)) {
            const item = document.createElement('div');
            item.className = 'eh-strip-item eh-placeholder';
            item.dataset.page = String(p);
            applyItemDimensions(item, p);
            renderPlaceholderContent(item, p);
            pageItemMap.set(p, item);

            frag.appendChild(item);
          }
        }
        const endCard = document.getElementById('eh-strip-end-card');
        if (endCard) {
          container.insertBefore(frag, endCard);
        } else {
          container.appendChild(frag);
        }
      } else {
        createOrGetPlaceholder(targetPage);
      }

      const currentImg = document.getElementById('img');
      if (currentImg && currentImg.src && currentImg._eh_page === targetPage && currentImg.complete && currentImg.naturalWidth > 0) {
        pageImgSrcCache.set(targetPage, currentImg.src);
        if (!loadedImages.has(targetPage)) {
          renderImageInItem(targetPage, currentImg.src, 0);
        }
      }

      ensureBottomGalleryButton();

      discoverAndFetchGallery(currentPageNum);

      isTransitioningLayout = true;
      instantScrollToPage(currentPageNum);

      const obs = getStripItemObserver();
      if (obs) {
        for (const it of pageItemMap.values()) {
          obs.observe(it);
        }
      }

      requestAnimationFrame(() => {
        instantScrollToPage(currentPageNum);
        isTransitioningLayout = false;
        scheduleScrollUpdate();
        checkAndLoadNearbyPages(currentPageNum);
      });

      lastUpdatePage = currentPageNum;
      syncPageIndicator(currentPageNum);
      updateTopBarInfo(currentPageNum);
    }

    function disableStripMode() {
      stripActive = false;
      cancelKeyboardAnimation();
      safeStorageSet(localStorage, 'eh_strip_mode_enabled', '0');

      if (historyDebounceTimer) {
        clearTimeout(historyDebounceTimer);
        historyDebounceTimer = null;
      }
      if (scrollRafId) {
        cancelAnimationFrame(scrollRafId);
        scrollRafId = null;
      }

      if (stripItemObserver) {
        stripItemObserver.disconnect();
        stripItemObserver = null;
      }

      const targetPage = getCurrentlyVisiblePageNum() || currentPageNum || 1;
      currentPageNum = targetPage;

      // Grab the already-loaded image from the strip item for the current page (before the strip is torn down)
      const currentStripItem = pageItemMap.get(targetPage);
      const currentStripImg = currentStripItem?.querySelector('img.eh-main-img');
      const stripImgReady = Boolean(currentStripImg && currentStripImg.complete && currentStripImg.naturalWidth > 0);
      const loadedStripSrc = stripImgReady ? currentStripImg.src : pageImgSrcCache.get(targetPage);
      const loadedStripDim = stripImgReady
        ? {
            width: currentStripImg.naturalWidth,
            height: currentStripImg.naturalHeight,
            aspectRatio: currentStripImg.naturalWidth / currentStripImg.naturalHeight
          }
        : null;
      if (loadedStripDim) {
        recordDisplayDims(targetPage, loadedStripDim.width, loadedStripDim.height);
      }

      for (const item of pageItemMap.values()) {
        if (typeof item._abortImg === 'function') {
          item._abortImg();
          item._abortImg = null;
        }
        item._currentImgToken = null;
      }

      // The native #img still holds whichever page the tab was opened on. If the strip is sitting on
      // a different page (e.g. a placeholder), un-hiding #i3 would flash that stale image at its stale
      // size. Blank it and show the target page's placeholder BEFORE the single-page view becomes visible.
      const nativeImg = document.getElementById('img');
      if (nativeImg && (nativeImg._eh_page !== targetPage || nativeImg._eh_obsolete)) {
        nativeImg._eh_obsolete = false;
        nativeImg._eh_page = 0; // not any real page: keeps initImageMonitor from acting on the blank image
        nativeImg.__eh_monitored = false;
        nativeImg.onload = null;
        nativeImg.onerror = null;
        nativeImg.style.opacity = '0';
        nativeImg.removeAttribute('src');
        showSinglePageThumbnailPlaceholder(targetPage);
      }

      setStripClasses(false, currentLayout);
      updateToggleButtonUI();

      const container = document.getElementById('eh-strip-container');
      if (container) {
        container.removeEventListener('scroll', scheduleScrollUpdate);
        container.remove();
      }

      const bottomBar = document.getElementById('eh-bottom-gallery-bar');
      if (bottomBar) bottomBar.remove();

      const startSpacer = document.getElementById('eh-strip-start-spacer');
      if (startSpacer) startSpacer.remove();

      pageItemMap.clear();
      loadedImages.clear();
      loadingPages.clear();
      failedPages.clear();

      // Instant transition: display the current page in single-page mode immediately
      if (loadedStripSrc) {
        displaySinglePageImage(targetPage, loadedStripSrc, loadedStripDim);
      } else {
        turnSinglePage(targetPage);
      }

      updateTopBarInfo(currentPageNum);
      initImageMonitor();
      startPrefetch();
    }

    function toggleStripMode() {
      if (stripActive) {
        disableStripMode();
      } else {
        enableStripMode();
      }
    }

    function toggleStripLayout() {
      const targetPage = currentPageNum || getCurrentlyVisiblePageNum() || 1;
      cancelKeyboardAnimation();
      isTransitioningLayout = true;

      currentLayout = currentLayout === 'horizontal' ? 'vertical' : 'horizontal';
      safeStorageSet(localStorage, 'eh_strip_layout', currentLayout);

      setStripClasses(true, currentLayout);

      const container = document.getElementById('eh-strip-container');
      if (container) {
        container.className = `eh-strip-container layout-${currentLayout}`;
        if (currentLayout === 'vertical') {
          container.scrollLeft = 0;
        }
        for (const [p, item] of pageItemMap.entries()) {
          applyItemDimensions(item, p);
        }
      }

      if (stripItemObserver) {
        stripItemObserver.disconnect();
        stripItemObserver = null;
      }
      const obs = getStripItemObserver();
      if (obs) {
        for (const item of pageItemMap.values()) {
          obs.observe(item);
        }
      }

      ensureBottomGalleryButton();
      updateToggleButtonUI();

      currentPageNum = targetPage;
      lastUpdatePage = targetPage;
      syncPageIndicator(targetPage);
      updateTopBarInfo(targetPage);

      if (container) void container.offsetHeight;
      instantScrollToPage(targetPage);

      requestAnimationFrame(() => {
        instantScrollToPage(targetPage);
        setTimeout(() => {
          instantScrollToPage(targetPage);
          currentPageNum = targetPage;
          lastUpdatePage = targetPage;
          syncPageIndicator(targetPage);
          isTransitioningLayout = false;
          scheduleScrollUpdate();
          checkAndLoadNearbyPages(targetPage);
        }, 60);
      });
    }

    function getSingleModeLowerArea() {
      const i5 = document.getElementById('i5');
      const i6 = document.getElementById('i6');
      const i7 = document.getElementById('i7');
      const loadfail = document.getElementById('loadfail');
      const loadfailParent = loadfail ? loadfail.closest('.idi, div, p') : null;

      let textMatch = null;
      const candidates = document.querySelectorAll('#i1 > div, #i1 > p, div.idi');
      for (const el of candidates) {
        const txt = (el.textContent || '').toLowerCase();
        if (txt.includes('download original') || txt.includes('reload broken image') || txt.includes('show galleries')) {
          textMatch = el;
          break;
        }
      }
      return i5 || i6 || i7 || loadfailParent || textMatch;
    }

function placeStripControls() {
      let controls = document.getElementById('eh-strip-controls');
      if (!controls) return;

      if (stripActive) {
        controls.classList.remove('single-mode-bottom');
        const i2 = document.getElementById('i2');
        if (i2 && controls.parentNode !== i2) {
          const topbarInfo = document.getElementById('eh-topbar-info');
          if (topbarInfo) {
            i2.insertBefore(controls, topbarInfo.nextSibling);
          } else {
            i2.appendChild(controls);
          }
        }
      } else {
        controls.classList.add('single-mode-bottom');

        // Locate the 30x30px icon container that links back to the gallery (#i5 / div.sb)
        const galleryIcon = document.getElementById('i5') ||
                            document.querySelector('div.sb') ||
                            document.querySelector('#i1 a[href*="/g/"] img')?.closest('div, p');

        if (galleryIcon && galleryIcon.parentNode) {
          // Insert directly underneath (after) the 30x30px gallery icon
          if (galleryIcon.nextElementSibling !== controls || controls.parentNode !== galleryIcon.parentNode) {
            galleryIcon.insertAdjacentElement('afterend', controls);
          }
        } else {
          // Fallback placement if the gallery icon container isn't found
          const lowerArea = getSingleModeLowerArea();
          if (lowerArea && lowerArea.parentNode) {
            if (controls.nextSibling !== lowerArea || controls.parentNode !== lowerArea.parentNode) {
              lowerArea.parentNode.insertBefore(controls, lowerArea);
            }
          } else {
            const i4 = document.getElementById('i4');
            if (i4 && i4.parentNode) {
              if (i4.nextElementSibling !== controls || controls.parentNode !== i4.parentNode) {
                i4.insertAdjacentElement('afterend', controls);
              }
            } else {
              const i1 = document.getElementById('i1') || document.body;
              if (controls.parentNode !== i1) {
                i1.appendChild(controls);
              }
            }
          }
        }
      }
    }

    function updateToggleButtonUI() {
      const modeBtn = document.getElementById('eh-strip-mode-toggle');
      const statusText = document.getElementById('eh-strip-status');
      const dirBtn = document.getElementById('eh-strip-dir-toggle');
      const dirText = document.getElementById('eh-strip-dir-text');
      const pageDisplay = document.getElementById('eh-strip-page-display');
      const galleryBtn = document.getElementById('eh-strip-gallery-btn');

      placeStripControls();

      if (modeBtn && statusText) {
        if (stripActive) {
          modeBtn.classList.add('active');
          statusText.textContent = 'ON';
        } else {
          modeBtn.classList.remove('active');
          statusText.textContent = 'OFF';
        }
      }

      if (dirBtn && dirText) {
        dirBtn.style.display = stripActive ? 'inline-flex' : 'none';
        dirText.textContent = currentLayout === 'horizontal' ? 'Horizontal (RTL)' : 'Vertical';
      }

      if (pageDisplay) {
        pageDisplay.style.display = stripActive ? 'inline-block' : 'none';
      }

      if (galleryBtn) {
        galleryBtn.style.display = stripActive ? 'inline-flex' : 'none';
        galleryBtn.href = getGalleryUrl();
      }

      const frontBtn = document.getElementById('eh-strip-front-btn');
      const onionBtn = document.getElementById('eh-strip-onion-btn');

      if (frontBtn) {
        frontBtn.style.display = stripActive ? 'inline-flex' : 'none';
        frontBtn.href = cachedFrontUrl || (location.origin + '/');
      }

      if (onionBtn) {
        onionBtn.style.display = stripActive && cachedOnionUrl ? 'inline-flex' : 'none';
        if (cachedOnionUrl) onionBtn.href = cachedOnionUrl;
      }
    }

    function injectReaderToggle() {
      let controls = document.getElementById('eh-strip-controls');
      if (!controls) {
        controls = document.createElement('div');
        controls.id = 'eh-strip-controls';
        controls.className = 'eh-strip-controls';

        const modeBtn = document.createElement('button');
        modeBtn.type = 'button';
        modeBtn.id = 'eh-strip-mode-toggle';
        modeBtn.className = 'eh-strip-btn' + (stripActive ? ' active' : '');
        modeBtn.title = 'Toggle Seamless Strip Reading Mode';
        modeBtn.innerHTML = `Strip Mode: <span id="eh-strip-status">${stripActive ? 'ON' : 'OFF'}</span>`;
        modeBtn.addEventListener('click', toggleStripMode);

        const dirBtn = document.createElement('button');
        dirBtn.type = 'button';
        dirBtn.id = 'eh-strip-dir-toggle';
        dirBtn.className = 'eh-strip-btn';
        dirBtn.title = 'Switch Orientation (Horizontal RTL / Vertical)';
        dirBtn.style.display = stripActive ? 'inline-flex' : 'none';
        dirBtn.innerHTML = `<span id="eh-strip-dir-text">${currentLayout === 'horizontal' ? 'Horizontal (RTL)' : 'Vertical'}</span>`;
        dirBtn.addEventListener('click', toggleStripLayout);

        const galleryBtn = document.createElement('a');
        galleryBtn.id = 'eh-strip-gallery-btn';
        galleryBtn.className = 'eh-strip-btn';
        galleryBtn.title = 'Return to Gallery';
        galleryBtn.href = getGalleryUrl();
        galleryBtn.textContent = 'Gallery';
        galleryBtn.style.display = stripActive ? 'inline-flex' : 'none';

        const frontBtn = document.createElement('a');
        frontBtn.id = 'eh-strip-front-btn';
        frontBtn.className = 'eh-strip-btn';
        frontBtn.title = 'Front Page';
        frontBtn.href = cachedFrontUrl || (location.origin + '/');
        frontBtn.textContent = 'Front Page';
        frontBtn.style.display = stripActive ? 'inline-flex' : 'none';

        const onionBtn = document.createElement('a');
        onionBtn.id = 'eh-strip-onion-btn';
        onionBtn.className = 'eh-strip-btn';
        onionBtn.title = 'Tor / Onion Mirror';
        onionBtn.href = cachedOnionUrl || '#';
        onionBtn.textContent = 'Tor / Onion';
        onionBtn.target = '_blank';
        onionBtn.rel = 'noopener noreferrer';
        onionBtn.style.display = stripActive && cachedOnionUrl ? 'inline-flex' : 'none';

        const pageDisplay = document.createElement('span');
        pageDisplay.id = 'eh-strip-page-display';
        pageDisplay.className = 'eh-strip-page-counter';
        pageDisplay.style.display = stripActive ? 'inline-block' : 'none';

        controls.appendChild(modeBtn);
        controls.appendChild(dirBtn);
        controls.appendChild(galleryBtn);
        controls.appendChild(frontBtn);
        controls.appendChild(onionBtn);
        controls.appendChild(pageDisplay);

        const i2 = document.getElementById('i2');
        if (i2) {
          i2.appendChild(controls);
        } else {
          document.body.appendChild(controls);
        }
      }

      placeStripControls();
      updateToggleButtonUI();
      syncPageIndicator(getCurrentlyVisiblePageNum());
    }

    let lastScrollTime = 0;
    window.addEventListener(
      'mousemove',
      (e) => {
        if (!isReaderPage || !stripActive) return;
        if (Date.now() - lastScrollTime < 350) {
          document.body.classList.remove('eh-topbar-visible');
          return;
        }

        const i2 = document.getElementById('i2');
        const isVisible = document.body.classList.contains('eh-topbar-visible');
        const bottom = (i2 && isVisible) ? Math.max(50, i2.getBoundingClientRect().bottom) : 30;

        if (e.clientY <= bottom) {
          document.body?.classList.add('eh-topbar-visible');
        } else {
          document.body?.classList.remove('eh-topbar-visible');
        }
      },
      { passive: true }
    );

    document.addEventListener('mouseleave', () => {
      if (!isReaderPage || !stripActive) return;
      document.body.classList.remove('eh-topbar-visible');
      if (document.activeElement && document.activeElement.blur) {
        document.activeElement.blur();
      }
    });

    window.addEventListener('blur', () => {
      if (!isReaderPage || !stripActive) return;
      document.body.classList.remove('eh-topbar-visible');
      if (document.activeElement && document.activeElement.blur) {
        document.activeElement.blur();
      }
    });

    document.addEventListener('click', (e) => {
      if (!isReaderPage || !stripActive) return;
      const i2 = document.getElementById('i2');
      if (i2 && i2.contains(e.target)) {
        const btn = e.target.closest('button, a');
        if (btn) {
          setTimeout(() => { if (btn.blur) btn.blur(); }, 50);
        }
      } else if (i2 && e.clientY > 50) {
        document.body.classList.remove('eh-topbar-visible');
      }
    }, { passive: true });

    window.addEventListener('scroll', (e) => {
      if (!isReaderPage || !stripActive) return;
      lastScrollTime = Date.now();
      document.body.classList.remove('eh-topbar-visible');
      scheduleScrollUpdate();
    }, { passive: true });

    window.addEventListener(
      'wheel',
      (e) => {
        disarmSingleAlign();
        cancelKeyboardAnimation();
        if (!isReaderPage || !stripActive || isTransitioningLayout) return;
        const container = document.getElementById('eh-strip-container');
        if (!container) return;
        if (e.target && e.target.closest('#i2')) return;

        if (currentLayout === 'horizontal') {
          if (Math.abs(e.deltaY) >= Math.abs(e.deltaX)) {
            e.preventDefault();
            const rawDelta = e.deltaMode === 1 ? e.deltaY * 33 : (e.deltaMode === 2 ? e.deltaY * 100 : e.deltaY);
            const clampedDelta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 100);
            container.scrollLeft -= clampedDelta;
            scheduleScrollUpdate();
          }
        }
      },
      { passive: false }
    );

    let resizeTimer = null;
    function handleViewportResize() {
      if (!isReaderPage || !stripActive) return;
      isTransitioningLayout = true;
      cancelKeyboardAnimation();
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        instantScrollToPage(currentPageNum);
        isTransitioningLayout = false;
        scheduleScrollUpdate();
      }, 70);
    }

    window.addEventListener('resize', handleViewportResize, { passive: true });
    document.addEventListener('fullscreenchange', handleViewportResize, { passive: true });
    document.addEventListener('webkitfullscreenchange', handleViewportResize, { passive: true });

    function instantScrollToPage(targetPage) {
      if (!isReaderPage || !stripActive) return;
      const container = document.getElementById('eh-strip-container');
      if (!container) return;
      let item = pageItemMap.get(targetPage);
      if (!item) item = createOrGetPlaceholder(targetPage);
      if (!item) return;

      cancelKeyboardAnimation();

      if (currentLayout === 'horizontal') {
        window.scrollTo(0, 0);
        const itemRect = item.getBoundingClientRect();
        const contRect = container.getBoundingClientRect();
        const deltaX = ((itemRect.left + itemRect.right) / 2) - ((contRect.left + contRect.right) / 2);
        const maxScroll = 0;
        const minScroll = -(container.scrollWidth - container.clientWidth);

        let targetLeft = container.scrollLeft + deltaX;
        if (targetLeft > maxScroll) targetLeft = maxScroll;
        if (targetLeft < minScroll) targetLeft = minScroll;

        container.scrollLeft = targetLeft;
        lastScrollLeft = targetLeft;
      } else {
        if (container) container.scrollLeft = 0;
        const itemRect = item.getBoundingClientRect();
        const deltaY = (itemRect.height <= window.innerHeight)
          ? (((itemRect.top + itemRect.bottom) / 2) - (window.innerHeight / 2))
          : itemRect.top;
        const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);

        let targetTop = window.scrollY + deltaY;
        if (targetTop < 0) targetTop = 0;
        if (targetTop > maxScroll) targetTop = maxScroll;

        window.scrollTo(0, targetTop);
        lastScrollY = targetTop;
      }
    }

    function scrollToPage(targetPage) {
      if (!isReaderPage || !stripActive) return;
      const total = getTotalPages();
      if (total > 0) {
        if (targetPage < 1) targetPage = 1;
        if (targetPage > total) targetPage = total;
      } else if (targetPage < 1) {
        targetPage = 1;
      }

      currentPageNum = targetPage;
      syncPageIndicator(targetPage);
      updateTopBarInfo(targetPage);
      checkAndLoadNearbyPages(targetPage);

      let item = pageItemMap.get(targetPage);
      if (!item) item = createOrGetPlaceholder(targetPage);
      if (!item) return;

      const container = document.getElementById('eh-strip-container');
      if (!container) return;

      cancelKeyboardAnimation();

      if (currentLayout === 'horizontal') {
        const itemRect = item.getBoundingClientRect();
        const contRect = container.getBoundingClientRect();
        const deltaX = ((itemRect.left + itemRect.right) / 2) - ((contRect.left + contRect.right) / 2);
        const maxScroll = 0;
        const minScroll = -(container.scrollWidth - container.clientWidth);

        let targetLeft = container.scrollLeft + deltaX;
        if (targetLeft > maxScroll) targetLeft = maxScroll;
        if (targetLeft < minScroll) targetLeft = minScroll;

        targetScrollLeft = targetLeft;
        animateHorizontalScroll(container);
      } else {
        const itemRect = item.getBoundingClientRect();
        const deltaY = (itemRect.height <= window.innerHeight)
          ? (((itemRect.top + itemRect.bottom) / 2) - (window.innerHeight / 2))
          : itemRect.top;
        const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);

        let targetTop = window.scrollY + deltaY;
        if (targetTop < 0) targetTop = 0;
        if (targetTop > maxScroll) targetTop = maxScroll;

        targetScrollTop = targetTop;
        animateVerticalScroll();
      }
    }

    window.addEventListener(
      'keydown',
      (e) => {
        const tag = document.activeElement?.tagName?.toLowerCase();
        if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) {
          return;
        }

        if (e.ctrlKey || e.altKey || e.metaKey) {
          return;
        }

        if (isReaderPage) {
          if (stripActive) {
            const container = document.getElementById('eh-strip-container');
            if (!container) return;

            const isHoriz = currentLayout === 'horizontal';
            const step = 140;
            const pageStep = Math.round((isHoriz ? window.innerWidth : window.innerHeight) * 0.85);

            if (e.key === '[' || e.key === '{') {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              const now = Date.now();
              if (now - lastStripTurnTime < 180) return;
              lastStripTurnTime = now;
              scrollDirection = 'backward';
              scrollToPage(getCurrentlyVisiblePageNum() - 1);
              return;
            }
            if (e.key === ']' || e.key === '}') {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              const now = Date.now();
              if (now - lastStripTurnTime < 180) return;
              lastStripTurnTime = now;
              scrollDirection = 'forward';
              scrollToPage(getCurrentlyVisiblePageNum() + 1);
              return;
            }

            if (e.key === 'Home') {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              cancelKeyboardAnimation();
              if (isHoriz) {
                targetScrollLeft = 0;
                animateHorizontalScroll(container);
              } else {
                targetScrollTop = 0;
                animateVerticalScroll();
              }
              return;
            }
            if (e.key === 'End') {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              cancelKeyboardAnimation();
              if (isHoriz) {
                targetScrollLeft = -(container.scrollWidth - container.clientWidth);
                animateHorizontalScroll(container);
              } else {
                targetScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
                animateVerticalScroll();
              }
              return;
            }

            if (e.key === 'r' || e.key === 'R') {
              e.preventDefault();
              const visPage = getCurrentlyVisiblePageNum();
              reloadPageImage(visPage, 0);
              return;
            }

            let delta = 0;

            if (
              e.key === 'ArrowDown' || e.key === 's' || e.key === 'S' ||
              e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A'
            ) {
              delta = step;
            } else if (
              e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W' ||
              e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D'
            ) {
              delta = -step;
            } else if (e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey)) {
              delta = pageStep;
            } else if (e.key === 'PageUp' || (e.key === ' ' && e.shiftKey)) {
              delta = -pageStep;
            }

            if (delta !== 0) {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              scrollByEvenIncrement(delta);
            }
            return;
          }

          if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();

            const isRepeat = e.repeat;
            const minInterval = isRepeat ? 70 : 40;
            const now = Date.now();
            if (now - lastSingleTurnTime < minInterval) return;
            lastSingleTurnTime = now;

            const targetP = currentPageNum + 1;
            turnSinglePage(targetP, isRepeat);
          } else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();

            const isRepeat = e.repeat;
            const minInterval = isRepeat ? 70 : 40;
            const now = Date.now();
            if (now - lastSingleTurnTime < minInterval) return;
            lastSingleTurnTime = now;

            const targetP = currentPageNum - 1;
            turnSinglePage(targetP, isRepeat);
          } else if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            scrollByEvenIncrement(140);
          } else if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            scrollByEvenIncrement(-140);
          } else if (e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey)) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            scrollByEvenIncrement(Math.round(window.innerHeight * 0.85));
          } else if (e.key === 'PageUp' || (e.key === ' ' && e.shiftKey)) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            scrollByEvenIncrement(-Math.round(window.innerHeight * 0.85));
          } else if (e.key === 'Home') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            targetScrollTop = 0;
            animateVerticalScroll();
          } else if (e.key === 'End') {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            targetScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
            animateVerticalScroll();
          } else if (e.key === 'r' || e.key === 'R') {
            e.preventDefault();
            executeBrokenReload();
          }
        } else if (isGalleryPage) {
          if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') {
            const nextThumb = document.querySelector('table.ptb td.ptds + td a, table.ptt td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
            if (nextThumb && nextThumb.href && nextThumb.textContent.trim() !== '<') {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              if (typeof window.__eh_navigateGalleryPage === 'function') {
                window.__eh_navigateGalleryPage(nextThumb.href, true);
              } else {
                nextThumb.click();
              }
            }
          } else if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') {
            const curThumb = document.querySelector('table.ptb td.ptds, table.ptt td.ptds');
            const prevThumb = curThumb?.previousElementSibling?.querySelector('a');
            if (prevThumb && prevThumb.href) {
              e.preventDefault();
              e.stopPropagation();
              e.stopImmediatePropagation();
              if (typeof window.__eh_navigateGalleryPage === 'function') {
                window.__eh_navigateGalleryPage(prevThumb.href, true);
              } else {
                prevThumb.click();
              }
            }
          }
        }
      },
      true
    );

    window.addEventListener('popstate', (e) => {
      if (isGalleryPage) {
        if (typeof window.__eh_navigateGalleryPage === 'function') {
          window.__eh_navigateGalleryPage(location.href, false);
        }
        return;
      }
      const targetMeta = getCurrentPageMeta();
      const page = (e.state && e.state.page) || targetMeta.page;
      if (page && page !== currentPageNum) {
        if (stripActive) {
          scrollToPage(page);
        } else {
          // Back/forward already moved the URL: never push a new history entry for this page
          popSuppressPage = page;
          turnSinglePage(page);
        }
      }
    });

    let pendingSinglePage = null;
    let singlePageTurnRequestId = 0;
    let popSuppressPage = null;
    const singleReloadAttempts = new Map();

    function updatePageUrl(pageNo, isPush = false) {
      if (!pageNo || pageNo < 1) return;
      const gid = cachedGid || getPageParams().gid;
      let targetUrl = '';
      if (pageRespCache.has(pageNo) && pageRespCache.get(pageNo).s) {
        targetUrl = (window.base_url || '/') + pageRespCache.get(pageNo).s;
      } else {
        const key = pageImgKeyCache.get(pageNo);
        if (key && gid) {
          targetUrl = `${location.origin}/s/${key}/${gid}-${pageNo}`;
        }
      }
      if (!targetUrl) return;
      if (isPush && popSuppressPage === pageNo) isPush = false;
      const cleanPath = targetUrl.replace(/^https?:\/\/[^\/]+/, '');
      if (location.pathname === cleanPath && !location.search.includes('nl=')) return;
      try {
        if (isPush && window.history.pushState) {
          window.history.pushState({ page: pageNo }, document.title, targetUrl);
        } else if (window.history.replaceState) {
          window.history.replaceState({ page: pageNo }, document.title, targetUrl);
        }
      } catch {}
    }

    function updateNativePageCounters(pageNo) {
      const curSpans = document.querySelectorAll('#i2 .sn span:first-child, #i4 .sn span:first-child');
      curSpans.forEach(s => {
        if (s.textContent !== String(pageNo)) {
          s.textContent = String(pageNo);
        }
      });
      const tot = getTotalPages();
      const pageDisplay = document.getElementById('eh-strip-page-display');
      if (pageDisplay) {
        pageDisplay.textContent = tot ? `Page ${pageNo} / ${tot}` : `Page ${pageNo}`;
      }
      const galUrl = getGalleryUrl(pageNo);
      const galleryBtn = document.getElementById('eh-strip-gallery-btn');
      if (galleryBtn) {
        galleryBtn.href = galUrl;
      }
    }

    function updateSinglePageImageLink(pageNo) {
      const nextP = pageNo + 1;
      const tot = getTotalPages();
      const link = document.querySelector('#i3 a');
      if (!link) return;
      link.setAttribute('tabindex', '-1');
      if (!link.__eh_unfocusable) {
        link.__eh_unfocusable = true;
        link.addEventListener('mousedown', (e) => e.preventDefault());
      }
      if (tot > 0 && nextP > tot) {
        link.onclick = (e) => {
          e.preventDefault();
          if (link.blur) link.blur();
          const galUrl = getGalleryUrl();
          if (galUrl) location.href = galUrl;
          return false;
        };
        return;
      }
      link.onclick = (e) => {
        e.preventDefault();
        if (link.blur) link.blur();
        turnSinglePage(nextP);
        return false;
      };
    }

    function updateNavigationControls(pageNo) {
      updateNativePageCounters(pageNo);
      updateSinglePageImageLink(pageNo);

      const prevP = pageNo - 1;
      const nextP = pageNo + 1;
      const total = getTotalPages();

      const prevLinks = document.querySelectorAll('#prev, a#prev');
      prevLinks.forEach(a => {
        if (prevP >= 1) {
          a.onclick = (e) => { e.preventDefault(); turnSinglePage(prevP); return false; };
          if (pageRespCache.has(prevP)) {
            const r = pageRespCache.get(prevP);
            if (r.s) a.href = (window.base_url || '/') + r.s;
          }
        }
      });

      const nextLinks = document.querySelectorAll('#next, a#next');
      nextLinks.forEach(a => {
        if (!total || nextP <= total) {
          a.onclick = (e) => { e.preventDefault(); turnSinglePage(nextP); return false; };
          if (pageRespCache.has(nextP)) {
            const r = pageRespCache.get(nextP);
            if (r.s) a.href = (window.base_url || '/') + r.s;
          }
        }
      });
    }

    function displaySinglePageImage(targetPage, src, dim = null) {
      let img = document.getElementById('img');
      if (!img) return;

      img._eh_reqId = singlePageTurnRequestId;
      img._eh_obsolete = false;
      img.__eh_monitored = false;
      // Drop the previous page's load/error listeners and stall timer: their closures hold the OLD
      // page number and would otherwise size this image with the wrong page's data.
      if (typeof img.__eh_cleanup === 'function') {
        img.__eh_cleanup();
        img.__eh_cleanup = null;
      }
      clearStallTimer();
      // The site's inline onerror triggers its own full-page failover and races with ours
      if (img.hasAttribute('onerror')) img.removeAttribute('onerror');

      const targetDim = dim || getPageTargetDimensions(targetPage);
      if (targetDim.width && targetDim.height) {
        const f = syncNativeExtents(targetDim.width, targetDim.height, dim ? true : targetDim.exact !== false);
        img.style.width = f.width + 'px';
        img.style.height = f.height + 'px';
      } else {
        img.style.width = '';
        img.style.height = '';
      }
      if (targetDim.aspectRatio) {
        img.style.aspectRatio = String(targetDim.aspectRatio);
      } else {
        img.style.aspectRatio = '';
      }

      img.style.maxWidth = 'none';
      img.style.objectFit = 'contain';
      img.style.position = 'relative';
      img.style.pointerEvents = '';
      img.style.display = 'block';
      img.style.visibility = 'visible';
      img.style.zIndex = '2';

      img._eh_page = targetPage;

      const preRec = preloadedImages.get(targetPage);
      const isPreloadedReady = Boolean(preRec && preRec.isReady && preRec.img && preRec.img.naturalWidth > 0);

      img.fetchPriority = 'high';
      img.decoding = isPreloadedReady ? 'sync' : 'auto';

      if (img.src !== src) {
        img.src = src;
      }

      // Only reveal when the visible element itself holds the decoded bitmap. Revealing just because
      // a preload finished showed a blank frame whenever the element had not picked it up yet.
      if (img.complete && img.naturalWidth > 0) {
        img.style.opacity = '1';
        applySingleImageSizing(img, targetPage);
        updatePageDimensions(targetPage, img.naturalWidth, img.naturalHeight);
        hideSinglePageThumbnailPlaceholder();
        clearFailureToast();
        singleReloadAttempts.delete(targetPage);
      } else {
        img.style.opacity = '0';
        showSinglePageThumbnailPlaceholder(targetPage);
      }

      alignIfPending();
      requestAnimationFrame(alignIfPending);
      updateNavigationControls(targetPage);
      if (!applyBottomSections(targetPage)) ensureBottomForPage(targetPage);
      initImageMonitor();
      scheduleSingleReconcile();
      onPageTurn(targetPage);
    }

    // The site's own apply_json_state refreshes #i4-#i7 (image info, return-to-gallery link,
    // "show galleries with this image", "download original", reload links). We bypass it in
    // single-page mode, so replicate that part here.
    const BOTTOM_SECTION_IDS = ['i4', 'i5', 'i6', 'i7'];

    function cacheBottomFromResp(pageNo, resp) {
      if (!resp || !pageNo) return;
      const entry = {};
      let any = false;
      for (const k of BOTTOM_SECTION_IDS) {
        if (typeof resp[k] === 'string') {
          entry[k] = resp[k];
          any = true;
        }
      }
      if (!any) return;
      pageBottomCache.set(pageNo, entry);
      while (pageBottomCache.size > 60) {
        pageBottomCache.delete(pageBottomCache.keys().next().value);
      }
    }

    function applyBottomSections(pageNo) {
      if (stripActive || pageNo !== currentPageNum) return false;
      const entry = pageBottomCache.get(pageNo);
      if (!entry) return false;
      for (const k of BOTTOM_SECTION_IDS) {
        if (typeof entry[k] !== 'string') continue;
        const el = document.getElementById(k);
        if (!el) continue;
        if (el.__eh_html === entry[k]) continue;
        el.innerHTML = entry[k];
        el.__eh_html = entry[k];
      }
      // Rebind prev/next + counters on the freshly written markup, restore our info text
      updateNavigationControls(pageNo);
      updateTopBarInfo(pageNo);
      return true;
    }

    async function ensureBottomForPage(pageNo) {
      if (pageBottomCache.has(pageNo)) {
        applyBottomSections(pageNo);
        return;
      }
      const cachedResp = pageRespCache.get(pageNo);
      if (cachedResp) {
        cacheBottomFromResp(pageNo, cachedResp);
        applyBottomSections(pageNo);
        return;
      }
      const key = pageImgKeyCache.get(pageNo);
      if (!key) return;
      const r = await fetchApi(pageNo, key, null, false, true);
      if (r && !r.error) {
        pageRespCache.set(pageNo, r);
        cacheBottomFromResp(pageNo, r);
        applyBottomSections(pageNo);
      }
    }

    // Returns true when the response was consumed (displayed, or no longer relevant), false when it
    // carried no usable image so the caller should retry instead of leaving the page stuck.
    function applySinglePageResponse(resp, targetPage, requestId = null) {
      cacheBottomFromResp(targetPage, resp);
      if (requestId !== null && requestId !== singlePageTurnRequestId) return true;
      if (currentPageNum !== targetPage) return true;

      const extracted = extractInfoFromResp(resp, targetPage);
      if (extracted) pageTopBarInfoCache.set(targetPage, extracted);
      updateTopBarInfo(targetPage);

      updatePageUrl(targetPage, false);

      extractKeysFromResp(resp, targetPage);
      const newNl = extractNlCode(resp);
      if (newNl) pageNlCodes.set(targetPage, newNl);

      let imgSrc = null;
      if (resp && resp.i3) {
        const i3Doc = parseTemplate(resp.i3);
        const respImg = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
        imgSrc = respImg ? (respImg.getAttribute('src') || respImg.src) : null;
      }
      if (!imgSrc) {
        imgSrc = pageImgSrcCache.get(targetPage);
      } else {
        pageImgSrcCache.set(targetPage, imgSrc);
      }

      let w = resp.x ? parseInt(resp.x, 10) : 0;
      let h = resp.y ? parseInt(resp.y, 10) : 0;
      if ((!w || !h) && resp.i3) {
        const mW = resp.i3.match(/style=["'][^"']*width:\s*(\d+)px/i);
        const mH = resp.i3.match(/style=["'][^"']*height:\s*(\d+)px/i);
        if (mW) w = parseInt(mW[1], 10);
        if (mH) h = parseInt(mH[1], 10);
      }
      if (w > 0 && h > 0) {
        recordDisplayDims(targetPage, w, h);
        updatePageDimensions(targetPage, w, h);
      }

      if (imgSrc) {
        displaySinglePageImage(targetPage, imgSrc, (w && h ? { width: w, height: h, aspectRatio: w / h } : null));
      }

      if (pendingSinglePage === targetPage) pendingSinglePage = null;
      return Boolean(imgSrc);
    }

    let singleTurnPrefetchTimer = null;

    // Fetches and shows the current single page, retrying with backoff. Previously a failed or
    // timed-out API call just logged a warning and left the thumbnail placeholder up forever.
    async function loadSingleContent(targetPage, reqId, attempt = 0) {
      const stale = () => stripActive || reqId !== singlePageTurnRequestId || currentPageNum !== targetPage;
      let key = pageImgKeyCache.get(targetPage);
      if (!key) key = await ensureKeysForPage(targetPage, true);
      if (stale()) return;

      let resp = null;
      if (key) {
        resp = await fetchApi(targetPage, key, pageNlCodes.get(targetPage) || null, attempt > 0, true);
        if (stale()) return;
      }

      if (resp && !resp.error && resp.i3) {
        pageRespCache.set(targetPage, resp);
        extractKeysFromResp(resp, targetPage);
        if (applySinglePageResponse(resp, targetPage, reqId)) return;
      }

      if (attempt < MAX_RETRIES) {
        const delay = Math.min(6000, 1000 * Math.pow(1.8, attempt));
        setTimeout(() => {
          if (!stale()) loadSingleContent(targetPage, reqId, attempt + 1);
        }, delay);
        return;
      }
      showFailureToast(`Page ${targetPage} failed to load. Click to retry.`);
    }

    async function turnSinglePage(targetPage, isKeyRepeat = false, passedKey = null) {
      if (stripActive) return;
      const total = getTotalPages();
      if (targetPage < 1 || (total > 0 && targetPage > total)) return;

      notePageChange();
      if (popSuppressPage !== null && targetPage !== popSuppressPage) popSuppressPage = null;
      clearFailureToast();
      const thisRequestId = ++singlePageTurnRequestId;
      pendingSinglePage = targetPage;
      currentPageNum = targetPage;
      window.currentpage = targetPage;
      if (passedKey) pageImgKeyCache.set(targetPage, passedKey);

      // Clear any prior stall timer immediately
      clearStallTimer();

      // Align scroll to top ONCE immediately upon user-initiated page turn
      if (document.activeElement && document.activeElement.closest('#i3')) {
        document.activeElement.blur();
      }
      armSingleAlign();

      // Update counters, nav links, topbar text, and URL immediately (0 ms)
      syncPageIndicator(targetPage);
      updateNavigationControls(targetPage);
      applyBottomSections(targetPage);
      updateTopBarInfo(targetPage);
      cancelKeyboardAnimation();
      updatePageUrl(targetPage, true);

      scrollSinglePageToImageTop();
      requestAnimationFrame(alignIfPending);

      // Respect fast scrolling: settle before initiating heavy prefetch cascades
      if (isFastScrolling()) {
        if (singleTurnPrefetchTimer) {
          clearTimeout(singleTurnPrefetchTimer);
          singleTurnPrefetchTimer = null;
        }
        scheduleSettleCheck();
      } else {
        if (singleTurnPrefetchTimer) clearTimeout(singleTurnPrefetchTimer);
        singleTurnPrefetchTimer = setTimeout(() => {
          if (!stripActive && currentPageNum === targetPage) {
            prefetchSingleMode(targetPage);
          }
        }, 120);
      }

      // FAST LANE: Check preloadedImages or pageImgSrcCache
      const preloaded = preloadedImages.get(targetPage);
      const isPreloaded = preloaded && preloaded.isReady;
      const cachedSrc = isPreloaded ? preloaded.img.src : pageImgSrcCache.get(targetPage);

      if (cachedSrc) {
        displaySinglePageImage(targetPage, cachedSrc);
        updatePageUrl(targetPage, true);
        if (pendingSinglePage === targetPage) pendingSinglePage = null;
        return;
      }

      // If we have API response cached, apply it directly
      if (pageRespCache.has(targetPage)) {
        const resp = pageRespCache.get(targetPage);
        applySinglePageResponse(resp, targetPage, thisRequestId);
        return;
      }

      let key = passedKey || pageImgKeyCache.get(targetPage);

      // Exact size not known yet: any placeholder would be a guess that then has to snap to the
      // real size. The API answer is tiny and usually back within a couple hundred ms, so give it
      // a brief head start (the previous page stays on screen meanwhile) and size once.
      if (key && !getPageTargetDimensions(targetPage).exact) {
        const early = await Promise.race([
          fetchApi(targetPage, key, null, false, true),
          new Promise((resolve) => setTimeout(() => resolve(null), 300))
        ]);
        if (thisRequestId !== singlePageTurnRequestId || currentPageNum !== targetPage) return;
        if (early && !early.error && early.i3) {
          pageRespCache.set(targetPage, early);
          extractKeysFromResp(early, targetPage);
          if (applySinglePageResponse(early, targetPage, thisRequestId)) return;
        }
      }

      // Show thumbnail placeholder while fetching
      showSinglePageThumbnailPlaceholder(targetPage);

      try {
        await loadSingleContent(targetPage, thisRequestId, 0);
      } finally {
        if (pendingSinglePage === targetPage) {
          pendingSinglePage = null;
        }
      }
    }

    function hookReader() {
      if (typeof window.load_image === 'function' && !window.load_image.__eh_hooked) {
        const origLoadImage = window.load_image;
        window.origLoadImage = origLoadImage;

        window.load_image = function (page, imgKey) {
          currentPageNum = page;
          window.currentpage = page;
          if (imgKey) window.currentkey = imgKey;

          if (!stripActive) {
            turnSinglePage(page, false, imgKey);
            return false;
          }

          updatePageUrl(page, true);
          if (pageRespCache.has(page)) {
            onPageTurn(page);
            return false;
          }

          return origLoadImage.apply(this, arguments);
        };
        window.load_image.__eh_hooked = true;
      }

      if (typeof window.update_window_extents === 'function' && !window.update_window_extents.__eh_hooked) {
        const origExtents = window.update_window_extents;
        window.update_window_extents = function () {
          // Native version sizes from the xres/yres globals, which go stale when we bypass
          // apply_json_state. In single mode, size from the current page's real dimensions.
          if (!isReaderPage || stripActive) return origExtents.apply(this, arguments);
          applyCurrentSingleSizing();
        };
        window.update_window_extents.__eh_hooked = true;
      }

    function extractPageNumFromResp(resp) {
      if (!resp) return null;
      if (typeof resp.p === 'number' && resp.p > 0) return resp.p;
      if (typeof resp.p === 'string' && /^\d+$/.test(resp.p)) return parseInt(resp.p, 10);
      if (typeof resp.s === 'string') {
        const m = resp.s.match(/\d+-(\d+)/);
        if (m) return parseInt(m[1], 10);
      }
      const html = (resp.i2 || '') + ' ' + (resp.i4 || '');
      if (html) {
        const m = html.match(/class=["']sn["'][^>]*>[\s\S]*?<span>(\d+)<\/span>/i) ||
                  html.match(/<span>(\d+)<\/span>\s*\/\s*<span>\d+<\/span>/i);
        if (m) return parseInt(m[1], 10);
      }
      return null;
    }

      if (typeof window.apply_json_state === 'function' && !window.apply_json_state.__eh_hooked) {
        const origApply = window.apply_json_state;
        window.origApply = origApply;
        window.apply_json_state = function () {
          const resp = arguments[0];
          const parsedP = extractPageNumFromResp(resp);
          const activeP = parsedP || currentPageNum;

          if (resp && activeP) {
            cacheBottomFromResp(activeP, resp);
            extractKeysFromResp(resp, activeP);
            const newNl = extractNlCode(resp);
            if (newNl) pageNlCodes.set(activeP, newNl);
          }

          // In single-page mode, route cleanly through our update pipeline
          // to prevent native code from demolishing #i1 and reflowing the page!
          if (!stripActive) {
            if (resp && parsedP) {
              pageRespCache.set(parsedP, resp);
              if (parsedP === currentPageNum) {
                applySinglePageResponse(resp, parsedP);
              }
            }
            return false;
          }

          return origApply.apply(this, arguments);
        };
        window.apply_json_state.__eh_hooked = true;
      }

      if (typeof window.api_response === 'function' && !window.api_response.__eh_safe) {
        const origApiResponse = window.api_response;
        window.api_response = function (xhr) {
          try {
            if (xhr && typeof xhr.responseText === 'string') {
              const parsed = safeParseJson(xhr.responseText);
              if (parsed) return parsed;
              return { error: xhr.responseText.trim() || 'Invalid response' };
            }
            return origApiResponse.apply(this, arguments);
          } catch {
            return { error: 'Invalid JSON response' };
          }
        };
        window.api_response.__eh_safe = true;
      }
    }

    let turnDebounce = null;
    function onPageTurn(activePage) {
      if (turnDebounce) clearTimeout(turnDebounce);
      turnDebounce = setTimeout(() => {
        injectReaderToggle();
        syncThemeClass();
        pruneCaches(activePage || currentPageNum);
        initImageMonitor();
        if (stripActive) {
          startPrefetch();
        } else {
          if (singleTurnPrefetchTimer) clearTimeout(singleTurnPrefetchTimer);
          singleTurnPrefetchTimer = setTimeout(() => {
            if (!stripActive) prefetchSingleMode(activePage || currentPageNum);
          }, 180);
        }
      }, 25);
    }

    function clearStallTimer() {
      if (stallTimer) {
        clearTimeout(stallTimer);
        stallTimer = null;
      }
    }

    // In-place failover: ask the server for the page again with ?nl= (it rotates to another image
    // server), swap the new source into #img and keep the reader state. The old implementation did a
    // full location.replace(), which reset scroll, mode state and every cache on each failure.
    async function reloadBrokenImageSingle(pageNo = currentPageNum, manual = false) {
      if (stripActive) return;
      clearStallTimer();
      if (manual) {
        singleReloadAttempts.delete(pageNo);
        apiCircuitBreaker.reset();
        pageImgKeyCache.delete(pageNo);
        pageImgSrcCache.delete(pageNo);
        pageRespCache.delete(pageNo);
      }
      const attempt = singleReloadAttempts.get(pageNo) || 0;
      if (attempt >= MAX_RETRIES + 1) {
        isReloading = false;
        showFailureToast(`Page ${pageNo} failed to load. Click to retry.`);
        return;
      }
      singleReloadAttempts.set(pageNo, attempt + 1);
      isReloading = true;
      const reqId = singlePageTurnRequestId;
      const stale = () => stripActive || pageNo !== currentPageNum || reqId !== singlePageTurnRequestId;
      let newSrc = null;

      try {
        const nl = pageNlCodes.get(pageNo) || null;
        const { gid } = getPageParams();
        let imgKey = pageImgKeyCache.get(pageNo);
        if (!imgKey && pageNo === getCurrentPageMeta().page) {
          imgKey = getCurrentPageMeta().imgKey;
        }
        if (!imgKey) imgKey = await ensureKeysForPage(pageNo, true);
        if (stale()) return;

        // 1. Page HTML (with ?nl= if known) so the server notes the failure and hands out a fresh mirror
        if (imgKey && gid) {
          const url = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nl ? `?nl=${encodeURIComponent(nl)}` : '');
          try {
            const res = await fetch(url, { credentials: 'include', priority: 'high' });
            if (!res.ok) {
              apiCircuitBreaker.recordError(res.status, `Single HTML failover HTTP ${res.status}`);
            } else {
              const html = await res.text();
              if (/image limit exceeded|temporarily banned|excessive pageloads/i.test(html)) {
                apiCircuitBreaker.recordError(509, 'Image limit exceeded / IP banned');
              }
              const doc = new DOMParser().parseFromString(html, 'text/html');
              const newImg = doc.getElementById('img');
              const src = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
              const nextNl = extractNlCode(doc);
              if (nextNl) pageNlCodes.set(pageNo, nextNl);
              if (src && !src.includes('blank.gif') && !src.includes('509.gif')) newSrc = src;
            }
          } catch (err) {
            console.warn(`ExFastReader: failover fetch failed for page ${pageNo}:`, (err && err.message) || err);
          }
          if (stale()) return;
        }

        // 2. API showpage with the nl code
        if (!newSrc && imgKey) {
          const fresh = await fetchApi(pageNo, imgKey, nl || null, true, true);
          if (stale()) return;
          if (fresh && !fresh.error && fresh.i3) {
            pageRespCache.set(pageNo, fresh);
            extractKeysFromResp(fresh, pageNo);
            const nextNl = extractNlCode(fresh);
            if (nextNl) pageNlCodes.set(pageNo, nextNl);
            const i3Doc = parseTemplate(fresh.i3);
            const imgEl = i3Doc.querySelector('img#img') || i3Doc.querySelector('img');
            newSrc = imgEl ? (imgEl.getAttribute('src') || imgEl.src) : null;
          }
        }
      } catch (err) {
        console.warn(`ExFastReader: single-page reload failed for page ${pageNo}:`, (err && err.message) || err);
      } finally {
        isReloading = false;
      }

      if (stale()) return;

      if (newSrc) {
        preloadedImages.delete(pageNo);
        pageImgSrcCache.set(pageNo, newSrc);
        const cur = document.getElementById('img');
        if (cur) {
          cur.__eh_monitored = false;
          // Same URL handed back: clear the broken state so the assignment really re-requests it
          if (cur.src === newSrc) cur.removeAttribute('src');
        }
        displaySinglePageImage(pageNo, newSrc);
        return;
      }

      // Nothing usable yet: back off exponentially and try again, then surface a click-to-retry toast
      const delay = Math.min(8000, 1500 * Math.pow(1.8, attempt));
      setTimeout(() => {
        if (!stale()) reloadBrokenImageSingle(pageNo);
      }, delay);
    }
    const bounceSinglePageImage = reloadBrokenImageSingle;

    function executeBrokenReload() {
      apiCircuitBreaker.reset();
      clearFailureToast();
      if (stripActive) {
        const visPage = getCurrentlyVisiblePageNum();
        if (visPage) {
          if (activeRetryTimers.has(visPage)) {
            clearTimeout(activeRetryTimers.get(visPage));
            activeRetryTimers.delete(visPage);
          }
          reloadingPages.delete(visPage);
          loadingPages.delete(visPage);
          failedPages.delete(visPage);
          pageImgKeyCache.delete(visPage);
          pageImgSrcCache.delete(visPage);
          pageRespCache.delete(visPage);
          reloadPageImage(visPage, 0);
        }
      } else {
        const cur = currentPageNum || 1;
        failedPages.delete(cur);
        pageImgKeyCache.delete(cur);
        pageImgSrcCache.delete(cur);
        pageRespCache.delete(cur);
        reloadBrokenImageSingle(cur, true);
      }
    }

    function initImageMonitor() {
      if (stripActive) return;

      detectInitialPageDimensions();

      const img = document.getElementById('img');
      if (!img) return;
      if (img._eh_page !== undefined && img._eh_page !== currentPageNum) return;
      if (img._eh_obsolete) return;
      // Bail out BEFORE touching the stall timer: onPageTurn() and the MutationObserver call this
      // again a few ms after the page was displayed, and clearing the timer first meant slow
      // images were never detected (no retry, no error, just a stuck load).
      if (img.__eh_monitored) return;
      isReloading = false;
      clearStallTimer();
      if (typeof img.__eh_cleanup === 'function') {
        img.__eh_cleanup();
        img.__eh_cleanup = null;
      }
      img.__eh_monitored = true;

      const pageNo = currentPageNum;
      if (img._eh_page === undefined) {
        img._eh_page = pageNo;
      }
      let bounceTriggered = false;
      const startTime = Date.now();

      const triggerBounce = (reason) => {
        if (bounceTriggered || isReloading) return;
        bounceTriggered = true;
        clearStallTimer();
        img.removeEventListener('error', onError);
        img.removeEventListener('load', onLoad);
        console.warn(`ExFastReader: Single page ${pageNo} image ${reason}. Triggering in-place failover bounce.`);
        bounceSinglePageImage(pageNo);
      };

      const onError = () => {
        if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
        triggerBounce('network load error');
      };

      const onLoad = () => {
        clearStallTimer();
        img.removeEventListener('error', onError);
        img.removeEventListener('load', onLoad);
        if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
        if (img.naturalWidth === 0 || img.naturalHeight === 0) {
          triggerBounce('0x0 dimension after load');
        } else {
          const durationSec = Math.max(0.1, (Date.now() - startTime) / 1000);
          recordDownloadSpeedSample(pageNo, durationSec);
          // Reveal only once the bitmap is decoded; revealing on 'load' alone paints a blank frame
          // right after the placeholder is removed, which showed up as a flash.
          const reveal = () => {
            if (img._eh_page !== currentPageNum || img._eh_obsolete || stripActive) return;
            applySingleImageSizing(img, pageNo);
            updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
            img.style.opacity = '1';
            hideSinglePageThumbnailPlaceholder();
            clearFailureToast();
            singleReloadAttempts.delete(pageNo);
          };
          if (typeof img.decode === 'function') {
            img.decode().then(reveal, reveal);
          } else {
            reveal();
          }
        }
      };

      if (img.complete) {
        if (img.naturalWidth === 0 || img.naturalHeight === 0) {
          triggerBounce('0x0 dimension on arrival');
          return;
        }
        img.style.opacity = '1';
        applySingleImageSizing(img, pageNo);
        updatePageDimensions(pageNo, img.naturalWidth, img.naturalHeight);
        hideSinglePageThumbnailPlaceholder();
        clearFailureToast();
        singleReloadAttempts.delete(pageNo);
      } else {
        // Not loaded into the visible element yet: keep it hidden behind the thumbnail until its own
        // load event fires (a finished background preload does not mean #img has the bitmap).
        showSinglePageThumbnailPlaceholder(pageNo);

        img.addEventListener('error', onError, { once: true });
        img.addEventListener('load', onLoad, { once: true });
        img.__eh_cleanup = () => {
          img.removeEventListener('error', onError);
          img.removeEventListener('load', onLoad);
          bounceTriggered = true; // neutralise this closure's stall timer / late events
        };

        const { slowTimeoutMs } = calculateImageTimeout(pageNo);
        // If this image was already downloading as a background preload, count that time too
        const rec = preloadedImages.get(pageNo);
        const alreadyWaited = (rec && rec.startTime && !rec.isReady) ? (Date.now() - rec.startTime) : 0;
        const stallDelay = Math.max(800, slowTimeoutMs - alreadyWaited);
        stallTimer = setTimeout(() => {
          if (img._eh_page !== currentPageNum || img._eh_obsolete) return;
          if (!img.complete || img.naturalWidth === 0) {
            triggerBounce(`slow / stalled (> ${(slowTimeoutMs / 1000).toFixed(1)}s)`);
          }
        }, stallDelay);
      }
    }

    function initObservers() {
      const i2 = document.getElementById('i2');
      if (i2 && !i2.__eh_observed) {
        i2.__eh_observed = true;
        const obs = new MutationObserver(() => {
          if (!document.getElementById('eh-strip-controls')) {
            injectReaderToggle();
          }
        });
        obs.observe(i2, { childList: true });
      }

      const i1 = document.getElementById('i1') || document.body;
      if (i1 && !i1.__eh_observed) {
        i1.__eh_observed = true;
        const obs = new MutationObserver(() => {
          if (!stripActive) {
            placeStripControls();
          }
        });
        obs.observe(i1, { childList: true });
      }

      const container = document.getElementById('i3');
      if (container && !container.__eh_observed) {
        container.__eh_observed = true;
        let obsDebounce = null;
        const obs = new MutationObserver(() => {
          if (obsDebounce) clearTimeout(obsDebounce);
          obsDebounce = setTimeout(() => {
            if (!stripActive) initImageMonitor();
          }, 20);
        });
        obs.observe(container, { childList: true });
      }
    }

    // Watchdog to ensure no page stays stuck in loadingPages permanently
    if (isReaderPage) {
      setInterval(() => {
        if (!stripActive || loadingPages.size === 0) return;
        const now = Date.now();
        for (const [p, startTime] of loadingPages.entries()) {
          const { watchdogTimeoutMs } = calculateImageTimeout(p);
          if (now - startTime > watchdogTimeoutMs) {
            if (!isPageInActiveWindow(p)) {
              abortPageLoad(p);
            } else {
              console.warn(`ExFastReader Watchdog: Page ${p} exceeded ${(watchdogTimeoutMs / 1000).toFixed(1)}s load limit. Releasing slot.`);
              abortPageLoad(p);
              if (!pageItemMap.get(p)?.querySelector('img.eh-main-img')) {
                recordPageFailure(p, `Watchdog load timeout > ${(watchdogTimeoutMs / 1000).toFixed(1)}s`);
                const failInfo = failedPages.get(p);
                if (failInfo && failInfo.count >= MAX_RETRIES) {
                  const it = pageItemMap.get(p);
                  if (it) showRetryUI(it, p);
                } else {
                  setTimeout(() => {
                    if (stripActive && isPageInActiveWindow(p)) {
                      reloadPageImage(p, failInfo ? failInfo.count : 1);
                    }
                  }, 1200);
                }
              }
            }
          }
        }
      }, 3000);
    }

    function initIndexInfiniteScroll() {
      if (!isIndexPage) return;

      let infiniteScrollActive = safeStorageGet(localStorage, 'eh_infinite_scroll_enabled', '1') !== '0';
      let infiniteScrollCooldownUntil = 0;
      let infiniteScrollFailures = 0;
      let isLoadingNext = false;
      let nextPageUrl = getNextPageUrl(document);
      const loadedUrls = new Set([location.href]);
      let sentinelObserver = null;

      function findThumbnailDropdown() {
        let sel = document.querySelector("select[onchange*='inline_set=dm_']");
        if (sel && !sel.closest('#favact, .favact, [id*="favact"]')) return sel;
        sel = document.querySelector("select[onchange*='inline_set'], select[name*='inline_set']");
        if (sel && !sel.closest('#favact, .favact, [id*="favact"]')) return sel;
        const allSelects = document.querySelectorAll('select');
        for (const s of allSelects) {
          if (s.closest('#favact, .favact, [id*="favact"]') || s.name === 'favact' || s.id === 'favsel' || s.name === 'ddact') continue;
          const oc = s.getAttribute('onchange') || '';
          if (oc.includes('inline_set') || oc.includes('dm_')) return s;
          for (const opt of s.options) {
            const txt = (opt.textContent || '').trim().toLowerCase();
            if (txt === 'thumbnail' || txt === 'thumbnails' || opt.value === 't' || opt.value === 'dm_t') {
              return s;
            }
          }
        }
        return null;
      }

            function getFavoritesForm() {
        const sampleCb = document.querySelector('.itg input[type="checkbox"], input[name^="modifygals"]');
        if (sampleCb) {
          const f = sampleCb.closest('form');
          if (f) return f;
        }
        const itg = document.querySelector('.itg');
        if (itg) {
          const f = itg.closest('form');
          if (f) return f;
        }
        const postForm = document.querySelector('form[method="post"][action*="favorites"], form[method="POST"][action*="favorites"], form[method="post"]');
        if (postForm) return postForm;
        const favform = document.getElementById('favform') || document.querySelector('form[name="favform"]');
        if (favform && (!favform.method || favform.method.toLowerCase() === 'post')) return favform;
        return null;
      }

      function getFavactElement() {
        const byId = document.getElementById('favact');
        if (byId && byId.id !== 'eh-favact-top' && byId.tagName.toLowerCase() !== 'select' && byId.tagName.toLowerCase() !== 'input') {
          return byId;
        }
        const byClass = document.querySelector('div.favact:not(.eh-favact-top), p.favact:not(.eh-favact-top)');
        if (byClass) return byClass;
        const inputEl = document.querySelector('select[name="favact"], select[name="ddact"], input[name="apply"], select#favsel');
        if (inputEl) {
          const parent = inputEl.closest('#favact, .favact, div, p');
          if (parent && parent !== document.body && parent !== document.documentElement && !parent.classList.contains('eh-favact-top') && parent.id !== 'eh-favact-top') {
            return parent;
          }
        }
        return byId || null;
      }

      function toggleAllGalleries(checked) {
        if (typeof window.toggle_all === 'function') {
          try {
            window.toggle_all(checked);
          } catch (err) {
            console.warn('ExFastReader: native toggle_all error', err);
          }
        }

        const galleryCheckboxes = document.querySelectorAll(
          '.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]'
        );
        galleryCheckboxes.forEach((cb) => {
          cb.checked = checked;
        });

        document.querySelectorAll(
          '#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top, input[type="checkbox"][onclick*="toggle_all"]'
        ).forEach((cb) => {
          cb.checked = checked;
        });
      }

      function executeFavAction(selectedAction) {
        const form = getFavoritesForm();
        if (!form) return;
        if (!form.id) form.id = 'favform';

        let ddactInput = form.querySelector('select[name="ddact"], select[name="favact"]');
        if (ddactInput) {
          ddactInput.value = selectedAction;
        } else {
          let hiddenAct = form.querySelector('input[name="ddact"].eh-sync-input');
          if (!hiddenAct) {
            hiddenAct = document.createElement('input');
            hiddenAct.type = 'hidden';
            hiddenAct.name = 'ddact';
            hiddenAct.className = 'eh-sync-input';
            form.appendChild(hiddenAct);
          }
          hiddenAct.value = selectedAction;
        }

        let hiddenApply = form.querySelector('input[name="apply"].eh-sync-input');
        if (!hiddenApply) {
          hiddenApply = document.createElement('input');
          hiddenApply.type = 'hidden';
          hiddenApply.name = 'apply';
          hiddenApply.value = 'Apply';
          hiddenApply.className = 'eh-sync-input';
          form.appendChild(hiddenApply);
        }

        const checkedBoxes = document.querySelectorAll(
          '.itg input[type="checkbox"]:checked, input[name^="modifygals"]:checked'
        );
        checkedBoxes.forEach((cb) => {
          if (cb.form !== form) {
            cb.setAttribute('form', form.id);
          }
        });

        if (typeof form.requestSubmit === 'function') {
          form.requestSubmit();
        } else {
          form.submit();
        }
      }

      function getNextPageUrl(doc = document) {
        const ptdsNext = doc.querySelector('table.ptb td.ptds + td a, table.ptt td.ptds + td a');
        if (ptdsNext && ptdsNext.href && ptdsNext.textContent.trim() !== '<') {
          return ptdsNext.href;
        }
        const searchnavNext = doc.querySelector('.searchnav a[href*="next="], .searchnav a#next');
        if (searchnavNext && searchnavNext.href) {
          return searchnavNext.href;
        }
        const lastA = doc.querySelector('table.ptb td:last-child a, table.ptt td:last-child a');
        if (lastA && lastA.href && (lastA.textContent.trim() === '>' || lastA.textContent.trim() === '»')) {
          return lastA.href;
        }
        const relNext = doc.querySelector('a[rel="next"]');
        if (relNext && relNext.href) {
          return relNext.href;
        }
        return null;
      }

      function getOrCreateSentinel() {
        let sentinel = document.getElementById('eh-infinite-sentinel');
        if (!sentinel) {
          sentinel = document.createElement('div');
          sentinel.id = 'eh-infinite-sentinel';
          sentinel.className = 'eh-infinite-sentinel';
          sentinel.style.cssText = 'text-align: center; padding: 18px 0; margin: 12px auto; font-family: arial, helvetica, sans-serif; font-size: 10pt; font-weight: bold; width: 100%; clear: both; user-select: none;';

          const itg = document.querySelector('.itg');
          const ptb = document.querySelector('table.ptb, .itg + .searchnav, #favform + .searchnav');
          if (itg && itg.parentNode) {
            if (ptb && ptb.parentNode === itg.parentNode) {
              itg.parentNode.insertBefore(sentinel, ptb);
            } else if (itg.nextSibling) {
              itg.parentNode.insertBefore(sentinel, itg.nextSibling);
            } else {
              itg.parentNode.appendChild(sentinel);
            }
          }
        }
        return sentinel;
      }

      function updateSentinelState(state) {
        const sentinel = getOrCreateSentinel();
        if (!sentinel) return;

        if (!infiniteScrollActive) {
          sentinel.style.display = 'none';
          return;
        }

        sentinel.style.display = 'block';
        if (state === 'loading') {
          sentinel.innerHTML = `
            <div style="display: inline-flex; align-items: center; justify-content: center; gap: 8px;">
              <span class="eh-spinner" style="display: inline-block;"></span>
              <span>Loading next page...</span>
            </div>
          `;
        } else if (state === 'end') {
          sentinel.innerHTML = `<span style="opacity: 0.7;">— All pages loaded —</span>`;
        } else if (state === 'error') {
          sentinel.innerHTML = `
            <span style="color: #ef4444; margin-right: 8px;">Failed to load next page.</span>
            <button type="button" class="eh-strip-btn active" id="eh-infinite-retry-btn">Retry</button>
          `;
          const btn = document.getElementById('eh-infinite-retry-btn');
          if (btn) {
            btn.onclick = () => {
              infiniteScrollCooldownUntil = 0;
              loadNextPage();
            };
          }
        } else {
          sentinel.innerHTML = '';
        }
      }

      async function loadNextPage() {
        if (!infiniteScrollActive || isLoadingNext || !nextPageUrl || loadedUrls.has(nextPageUrl)) return;
        if (Date.now() < infiniteScrollCooldownUntil) return;
        isLoadingNext = true;
        updateSentinelState('loading');

        const fetchUrl = nextPageUrl;
        loadedUrls.add(fetchUrl);

        try {
          const res = await fetch(fetchUrl, { credentials: 'include' });
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const html = await res.text();
          const doc = new DOMParser().parseFromString(html, 'text/html');

          const currentItg = document.querySelector('.itg');
          const newItg = doc.querySelector('.itg');

          if (currentItg && newItg) {
            if (currentItg.tagName.toLowerCase() === 'table') {
              const currentTbody = currentItg.querySelector(':scope > tbody') || currentItg;
              const newTbody = newItg.querySelector(':scope > tbody') || newItg;
              const newRows = Array.from(newTbody.children).filter(
                el => el.tagName && el.tagName.toLowerCase() === 'tr' && !el.querySelector('th')
              );
              for (const row of newRows) {
                currentTbody.appendChild(document.importNode(row, true));
              }
            } else {
              const newItems = Array.from(newItg.children);
              for (const item of newItems) {
                currentItg.appendChild(document.importNode(item, true));
              }
            }
          }

          const curPtb = document.querySelector('table.ptb');
          const newPtb = doc.querySelector('table.ptb');
          if (curPtb && newPtb) {
            curPtb.innerHTML = newPtb.innerHTML;
          }

          nextPageUrl = getNextPageUrl(doc);
          syncFavactPosition();
          infiniteScrollFailures = 0;
          infiniteScrollCooldownUntil = 0;

          if (nextPageUrl && !loadedUrls.has(nextPageUrl)) {
            updateSentinelState('idle');
          } else {
            nextPageUrl = null;
            updateSentinelState('end');
          }
        } catch (err) {
          console.warn('ExFastReader: Infinite scroll page load error', err);
          loadedUrls.delete(fetchUrl);
          infiniteScrollFailures++;
          const cooldown = Math.min(30000, 2500 * Math.pow(2, infiniteScrollFailures - 1));
          infiniteScrollCooldownUntil = Date.now() + cooldown;
          updateSentinelState('error');
        } finally {
          isLoadingNext = false;
        }
      }

      function setupObserver() {
        if (sentinelObserver) {
          sentinelObserver.disconnect();
          sentinelObserver = null;
        }
        if (!infiniteScrollActive) return;

        const sentinel = getOrCreateSentinel();
        if (!sentinel) return;

        if (typeof IntersectionObserver !== 'undefined') {
          sentinelObserver = new IntersectionObserver((entries) => {
            for (const entry of entries) {
              if (entry.isIntersecting) {
                loadNextPage();
              }
            }
          }, { rootMargin: '600px' });
          sentinelObserver.observe(sentinel);
        }
      }

      let isSyncingFavact = false;
      function syncFavactPosition() {
        if (!location.pathname.startsWith('/favorites')) return;
        if (isSyncingFavact) return;
        isSyncingFavact = true;

        try {
          const bottomFavact = getFavactElement();
          const ido = document.querySelector('.ido') || document.querySelector('div.ido');
          if (!ido) return;

          if (ido.style.position !== 'relative') {
            ido.style.setProperty('position', 'relative', 'important');
          }

          let topFavact = document.getElementById('eh-favact-top');

          if (infiniteScrollActive) {
            if (!topFavact && bottomFavact) {
              topFavact = bottomFavact.cloneNode(true);
              topFavact.id = 'eh-favact-top';
              topFavact.classList.add('eh-favact-top');

              const cloneCheckbox = topFavact.querySelector('#alltoggle, [name="alltoggle"]');
              if (cloneCheckbox) cloneCheckbox.id = 'eh-alltoggle-top';

              ido.insertBefore(topFavact, ido.firstChild);
            } else if (topFavact) {
              topFavact.style.display = 'inline-flex';
            }

            const topSelect = topFavact ? topFavact.querySelector('select') : null;
            const bottomSelect = bottomFavact ? bottomFavact.querySelector('select') : null;
            const topApply = topFavact ? topFavact.querySelector('input[type="submit"], input[type="button"], button') : null;
            const bottomApply = bottomFavact ? bottomFavact.querySelector('input[type="submit"], input[type="button"], button') : null;

            // Two-way dropdown sync
            if (topSelect && !topSelect.__eh_bound) {
              topSelect.__eh_bound = true;
              topSelect.addEventListener('change', () => {
                if (bottomSelect) bottomSelect.value = topSelect.value;
              });
            }
            if (bottomSelect && !bottomSelect.__eh_bound) {
              bottomSelect.__eh_bound = true;
              bottomSelect.addEventListener('change', () => {
                if (topSelect) topSelect.value = bottomSelect.value;
              });
            }

            // Apply button handling
            if (topApply && !topApply.__eh_bound) {
              topApply.__eh_bound = true;
              topApply.addEventListener('click', (e) => {
                e.preventDefault();
                executeFavAction(topSelect ? topSelect.value : (bottomSelect ? bottomSelect.value : ''));
              });
            }
            if (bottomApply && !bottomApply.__eh_bound) {
              bottomApply.__eh_bound = true;
              bottomApply.addEventListener('click', (e) => {
                e.preventDefault();
                executeFavAction(bottomSelect ? bottomSelect.value : (topSelect ? topSelect.value : ''));
              });
            }
          } else {
            if (topFavact) {
              topFavact.style.display = 'none';
            }
          }
        } catch (err) {
          console.warn('ExFastReader: syncFavactPosition error', err);
        } finally {
          isSyncingFavact = false;
        }
      }      // Delegated listener for alltoggle checkboxes (both top and bottom)
      document.addEventListener('click', (e) => {
        const toggleCb = e.target.closest('#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top, input[type="checkbox"][onclick*="toggle_all"]');
        if (toggleCb && toggleCb.type === 'checkbox') {
          setTimeout(() => {
            toggleAllGalleries(toggleCb.checked);
          }, 0);
        }
      });

      // Delegated listener for individual gallery checkboxes to keep master checkboxes synced
      document.addEventListener('change', (e) => {
        if (e.target && e.target.type === 'checkbox') {
          if (e.target.matches('.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]')) {
            const all = document.querySelectorAll('.itg input[type="checkbox"], input[name^="modifygals"], input[name*="modifygals"]');
            const checked = document.querySelectorAll('.itg input[type="checkbox"]:checked, input[name^="modifygals"]:checked, input[name*="modifygals"]:checked');
            const isAll = all.length > 0 && all.length === checked.length;
            document.querySelectorAll('#alltoggle, [name="alltoggle"], .alltoggle, #eh-alltoggle-top').forEach((cb) => {
              cb.checked = isAll;
            });
          }
        }
      }, { passive: true });

      function toggleInfiniteScroll() {
        infiniteScrollActive = !infiniteScrollActive;
        safeStorageSet(localStorage, 'eh_infinite_scroll_enabled', infiniteScrollActive ? '1' : '0');
        syncFavactPosition();

        const btn = document.getElementById('eh-infinite-scroll-toggle');
        if (btn) {
          btn.classList.toggle('active', infiniteScrollActive);
        }

        if (infiniteScrollActive) {
          infiniteScrollCooldownUntil = 0;
          updateSentinelState('idle');
          setupObserver();
          const scrollDist = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight);
          if (scrollDist < 800) {
            loadNextPage();
          }
        } else {
          updateSentinelState('idle');
          if (sentinelObserver) {
            sentinelObserver.disconnect();
            sentinelObserver = null;
          }
        }
      }

      function injectToggle() {
        const dropdown = findThumbnailDropdown();
        if (!dropdown || document.getElementById('eh-infinite-scroll-toggle')) return Boolean(dropdown);

        const wrap = document.createElement('span');
        wrap.id = 'eh-infinite-scroll-wrap';
        wrap.style.cssText = 'display: inline-flex; align-items: center; margin-left: 8px; vertical-align: middle; height: 100%;';

        const btn = document.createElement('button');
        btn.type = 'button';
        btn.id = 'eh-infinite-scroll-toggle';
        btn.className = 'eh-strip-btn' + (infiniteScrollActive ? ' active' : '');
        btn.title = 'Toggle Infinite Scrolling';
        btn.textContent = 'Infinite Scroll';

        const syncDropdownStyle = () => {
          if (!dropdown.isConnected || !btn.isConnected) return;
          const cs = window.getComputedStyle(dropdown);
          if (cs.fontFamily) btn.style.fontFamily = cs.fontFamily;
          if (cs.fontSize) btn.style.fontSize = cs.fontSize;
          if (cs.fontWeight) btn.style.fontWeight = cs.fontWeight;
          const h = dropdown.offsetHeight;
          if (h > 0) {
            btn.style.height = h + 'px';
            btn.style.boxSizing = 'border-box';
          }
        };

        btn.addEventListener('click', (e) => {
          e.preventDefault();
          toggleInfiniteScroll();
          btn.blur();
        });

        wrap.appendChild(btn);
        dropdown.insertAdjacentElement('afterend', wrap);

        syncDropdownStyle();
        requestAnimationFrame(syncDropdownStyle);
        setTimeout(syncDropdownStyle, 100);

        if (infiniteScrollActive) {
          setupObserver();
        }
        syncFavactPosition();
        return true;
      }

      if (location.pathname.startsWith('/favorites')) {
        syncFavactPosition();
        let favDebounce = null;
        const favObs = new MutationObserver(() => {
          if (favDebounce) return;
          favDebounce = setTimeout(() => {
            favDebounce = null;
            syncFavactPosition();
          }, 80);
        });
        favObs.observe(document.body || document.documentElement, { childList: true, subtree: true });
        setTimeout(() => favObs.disconnect(), 15000);
      }

      window.addEventListener('scroll', () => {
        if (!infiniteScrollActive || isLoadingNext || !nextPageUrl || Date.now() < infiniteScrollCooldownUntil) return;
        const scrollDist = document.documentElement.scrollHeight - (window.scrollY + window.innerHeight);
        if (scrollDist < 800) {
          loadNextPage();
        }
      }, { passive: true });

      let injected = injectToggle();
      if (!injected) {
        let obsTimeout = null;
        const obs = new MutationObserver(() => {
          if (injectToggle()) {
            if (obsTimeout) clearTimeout(obsTimeout);
            obs.disconnect();
          }
        });
        obs.observe(document.documentElement, { childList: true, subtree: true });
        obsTimeout = setTimeout(() => obs.disconnect(), 10000);
      }

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => {
          if (!nextPageUrl) nextPageUrl = getNextPageUrl(document);
          syncFavactPosition();
          if (infiniteScrollActive) setupObserver();
          if (!document.getElementById('eh-infinite-scroll-toggle')) injectToggle();
        }, { once: true });
      }
    }

    syncThemeClass();

    if (isReaderPage) {
      const initialMeta = getCurrentPageMeta();
      currentPageNum = initialMeta.page || 1;
      if (initialMeta.imgKey) {
        pageImgKeyCache.set(currentPageNum, initialMeta.imgKey);
      }
      detectInitialPageDimensions();

      const initialImg = document.getElementById('img');
      if (initialImg) {
        initialImg._eh_page = currentPageNum;
        initialImg._eh_obsolete = false;
        const sw = parseInt(initialImg.style?.width, 10);
        const sh = parseInt(initialImg.style?.height, 10);
        if (sw >= 400 && sh >= 400) {
          recordDisplayDims(currentPageNum, sw, sh);
        } else if (initialImg.naturalWidth >= 400 && initialImg.naturalHeight >= 400) {
          recordDisplayDims(currentPageNum, initialImg.naturalWidth, initialImg.naturalHeight);
        }
      }
    }

    if (isReaderPage) {
      let pollCount = 0;
      let earlyControlsInjected = false;
      let readerStarted = false;

      function startReaderEarly() {
        if (readerStarted) return;
        const i3 = document.getElementById('i3');
        const img = document.getElementById('img');
        if (!i3 && !img && document.readyState === 'loading') return;
        readerStarted = true;

        const curMeta = getCurrentPageMeta();
        if (curMeta && curMeta.page) currentPageNum = curMeta.page;
        if (curMeta && curMeta.imgKey) pageImgKeyCache.set(currentPageNum, curMeta.imgKey);

        detectInitialThumbsPerPage();
        detectInitialPageDimensions();
        extractKeysFromDoc(document);
        const initNl = extractInitialNlCode(document);
        if (initNl) {
          pageNlCodes.set(currentPageNum, initNl);
        }
        extractAndHideBottomLinks();
        discoverAndFetchGallery(currentPageNum);
        prefetchNearbyThumbnails(currentPageNum);
        if (stripActive) {
          enableStripMode();
        } else {
          updateNavigationControls(currentPageNum);
          initImageMonitor();
          startPrefetch();
          injectReaderToggle();
        }
      }

      // Start mounting and rendering the reader immediately
      startReaderEarly();

      const hookTimer = setInterval(() => {
        pollCount++;
        startReaderEarly();

        const { gid, showkey } = getPageParams();
        const i2 = document.getElementById('i2');
        if (i2 && !earlyControlsInjected) {
          earlyControlsInjected = true;
          injectReaderToggle();
          syncThemeClass();
          initObservers();
        }

        const canHook = typeof window.load_image === 'function' &&
                        typeof window.apply_json_state === 'function' &&
                        gid && showkey;

        if (canHook) {
          hookReader();
          clearInterval(hookTimer);
        } else if (pollCount > 400) {
          clearInterval(hookTimer);
        }
      }, 25);
    } else if (isGalleryPage) {
      let lastFavToggleTime = 0;
      let cachedPopupData = null; // { note: '', slots: '', names: [], popupItems: [], isFavorited: bool, favcat: num }
      let savedFavLinkInitialHtml = null;

      const DEFAULT_FAV_NAMES = [
        'Favorites 0', 'Favorites 1', 'Favorites 2', 'Favorites 3', 'Favorites 4',
        'Favorites 5', 'Favorites 6', 'Favorites 7', 'Favorites 8', 'Favorites 9'
      ];

      function getSavedFavNames() {
        try {
          const raw = safeStorageGet(localStorage, 'eh_fav_cat_names', null);
          if (raw) {
            const parsed = JSON.parse(raw);
            if (Array.isArray(parsed) && parsed.length >= 10) return parsed;
          }
        } catch {}
        return [...DEFAULT_FAV_NAMES];
      }

      function detectCurrentCategory() {
        if (cachedPopupData && typeof cachedPopupData.favcat === 'number' && cachedPopupData.isFavorited) {
          return cachedPopupData.favcat;
        }
        const favDiv = document.getElementById('fav');
        if (favDiv) {
          const style = (favDiv.getAttribute('style') || '') + ' ' + (favDiv.style.cssText || '') + ' ' + (favDiv.style.backgroundPosition || '');
          const m = style.match(/(?:0(?:px|%)?|left)\s+(-?\d+)px/i) ||
                    style.match(/background-position:\s*[^;]*?(-?\d+)px/i) ||
                    style.match(/(-?\d+)px\s+no-repeat/i) ||
                    style.match(/0px\s+(-?\d+)px/i);
          if (m) {
            const posY = parseInt(m[1], 10);
            const idx = Math.round((-2 - posY) / 19);
            if (idx >= 0 && idx <= 9) return idx;
          }
          const title = favDiv.getAttribute('title');
          if (title && title.trim()) {
            const names = getSavedFavNames();
            const idx = names.findIndex(n => n.toLowerCase() === title.trim().toLowerCase());
            if (idx !== -1) return idx;
            const mNum = title.match(/Favorites\s+(\d)/i);
            if (mNum) {
              const catNum = parseInt(mNum[1], 10);
              if (catNum >= 0 && catNum <= 9) return catNum;
            }
          }
        }
        const favLink = document.getElementById('favoritelink');
        if (favLink) {
          const txt = favLink.textContent.trim().toLowerCase();
          if (txt && !txt.includes('add to')) {
            const names = getSavedFavNames();
            const idx = names.findIndex(n => n.toLowerCase() === txt);
            if (idx !== -1) return idx;
            const mNum = txt.match(/favorites\s+(\d)/i);
            if (mNum) {
              const catNum = parseInt(mNum[1], 10);
              if (catNum >= 0 && catNum <= 9) return catNum;
            }
          }
        }
        return null;
      }

      function isGalleryFavorited() {
        if (cachedPopupData && typeof cachedPopupData.isFavorited === 'boolean') {
          return cachedPopupData.isFavorited;
        }
        const favLink = document.getElementById('favoritelink');
        if (favLink) {
          const txt = favLink.textContent.trim().toLowerCase();
          if (txt.includes('edit')) return true;
          if (txt && !txt.includes('add to')) return true;
        }
        const favDiv = document.getElementById('fav');
        if (favDiv) {
          if (favDiv.getAttribute('title')) return true;
          const style = (favDiv.getAttribute('style') || '') + ' ' + (favDiv.style.cssText || '');
          if (style.includes('fav.png') || /background(-image)?:\s*url/i.test(style)) return true;
        }
        return detectCurrentCategory() !== null;
      }

      // Intercept mousedown, pointerdown, and click on window to prevent foreign scripts (ComicRead, etc.) or native popup
      ['mousedown', 'pointerdown', 'click'].forEach((evtType) => {
        window.addEventListener(evtType, (e) => {
          const favTarget = e.target.closest('#gdf, #favoritelink, [onclick*="pop_fav"], [onclick*="addfav"]');
          if (favTarget) {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            if (evtType === 'click') {
              openInlineFavorites();
            }
            return false;
          }
        }, true);
      });

      // Override pop_fav, popUp, window.open
      try {
        const _popFavOverride = function () {
          openInlineFavorites();
          return false;
        };
        Object.defineProperty(window, 'pop_fav', {
          configurable: true,
          enumerable: true,
          get() { return _popFavOverride; },
          set() {}
        });
      } catch {
        window.pop_fav = function () { openInlineFavorites(); return false; };
      }

      const _origPopUp = window.popUp;
      window.popUp = function (url, w, h) {
        if (typeof url === 'string' && (url.includes('addfav') || url.includes('act=addfav'))) {
          openInlineFavorites();
          return false;
        }
        if (typeof _origPopUp === 'function') {
          return _origPopUp.apply(this, arguments);
        }
        return false;
      };

      const _origWindowOpen = window.open;
      window.open = function (url, name, specs) {
        if (typeof url === 'string' && (url.includes('addfav') || url.includes('act=addfav'))) {
          openInlineFavorites();
          return null;
        }
        if (typeof _origWindowOpen === 'function') {
          return _origWindowOpen.apply(this, arguments);
        }
        return null;
      };

      function purgeForeignPopups() {
        const gd3 = document.getElementById('gd3');
        if (gd3) {
          Array.from(gd3.children).forEach((child) => {
            const id = child.id;
            const cls = child.className;
            if (!['gdc', 'gdn', 'gdd', 'gdr', 'gdf'].includes(id) && cls !== 'c') {
              child.remove();
            }
          });
        }
      }

      function sanitizeFavElements() {
        const oldGdf = document.getElementById('gdf');
        if (oldGdf && !oldGdf.__eh_sanitized) {
          const favLink = oldGdf.querySelector('#favoritelink');
          if (favLink && !savedFavLinkInitialHtml && favLink.querySelector('img')) {
            savedFavLinkInitialHtml = favLink.innerHTML;
          }
          const newGdf = oldGdf.cloneNode(true);
          newGdf.__eh_sanitized = true;
          newGdf.removeAttribute('onclick');
          const newFavLink = newGdf.querySelector('#favoritelink');
          if (newFavLink) newFavLink.removeAttribute('onclick');

          newGdf.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
            openInlineFavorites();
          });

          oldGdf.parentNode.replaceChild(newGdf, oldGdf);
        }
        purgeForeignPopups();
      }

      function attachRemoveHandler(btn, form, container) {
        if (!btn || btn.__eh_bound) return;
        btn.__eh_bound = true;
        btn.addEventListener('click', async (e) => {
          e.preventDefault();
          btn.disabled = true;
          btn.value = 'Removing...';
          const submitBtn = container.querySelector('.eh-fav-submit-btn');
          const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
          if (submitBtn) submitBtn.disabled = true;
          if (cancelBtn) cancelBtn.disabled = true;

          const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
          const gid = m ? m[1] : (window.gid || '');
          const token = m ? m[2] : (window.token || '');
          const postUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;

          const formData = new FormData();
          formData.append('favcat', 'favdel');
          formData.append('favnote', '');
          formData.append('apply', 'Apply Changes');
          formData.append('update', '1');

          try {
            const res = await fetch(postUrl, {
              method: 'POST',
              body: formData,
              credentials: 'include'
            });

            if (res.ok) {
              cachedPopupData = {
                note: '',
                slots: (cachedPopupData ? cachedPopupData.slots : ''),
                names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
                popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
                isFavorited: false,
                favcat: null
              };
              updateGalleryFavoriteUI('favdel', 'Remove from Favorites');
              closeInlineFavorites();
            } else {
              throw new Error('HTTP ' + res.status);
            }
          } catch (err) {
            console.warn('ExFastReader: Failed to remove favorite', err);
            alert('Failed to remove favorite. Please try again.');
            btn.disabled = false;
            btn.value = 'Remove from Favorites';
            if (submitBtn) submitBtn.disabled = false;
            if (cancelBtn) cancelBtn.disabled = false;
          }
        });
      }

      // Silently prefetch popup info (exact diamonds, note, slots, custom names) straight from the popup in background
      async function silentPrefetchPopupInfo() {
        if (cachedPopupData && typeof cachedPopupData.isFavorited === 'boolean') return cachedPopupData;
        const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        const gid = m ? m[1] : (window.gid || '');
        const token = m ? m[2] : (window.token || '');
        if (!gid || !token) return null;

        const popupUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;
        try {
          const res = await fetch(popupUrl, { credentials: 'include' });
          if (!res.ok) return null;
          const html = await res.text();
          const doc = new DOMParser().parseFromString(html, 'text/html');

          let note = '';
          const noteEl = doc.querySelector('textarea[name="favnote"]');
          if (noteEl) note = noteEl.value || noteEl.textContent || '';

          let slots = '';
          const mSlots = html.match(/(\d+\s*\/\s*\d+\s+favorite note slots used[^\n<\[]*(?:<a[^>]*>\[\?\]<\/a>)?)/i);
          if (mSlots) {
            slots = mSlots[1];
          } else {
            const allElements = doc.querySelectorAll('p, div');
            for (const p of allElements) {
              if (p.children.length <= 1 && p.textContent.includes('favorite note slots used')) {
                slots = p.innerHTML.trim();
                break;
              }
            }
          }
            if (slots && !slots.includes('href=')) {
                slots += ` <a href="https://ehwiki.org/wiki/Favorite" target="_blank">[?]</a>`;
            }

          const catDivs = doc.querySelectorAll('.nosel > div, div.nosel > div');
          const names = getSavedFavNames();
          const popupItems = [];

          if (catDivs.length >= 10) {
            catDivs.forEach((div, i) => {
              if (i < 10) {
                const txt = div.textContent.trim();
                if (txt && !txt.toLowerCase().includes('remove')) {
                  names[i] = txt;
                }
                const diamondDiv = div.querySelector('div.i, div[style*="background"]');
                const diamondStyle = diamondDiv ? diamondDiv.getAttribute('style') : `background-image:url('https://ehgt.org/g/fav.png'); background-position:0px ${-2 - 19 * i}px;`;
                popupItems.push({ idx: i, name: txt || names[i], style: diamondStyle });
              }
            });
            safeStorageSet(localStorage, 'eh_fav_cat_names', JSON.stringify(names));
          }

          const hasApply = Boolean(doc.querySelector('input[value*="Apply Changes"], input[value="Apply Changes"]'));
          const hasRemove = Boolean(doc.querySelector('input[value*="Remove"], [onclick*="favdel"]'));
          const checkedRadio = doc.querySelector('input[name="favcat"]:checked');
          const checkedCat = checkedRadio ? parseInt(checkedRadio.value, 10) : null;

          const isFavorited = hasApply || hasRemove || (checkedCat !== null && isGalleryFavorited());
          const favcat = checkedCat !== null ? checkedCat : (detectCurrentCategory() ?? 0);

          cachedPopupData = { note, slots, names, popupItems, isFavorited, favcat };

          // If the menu is currently open, seamlessly sync the extracted fields
          const container = document.getElementById('eh-inline-favorites-container');
          if (container && container.style.display !== 'none') {
            const textarea = container.querySelector('#eh-fav-note-textarea');
            if (textarea && !textarea.value && note) {
              textarea.value = note;
            }
            if (slots) {
              const slotsEl = container.querySelector('.eh-fav-slots');
              if (slotsEl) slotsEl.innerHTML = slots;
            }
            if (popupItems.length > 0) {
              popupItems.forEach((item) => {
                const label = container.querySelector(`.eh-fav-cat-label[data-cat-idx="${item.idx}"]`);
                if (label && label.textContent !== item.name) {
                  label.textContent = item.name;
                  label.title = item.name;
                }
                const diamond = container.querySelector(`.eh-fav-cat-diamond[data-cat-idx="${item.idx}"]`);
                if (diamond && item.style) {
                  diamond.setAttribute('style', item.style);
                }
              });
            }

            const submitBtn = container.querySelector('.eh-fav-submit-btn');
            if (submitBtn) {
              submitBtn.value = isFavorited ? 'Apply Changes' : 'Add to Favorites';
            }

            const targetRadio = container.querySelector(`input[name="favcat"][value="${favcat}"]`);
            if (targetRadio) {
              targetRadio.checked = true;
            }

            let removeBtn = container.querySelector('#eh-fav-remove-btn');
            if (isFavorited) {
              if (!removeBtn) {
                removeBtn = document.createElement('input');
                removeBtn.type = 'button';
                removeBtn.className = 'eh-fav-remove-btn';
                removeBtn.id = 'eh-fav-remove-btn';
                removeBtn.value = 'Remove from Favorites';
                const actionsEl = container.querySelector('.eh-fav-actions');
                const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
                if (actionsEl && cancelBtn) {
                  actionsEl.insertBefore(removeBtn, cancelBtn);
                }
                const formEl = container.querySelector('#eh-fav-form');
                attachRemoveHandler(removeBtn, formEl, container);
              }
            } else if (removeBtn) {
              removeBtn.remove();
            }
          }
          return cachedPopupData;
        } catch {
          return null;
        }
      }

      function openInlineFavorites() {
        const now = Date.now();
        if (now - lastFavToggleTime < 300) return;
        lastFavToggleTime = now;

        purgeForeignPopups();

        const gd4 = document.getElementById('gd4');
        if (!gd4) return;

        let container = document.getElementById('eh-inline-favorites-container');
        if (container && container.style.display !== 'none') {
          closeInlineFavorites();
          return;
        }

        // Clean up any stray containers
        const allContainers = document.querySelectorAll('#eh-inline-favorites-container');
        if (allContainers.length > 1) {
          for (let i = 1; i < allContainers.length; i++) allContainers[i].remove();
          container = allContainers[0];
        }

        // Hide ALL other elements inside #gd4 (tag list, voting, and enter new tags boxes)
        gd4.classList.add('eh-fav-active');
        Array.from(gd4.children).forEach((child) => {
          if (child !== container) {
            if (child._eh_prev_display === undefined) child._eh_prev_display = child.style.display;
            child.style.setProperty('display', 'none', 'important');
          }
        });

        document.querySelectorAll('#tagmenu_new, #tagmenu_act, #tagmenu, #tagform').forEach((el) => {
          if (el.closest('#gd4')) {
            el.style.setProperty('display', 'none', 'important');
          }
        });

        if (!container) {
          container = document.createElement('div');
          container.id = 'eh-inline-favorites-container';
          gd4.appendChild(container);
        }
        container.style.display = 'block';

        // INSTANT SYNCHRONOUS BUILD: 0ms delay, no loading spinner
        renderBuiltInFavoriteForm(container);

        // Silently sync note/slots/folder names in the background
        silentPrefetchPopupInfo();
      }

      function closeInlineFavorites() {
        const gd4 = document.getElementById('gd4');
        if (gd4) {
          gd4.classList.remove('eh-fav-active');
          Array.from(gd4.children).forEach((child) => {
            if (child.id !== 'eh-inline-favorites-container') {
              child.style.display = child._eh_prev_display !== undefined ? child._eh_prev_display : '';
            }
          });
        }
        const container = document.getElementById('eh-inline-favorites-container');
        if (container) {
          container.style.display = 'none';
        }
      }

      function updateGalleryFavoriteUI(catVal, submitVal) {
        const favDiv = document.getElementById('fav');
        const favLink = document.getElementById('favoritelink');
        const gdf = document.getElementById('gdf');

        if (catVal === 'favdel' || (submitVal && submitVal.toLowerCase().includes('remove'))) {
          if (favDiv) {
            favDiv.style.background = '';
            favDiv.style.width = '';
            favDiv.style.height = '';
            favDiv.style.display = '';
            favDiv.style.marginRight = '';
            favDiv.removeAttribute('title');
            favDiv.innerHTML = '';
          }
          if (favLink) {
            favLink.innerHTML = savedFavLinkInitialHtml || '<img src="https://ehgt.org/g/mr.gif" /> Add to Favorites';
          }
          if (gdf) {
            gdf.querySelectorAll('.i, img[src*="fav.png"], div[style*="fav.png"]').forEach((el) => {
              if (el !== favDiv) el.remove();
            });
          }
          cachedPopupData = {
            note: '',
            slots: (cachedPopupData ? cachedPopupData.slots : ''),
            names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
            popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
            isFavorited: false,
            favcat: null
          };
        } else if (catVal !== null && catVal !== undefined) {
          const catIdx = parseInt(catVal, 10);
          if (!isNaN(catIdx)) {
            const names = getSavedFavNames();
            const chosenName = names[catIdx] || ('Favorites ' + catIdx);
            if (favDiv) {
              favDiv.innerHTML = '';
              favDiv.style.background = `transparent url('https://ehgt.org/g/fav.png') 0px ${-2 - 19 * catIdx}px no-repeat`;
              favDiv.style.width = '19px';
              favDiv.style.height = '19px';
              favDiv.style.display = 'inline-block';
              favDiv.style.marginRight = '4px';
              favDiv.title = chosenName;
            }
            if (favLink) {
              favLink.textContent = chosenName;
            }
            if (gdf) {
              gdf.querySelectorAll('.i, img[src*="fav.png"], div[style*="fav.png"]').forEach((el) => {
                if (el !== favDiv) el.remove();
              });
            }
            cachedPopupData = {
              note: (cachedPopupData ? cachedPopupData.note : ''),
              slots: (cachedPopupData ? cachedPopupData.slots : ''),
              names,
              popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
              isFavorited: true,
              favcat: catIdx
            };
          }
        }
      }

      function renderBuiltInFavoriteForm(container) {
        const currentCat = detectCurrentCategory();
        const isAlreadyFav = isGalleryFavorited();

        const submitText = isAlreadyFav ? 'Apply Changes' : 'Add to Favorites';
        const checkedCat = currentCat !== null ? currentCat : 0;
        const favNames = (cachedPopupData && cachedPopupData.names) ? cachedPopupData.names : getSavedFavNames();
        const noteVal = (cachedPopupData && cachedPopupData.note) ? cachedPopupData.note : '';
        const slotsHtml = (cachedPopupData && cachedPopupData.slots) ? cachedPopupData.slots : `0 / 1000 favorite note slots used. <a href="https://ehwiki.org/wiki/Favorite" target="_blank">[?]</a>`;
        const popupItems = cachedPopupData && cachedPopupData.popupItems ? cachedPopupData.popupItems : [];

        let catsHtml = '';
        for (let i = 0; i < 10; i++) {
          const name = favNames[i] || `Favorites ${i}`;
          const isChecked = (i === checkedCat);
          const customStyle = popupItems[i]?.style || `background-image:url('https://ehgt.org/g/fav.png'); background-position:0px ${-2 - 19 * i}px;`;
          catsHtml += `
            <label class="eh-fav-cat-item">
              <input type="radio" name="favcat" value="${i}" ${isChecked ? 'checked' : ''} />
              <div class="eh-fav-cat-diamond i" data-cat-idx="${i}" style="${customStyle}"></div>
              <span class="eh-fav-cat-label" data-cat-idx="${i}" title="${name}">${name}</span>
            </label>
          `;
        }

        const removeBtnHtml = isAlreadyFav
          ? `<input type="button" class="eh-fav-remove-btn" id="eh-fav-remove-btn" value="Remove from Favorites" />`
          : '';

        const headerText = 'Choose a favorite category to save this gallery under. You can also add a note to it if you wish.';

        container.innerHTML = `
          <form class="eh-fav-form" id="eh-fav-form" method="post">
            <input type="hidden" name="update" value="1" />
            <p class="eh-fav-header">${headerText}</p>
            <div class="eh-fav-body">
              <div class="eh-fav-cats">
                ${catsHtml}
              </div>
              <div class="eh-fav-note-pane">
                <div class="eh-fav-note-title">Favorite Note (Max 200 Characters)</div>
                <textarea name="favnote" id="eh-fav-note-textarea" maxlength="200" class="eh-fav-note-textarea" placeholder="">${noteVal}</textarea>
                <div class="eh-fav-slots">${slotsHtml}</div>
                <div class="eh-fav-actions">
                  <input type="submit" name="apply" class="eh-fav-submit-btn" value="${submitText}" />
                  ${removeBtnHtml}
                  <input type="button" class="eh-fav-cancel-btn" value="Cancel" />
                </div>
              </div>
            </div>
          </form>
        `;

        const form = container.querySelector('#eh-fav-form');
        const cancelBtn = container.querySelector('.eh-fav-cancel-btn');
        const submitBtn = container.querySelector('.eh-fav-submit-btn');
        const removeBtn = container.querySelector('#eh-fav-remove-btn');

        if (cancelBtn) {
          cancelBtn.addEventListener('click', (e) => {
            e.preventDefault();
            closeInlineFavorites();
          });
        }

        if (removeBtn) {
          attachRemoveHandler(removeBtn, form, container);
        }

        if (form) {
          form.addEventListener('submit', async (e) => {
            e.preventDefault();

            const curSubmitVal = submitBtn ? submitBtn.value : 'Add to Favorites';
            const selectedRadio = form.querySelector('input[name="favcat"]:checked');
            const chosenCat = selectedRadio ? selectedRadio.value : '0';
            const chosenNote = form.querySelector('#eh-fav-note-textarea')?.value || '';

            const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
            const gid = m ? m[1] : (window.gid || '');
            const token = m ? m[2] : (window.token || '');
            const postUrl = `${location.origin}/gallerypopups.php?gid=${gid}&t=${token}&act=addfav`;

            const formData = new FormData();
            formData.append('favcat', chosenCat);
            formData.append('favnote', chosenNote);
            formData.append('apply', curSubmitVal);
            formData.append('update', '1');

            if (submitBtn) {
              submitBtn.disabled = true;
              submitBtn.value = 'Saving...';
            }
            if (removeBtn) removeBtn.disabled = true;
            if (cancelBtn) cancelBtn.disabled = true;

            try {
              const res = await fetch(postUrl, {
                method: 'POST',
                body: formData,
                credentials: 'include'
              });

              if (res.ok) {
                cachedPopupData = {
                  note: chosenNote,
                  slots: (cachedPopupData ? cachedPopupData.slots : ''),
                  names: (cachedPopupData ? cachedPopupData.names : getSavedFavNames()),
                  popupItems: (cachedPopupData ? cachedPopupData.popupItems : []),
                  isFavorited: true,
                  favcat: parseInt(chosenCat, 10)
                };
                updateGalleryFavoriteUI(chosenCat, curSubmitVal);
                closeInlineFavorites();
              } else {
                throw new Error('HTTP ' + res.status);
              }
            } catch (err) {
              console.warn('ExFastReader: Failed to save favorite', err);
              alert('Failed to save favorite. Please try again.');
              if (submitBtn) {
                submitBtn.disabled = false;
                submitBtn.value = curSubmitVal;
              }
              if (removeBtn) removeBtn.disabled = false;
              if (cancelBtn) cancelBtn.disabled = false;
            }
          });
        }
      }

      function setupInlineFavorites() {
        const favLink = document.getElementById('favoritelink');
        if (favLink && favLink.querySelector('img')) {
          savedFavLinkInitialHtml = favLink.innerHTML;
        }
        sanitizeFavElements();
        silentPrefetchPopupInfo();

        window.addEventListener('keydown', (e) => {
          if (e.key === 'Escape') {
            const container = document.getElementById('eh-inline-favorites-container');
            if (container && container.style.display !== 'none') {
              closeInlineFavorites();
            }
          }
        });
      }

      function initGalleryPage() {
        setupInlineFavorites();
        const m = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
        const curGid = m ? parseInt(m[1], 10) : 0;
        if (m) {
          const gUrl = location.origin + `/g/${m[1]}/${m[2]}/`;
          safeStorageSet(sessionStorage, 'eh_gallery_' + m[1], gUrl);
          safeStorageSet(localStorage, 'eh_gallery_' + m[1], gUrl);
          cachedGalleryUrl = gUrl;
        }
        if (curGid) {
          cachedGid = curGid;
          loadGalleryDataFromSession(curGid);
        }
        const gpcEl = document.querySelector('.gpc, #gpc');
        if (gpcEl) {
          const mGpc = gpcEl.textContent.match(/Showing\s+(\d+)\s*-\s*(\d+)\s+of\s+([\d,]+)\s+images/i);
          if (mGpc) {
            const sNum = parseInt(mGpc[1], 10);
            const eNum = parseInt(mGpc[2], 10);
            const tot = parseInt(mGpc[3].replace(/,/g, ''), 10);
            if (eNum < tot) {
              const count = eNum - sNum + 1;
              if ([20, 40, 100].includes(count)) {
                thumbsPerPage = count;
                safeStorageSet(localStorage, 'eh_thumbs_per_page', String(count));
              }
            }
            if (tot > 0) cachedTotalPages = tot;
          }
        }
        const thumbCount = document.querySelectorAll('#gdt .gdtm, #gdt .gdtl').length;
        if ([20, 40, 100].includes(thumbCount)) {
          thumbsPerPage = thumbCount;
          safeStorageSet(localStorage, 'eh_thumbs_per_page', String(thumbCount));
        }

        // Immediately parse all thumbnails, dimensions, keys, and sprites directly from the gallery DOM
        extractDimensionsFromGallery(document);
        if (curGid) {
          saveGalleryDataToSession(curGid);
          // Cache data for current gallery page; avoid mass background downloads
          const nextGal = 1;
          const totalGalPages = Math.ceil(cachedTotalPages / thumbsPerPage);
          if (nextGal < totalGalPages && !fetchedGalleryPages.has(nextGal) && !inFlightGalleryFetches.has(nextGal)) {
            fetchGalleryPage(nextGal);
          }
        }

        if ('scrollRestoration' in history) {
          try { history.scrollRestoration = 'manual'; } catch {}
        }

        const MAX_PREFETCH_LINKS = 12;
        const prefetchLinks = [];
        const prefetchedUrls = new Set();
        function safePrefetchUrl(url) {
          if (!url || prefetchedUrls.has(url)) return;
          try {
            prefetchedUrls.add(url);
            while (prefetchLinks.length >= MAX_PREFETCH_LINKS) {
              const oldLink = prefetchLinks.shift();
              if (oldLink && oldLink.parentNode) {
                oldLink.remove();
              }
            }
            const link = document.createElement('link');
            link.rel = 'prefetch';
            link.href = url;
            document.head.appendChild(link);
            prefetchLinks.push(link);
          } catch {}
        }

let isGalleryNavigating = false;
        async function navigateGalleryPage(targetUrl, pushState = true) {
          if (isGalleryNavigating) return;
          isGalleryNavigating = true;

          const gdt = document.getElementById('gdt');
          const ptt = document.querySelector('table.ptt');
          const ptb = document.querySelector('table.ptb');
          const gpc = document.querySelector('.gpc, #gpc');

          try {
            const res = await fetch(targetUrl, { credentials: 'include' });
            if (!res.ok) {
              location.href = targetUrl;
              return;
            }

            const html = await res.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');

            const newPtt = doc.querySelector('table.ptt');
            const newGpc = doc.querySelector('.gpc, #gpc');
            const newGdt = doc.getElementById('gdt');
            const newPtb = doc.querySelector('table.ptb');

            // 1. Update in natural top-to-bottom DOM order to eliminate reflow jumping
            if (newPtt && ptt) {
              ptt.innerHTML = newPtt.innerHTML;
            }
            if (newGpc && gpc) {
              gpc.innerHTML = newGpc.innerHTML;
            }
            if (newGdt && gdt) {
              gdt.innerHTML = newGdt.innerHTML;
            }
            if (newPtb && ptb) {
              ptb.innerHTML = newPtb.innerHTML;
            }

            if (doc.title) {
              document.title = doc.title;
            }

            if (pushState) {
              window.history.pushState({ galleryUrl: targetUrl }, doc.title || document.title, targetUrl);
            }

            // 2. Parse and cache the newly loaded thumbnails and dimensions
            extractDimensionsFromGallery(doc);
            if (curGid) {
              saveGalleryDataToSession(curGid);
            }

            // 3. Prefetch next page thumbnail if present
            const nextP = document.querySelector('table.ptb td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
            if (nextP && nextP.href && nextP.textContent.trim() !== '<') {
              safePrefetchUrl(nextP.href);
            }
          } catch (err) {
            console.warn('ExFastReader: Gallery AJAX navigation failed, falling back to full navigation', err);
            location.href = targetUrl;
          } finally {
            isGalleryNavigating = false;
          }
        }

        window.__eh_navigateGalleryPage = navigateGalleryPage;

        document.addEventListener('click', (e) => {
          const cell = e.target.closest('table.ptt td, table.ptb td, table.ptt a, table.ptb a, .searchnav td, .searchnav a');
          if (!cell) return;

          const link = cell.tagName === 'A' ? cell : cell.querySelector('a');
          if (!link) return;

          const rawHref = link.getAttribute('href') || link.href;
          if (!rawHref || rawHref === '#' || rawHref.startsWith('javascript:')) return;

          let targetUrl;
          try {
            targetUrl = new URL(rawHref, location.href).href;
          } catch {
            targetUrl = link.href;
          }
          if (!targetUrl) return;

          try {
            const parsedTarget = new URL(targetUrl, location.origin);
            if (parsedTarget.origin !== location.origin) return;

            const mCur = location.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
            const mTar = parsedTarget.pathname.match(/\/g\/(\d+)\/([0-9a-fA-F]{10})/i);
            const isSameGallery = (mCur && mTar && mCur[1] === mTar[1]) ||
                                  (parsedTarget.pathname.replace(/\/+$/, '') === location.pathname.replace(/\/+$/, ''));
            if (!isSameGallery) return;

            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();

            navigateGalleryPage(targetUrl, true);
          } catch (err) {
            console.warn('ExFastReader: Gallery link check error', err);
          }
        }, true);

        // Listen for reader navigation clicks to guarantee session storage synchronization
        document.addEventListener('click', (e) => {
          const a = e.target.closest('a[href*="/s/"]');
          if (a && curGid) {
            saveGalleryDataToSession(curGid);
          }
        }, { capture: true, passive: true });

        // Prefetch reader page on hover for instant transition
        document.addEventListener('pointerover', (e) => {
          const a = e.target.closest('a[href*="/s/"]');
          if (a && a.href && !a.__eh_prefetched) {
            a.__eh_prefetched = true;
            safePrefetchUrl(a.href);
          }
        }, { capture: true, passive: true });

        const nextThumb = document.querySelector('table.ptb td.ptds + td a') || document.querySelector('table.ptb td:last-child a');
        if (nextThumb && nextThumb.href && nextThumb.textContent.trim() !== '<') {
          safePrefetchUrl(nextThumb.href);
        }
      }

      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initGalleryPage, { once: true });
      } else {
        initGalleryPage();
      }
    } else if (isIndexPage) {
      initIndexInfiniteScroll();
    }
  }
})();