☰

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 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Violentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Userscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Ex Fast Reader
// @namespace    https://tampermonkey.net/
// @version      4.6.6
// @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();
    // "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;
      });
    }

    function failThumbSheet(entry) {
      entry.ready = false;
      entry.tries += 1;
      if (entry.tries <= 2) {
        setTimeout(() => {
          if (thumbSheets.get(entry.url) === entry && !entry.ready) startThumbSheetLoad(entry, true);
        }, 800 * entry.tries);
      } else {
        entry.failedAt = Date.now();
      }
    }

    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;
      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 {}
    }

    const GDATA_VERSION = 3;

    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);
          }
        }
        if (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) {
      let toast = document.getElementById('eh-toast-fail');
      if (!toast) {
        toast = document.createElement('div');
        toast.id = 'eh-toast-fail';
        toast.className = 'eh-toast-fail';
        document.body.appendChild(toast);
        toast.addEventListener('click', () => {
          safeStorageRemove(sessionStorage, 'eh_retry_' + location.pathname + '_' + currentPageNum);
          safeStorageRemove(sessionStorage, 'eh_retry_' + location.pathname);
          toast.remove();
          executeBrokenReload();
        });
      }
      toast.textContent = msg;
    }

    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;

    function detectInitialPageDimensions() {
      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 });
        recordDisplayDims(currentPageNum, foundW, foundH);
        applyGlobalAspectRatio(defaultAspectRatio);
        return;
      }

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

    function updateThumbnailAspectRatio(pageNo, ar) {
      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;

      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);
        }
      }
    }

    function updatePageDimensions(pageNo, width, height) {
      if (!width || !height) return;
      // Thumbnail sizes (< 400px) must only update aspect ratio, never full-page display dimensions
      if (width < 400 || height < 400) {
        updateThumbnailAspectRatio(pageNo, width / height);
        return;
      }
      detectedDisplayLimit = Math.max(detectedDisplayLimit, width, height);
      const ar = width / height;
      const prevDim = pageDimensions.get(pageNo);
      // Keep the stored size only when it is already real (>= 400px) and within 0.2% of the loaded
      // ratio. A ratio that came from a thumbnail (width 0) is quantised by +-0.5px on a ~200px image,
      // so it must always be replaced by the real one; the old 2% rule froze that error in place.
      const prevReal = prevDim && prevDim.width >= 400 && prevDim.height >= 400;
      if (prevReal && prevDim.aspectRatio && Math.abs(prevDim.aspectRatio / ar - 1) < 0.002) {
        let changed = false;
        if ((!prevDim.width || prevDim.width < 400) && width >= 400) {
          prevDim.width = width;
          prevDim.height = height;
          changed = true;
        }
        if (changed && currentPageNum === pageNo) {
          updateTopBarInfo(pageNo);
        }
        return;
      }
      withScrollAnchor(() => {
        pageDimensions.set(pageNo, { width, height, 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);
          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);
      }
    }

    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) {
          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);
        const img = item ? item.querySelector('img.eh-main-img') : (pageNo === currentPageNum ? document.getElementById('img') : null);
        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;
          }
        }
      }
    }

    function recordDisplayDims(pageNo, w, h) {
      if (!(w >= 400 && h >= 400)) return;
      const orig = pageDimensions.get(pageNo);

      const guess = placeholderGuess.get(pageNo);
      if (guess) {
        placeholderGuess.delete(pageNo);
        if (guess.width !== w || guess.height !== h) {
          console.info('[ExFastReader] placeholder size miss, page ' + pageNo + ': guessed ' +
            guess.width + 'x' + guess.height + ' (' + guess.src + '), actual ' + w + 'x' + h, guess);
        }
      }
      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 });
      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; // downscaled by the server: this width is definitely the cap
      } else if (!displayCapW || displayCapW < 400) {
        newCap = w;
      } else if (w > displayCapW) {
        newCap = w;
      }
      if (newCap !== displayCapW && newCap >= 400) {
        displayCapW = newCap;
        displayByOriginal.clear(); // served sizes depend on the cap
        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));
      }
    }

    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. 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) {
        recordDisplayDims(pageNo, fromResp.width, fromResp.height);
        return { ...fromResp, exact: true };
      }

      // 2. Previously confirmed size for this page
      const known = pageDisplayDims.get(pageNo);
      if (known) return { ...known, 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;
        recordDisplayDims(pageNo, nw, nh);
        return { width: nw, height: nh, aspectRatio: nw / nh, exact: true };
      }

      // 4. Estimate (placeholder only).
      // Gallery thumbnails only give an aspect ratio with a few % of rounding noise, which is what
      // made the placeholder the wrong height. Pages in a gallery are usually the same size, so if
      // the thumbnail ratio is close to the last real page we reuse that page's exact size.
      const dim = pageDimensions.get(pageNo);
      const ow = (dim?.width && dim.width >= 400) ? dim.width : 0;
      const oh = (dim?.height && dim.height >= 400) ? dim.height : 0;
      const th = pageThumbnailCache.get(pageNo);
      const thumbAr = (th && th.w > 0 && th.h > 0) ? (th.w / th.h) : 0;
      const ar = dim?.aspectRatio || (ow > 0 && oh > 0 ? ow / oh : 0) || thumbAr;
      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;
      const smallerOriginal = ow >= 400 && refW >= 400 && ow < refW * 0.98;

      if (ar > 0) {
        // Served width = original width, capped by the site's resolution setting; with nothing
        // better to go on assume the neighbouring page's width (not the largest ever seen)
        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;
          // Same original size as a page we already know: identical served size, no rounding.
          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');
            }
            // Known original: compute the served height the way the server does (learned rounding).
            // No neighbour reuse here: a ratio tolerance of even 0.1% is ~2px on a 1935px image.
            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');
          }
          // Thumbnail ratio only. Reuse the neighbouring page's exact size when the ratios agree within
          // the thumbnail's own quantisation noise. (This used to sit after an unconditional return and
          // could never run.)
          if (refAr > 0 && refW === Math.round(w)) {
            const tol = (th && th.w > 0 && th.h > 0) ? 1.5 * (0.5 / th.w + 0.5 / th.h) : 0.01;
            if (Math.abs(ar / refAr - 1) <= tol) {
              return guessed({ width: refW, height: refH, aspectRatio: refAr, exact: false }, 'neighbour size');
            }
          }
          return guessed({ width: Math.round(w), height: Math.round(w / 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 };
    }

    // Nearest page whose real served size we know. Neighbouring pages almost always share a size,
    // so this is a far better guess for a placeholder than "the biggest width seen anywhere".
    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 });
                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 circuit breaker is active and this is not a priority request, avoid even queueing it
      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;

          // Queued while the user scrolled/turned on: don't spend a request on a page nobody is near anymore
          if (stripActive && !isPageInActiveWindow(parsedPage, 6)) return null;
          // Single mode prefetches API data up to 6 pages ahead; drop far-away requests
          if (!stripActive && !isPriority && Math.abs(parsedPage - currentPageNum) > 6) return null;
          if (!stripActive && isPriority && !forceFresh && Math.abs(parsedPage - currentPageNum) > 2) return null;

          // If priority request arrives during backoff, wait briefly (up to 3s)
          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 (trimmed.includes('Key expired') || trimmed.includes('Invalid key')) {
                errStr = 'Key expired';
              } else if (trimmed.includes('Image limit exceeded')) {
                errStr = 'Image limit exceeded';
              } else if (trimmed.includes('temporarily banned')) {
                errStr = 'IP temporarily banned';
              }
            }
            if (!errStr) errStr = 'Invalid API response';

            // If key is expired or invalid, invalidate it so a fresh key is fetched from the gallery
            if (/key expired|invalid key/i.test(errStr)) {
              pageImgKeyCache.delete(parsedPage);
            }

            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);
      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;
        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);

        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 HTML failover fetch: works whether nl is known or null!
      if (imgKey && gid) {
        try {
          const failoverUrl = `${location.origin}/s/${imgKey}/${gid}-${pageNo}` + (nl ? `?nl=${encodeURIComponent(nl)}` : '');
          const res = await fetch(failoverUrl, { credentials: 'include', priority: 'high' });
          if (!res.ok) {
            apiCircuitBreaker.recordError(res.status, `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 newSrc = newImg ? (newImg.getAttribute('src') || newImg.src) : null;
            const nextNl = extractNlCode(doc);
            if (nextNl) pageNlCodes.set(pageNo, nextNl);
            if (newSrc && !newSrc.includes('blank.gif') && !newSrc.includes('509.gif')) {
              reloadingPages.delete(pageNo);
              pageImgSrcCache.set(pageNo, newSrc);
              renderImageInItem(pageNo, newSrc, retryCount);
              return;
            }
          }
        } 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);
        }
      }

      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();
      const targets = [cur + 1, cur + 2]
        .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;
        const dim = pageDimensions.get(p);
        if (dim && dim.width >= 400 && dim.height >= 400) 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) {
              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;
          }

          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();
    }
  }
})();