Universal Manga Reader

Smart immersive manga reader overlay with proactive image detection/preload, auto next chapter, auto paging, shake paging, Wake Lock, auto-open rules, bottom more drawer, hide UI, and bilingual UI (English default / Chinese toggle).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Universal Manga Reader
// @namespace    https://github.com/ymhomer/ym_Userscript
// @version      1.8.0
// @description  Smart immersive manga reader overlay with proactive image detection/preload, auto next chapter, auto paging, shake paging, Wake Lock, auto-open rules, bottom more drawer, hide UI, and bilingual UI (English default / Chinese toggle).
// @author       ymhomer
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const AUTO_NEXT_FLAG = '__UMR_AUTO_OPEN__';
  const UMR_STATE_KEY = '__UMR_READER_STATE__';
  const UMR_AUTO_RULES_KEY = '__UMR_AUTO_RULES__';
  const UMR_LANG_KEY = '__UMR_LANG__';

  const SHAKE_SENSITIVITY_LEVELS = [1, 2, 3, 4, 5];

  const SHAKE_CONFIG_MAP = {
    1: { threshold: 2.7, burst: 5.2, cooldown: 900, decay: 0.80 },
    2: { threshold: 2.2, burst: 4.4, cooldown: 820, decay: 0.78 },
    3: { threshold: 1.8, burst: 3.7, cooldown: 760, decay: 0.76 },
    4: { threshold: 1.45, burst: 3.0, cooldown: 700, decay: 0.74 },
    5: { threshold: 1.1, burst: 2.4, cooldown: 620, decay: 0.72 }
  };

  const I18N = {
    en: {
      menu_open: '📚 Open Manga Reader',
      menu_close: '❌ Close Manga Reader',

      app_title: 'Manga Reader',
      app_title_with_count: ({ title, count }) => `${title || 'Manga Reader'} · ${count} pages`,

      mode_single: 'Single',
      mode_double: 'Double',

      auto_next: 'Auto Next',
      auto_play: 'Auto Play',
      hide_ui: 'Hide UI',
      show_ui: 'Show UI',
      fullscreen: 'Fullscreen',
      close: 'Close',
      more: 'More',
      help: 'Help',
      language: 'Language',
      language_en: 'English',
      language_zh: '中文',
      shake_paging: 'Shake Page',
      shake_sensitivity: ({ level }) => `Sensitivity ${level}`,
      auto_open_manager: 'Auto Open Rules',
      save: 'Save',
      cancel: 'Cancel',
      delete: 'Delete',
      edit: 'Edit',
      rule_dialog_title: 'Auto Open Immersive Reader',
      rule_empty: 'No rules yet. Press "+" to add the current page URL.',
      rule_input_placeholder: 'Enter a URL rule, wildcard * supported',
      rule_tip_html:
        'You can change the trailing part of a URL to <code>*</code> as a wildcard.<br>' +
        'It is recommended to set the rule to the real chapter reading URL, not a search page or list page.<br>' +
        'When adding, the current page URL will be filled in automatically.',

      loader_processing: 'Processing...',
      loader_trigger_lazy: 'Triggering lazy loading...',
      loader_detect_sources: 'Proactively detecting image sources...',
      loader_analyzing_pages: ({ domCount, probeCount }) => `Analyzing manga pages... DOM large images ${domCount} / proactive probe ${probeCount}`,
      loader_detecting_progress: ({ done, total }) => `Proactively detecting images... ${done}/${total}`,
      loader_analyzing_current_page: 'Analyzing images on this page...',
      loader_no_pages: 'No images that look like manga pages were found. Please fully load the page and try again.',

      toast_not_found_pages: 'No usable manga pages found',
      toast_loaded_pages: ({ count }) => `Loaded ${count} manga pages`,
      toast_last_page_no_next: 'Last page: next chapter target not found',
      toast_last_page_auto_next: ({ seconds }) => `Last page: auto entering next chapter in ${seconds} seconds`,
      toast_auto_next_failed_no_target: 'Auto next chapter failed: target not found',
      toast_auto_next_failed_open: 'Auto next chapter failed: unable to open target',
      toast_already_last: 'Already at the last page',
      toast_already_first: 'Already at the first page',
      toast_mode_single: 'Single-page mode',
      toast_mode_double: 'Double-page mode',
      toast_auto_next_off: 'Auto next chapter disabled',
      toast_auto_next_on: 'Auto next chapter enabled',
      toast_shake_off_due_autoplay: 'Shake paging disabled, switched to auto play',
      toast_auto_play_off: 'Auto play disabled',
      toast_auto_play_on: ({ seconds }) => `Auto play enabled (${seconds} sec)`,
      toast_motion_permission_denied: 'Motion permission not granted or unsupported by this browser',
      toast_shake_on: ({ level }) => `Shake paging enabled (sensitivity ${level})`,
      toast_shake_off: 'Shake paging disabled',
      toast_shake_sensitivity: ({ level }) => `Shake paging sensitivity: ${level}`,
      toast_fullscreen_fail: 'Fullscreen is not supported on this page or was blocked by the browser',
      toast_enter_rule: 'Please enter a URL rule',
      toast_rule_saved: 'Auto-open rule saved',
      toast_rule_deleted: 'Rule deleted',
      toast_lang_switched: ({ lang }) => `Language switched to ${lang}`,

      btn_seconds: ({ seconds }) => `${seconds}s`,
      btn_ui_unlock_title: 'Show controls',
      btn_fullscreen_title: 'Fullscreen',
      btn_close_title: 'Close',

      help_html:
        '<div><b>Keyboard:</b></div>' +
        '<div>← / A: Previous page</div>' +
        '<div>→ / D / Space: Next page</div>' +
        '<div>M: Single / Double page</div>' +
        '<div>P: Auto play On/Off</div>' +
        '<div>T: Switch auto play interval</div>' +
        '<div>N: Auto next chapter On/Off</div>' +
        '<div>F: Fullscreen</div>' +
        '<div>U: Hide / Show UI</div>' +
        '<div>Esc: Close</div>' +
        '<div style="margin-top:8px;"><b>Touch:</b></div>' +
        '<div>Swipe left/right to turn pages, tap left/right to turn pages, tap center to hide/show UI.</div>' +
        '<div style="margin-top:8px;"><b>Smart Display:</b></div>' +
        '<div>Portrait images prefer side fitting, landscape images prefer top/bottom fitting; double-page mode tries to keep both pages complete and centered closely together.</div>' +
        '<div style="margin-top:8px;"><b>Proactive Collection:</b></div>' +
        '<div>Scans DOM, lazy-load attributes, srcset, background images, and image URLs in scripts, then warms them up to improve auto-open completeness.</div>' +
        '<div style="margin-top:8px;"><b>Auto Play / Shake Page / Next Chapter:</b></div>' +
        '<div>Auto play and shake page are mutually exclusive. When one is enabled, the other will be disabled automatically. On the last page, if auto next chapter is enabled, it waits 5 seconds before jumping to the next chapter.</div>'
    },

    zh: {
      menu_open: '📚 开启漫画阅读器',
      menu_close: '❌ 关闭漫画阅读器',

      app_title: '漫画阅读器',
      app_title_with_count: ({ title, count }) => `${title || '漫画阅读器'} · ${count} 张`,

      mode_single: '单页',
      mode_double: '双页',

      auto_next: '自动下一章',
      auto_play: '自动翻页',
      hide_ui: '隐藏UI',
      show_ui: '显示UI',
      fullscreen: '全屏',
      close: '关闭',
      more: '更多',
      help: '说明',
      language: '语言',
      language_en: 'English',
      language_zh: '中文',
      shake_paging: '摇晃翻页',
      shake_sensitivity: ({ level }) => `敏感度 ${level}`,
      auto_open_manager: '自动打开沉浸式',
      save: '保存',
      cancel: '取消',
      delete: '删除',
      edit: '改',
      rule_dialog_title: '自动打开沉浸式',
      rule_empty: '目前没有规则。按「+」可把当前页面网址加入管理。',
      rule_input_placeholder: '请输入网址规则,支持 * 万用字元',
      rule_tip_html:
        '可把网址后端改成 <code>*</code> 作为万用字元。<br>' +
        '建议设定为真正进入漫画阅读时的连结,不要设定到搜寻页或列表页。<br>' +
        '新增时会先自动带入当前页面网址。',

      loader_processing: '正在处理...',
      loader_trigger_lazy: '正在触发页面懒加载...',
      loader_detect_sources: '正在主动探测图片来源...',
      loader_analyzing_pages: ({ domCount, probeCount }) => `正在分析漫画页... DOM大图 ${domCount} 张 / 主动探测 ${probeCount} 张`,
      loader_detecting_progress: ({ done, total }) => `正在主动探测图片... ${done}/${total}`,
      loader_analyzing_current_page: '正在分析本页图片...',
      loader_no_pages: '没有找到足够像漫画页的图片,请先把页面完整载入后再试。',

      toast_not_found_pages: '未找到可用漫画页',
      toast_loaded_pages: ({ count }) => `已载入 ${count} 张漫画页`,
      toast_last_page_no_next: '最后一页:未找到下一章按钮',
      toast_last_page_auto_next: ({ seconds }) => `最后一页:${seconds} 秒后自动进入下一章`,
      toast_auto_next_failed_no_target: '自动下一章失败:找不到目标',
      toast_auto_next_failed_open: '自动下一章失败:无法打开目标',
      toast_already_last: '已经是最后一页',
      toast_already_first: '已经是第一页',
      toast_mode_single: '单页模式',
      toast_mode_double: '双页模式',
      toast_auto_next_off: '已关闭自动下一章',
      toast_auto_next_on: '已开启自动下一章',
      toast_shake_off_due_autoplay: '已关闭摇晃翻页,改为自动翻页',
      toast_auto_play_off: '已关闭自动翻页',
      toast_auto_play_on: ({ seconds }) => `已开启自动翻页(${seconds} 秒)`,
      toast_motion_permission_denied: '装置摇晃权限未授予或此浏览器不支持',
      toast_shake_on: ({ level }) => `已开启摇晃翻页(敏感度 ${level})`,
      toast_shake_off: '已关闭摇晃翻页',
      toast_shake_sensitivity: ({ level }) => `摇晃翻页敏感度:${level}`,
      toast_fullscreen_fail: '此页不支持全屏或被浏览器阻止',
      toast_enter_rule: '请输入网址规则',
      toast_rule_saved: '已保存自动打开规则',
      toast_rule_deleted: '已删除规则',
      toast_lang_switched: ({ lang }) => `已切换语言:${lang}`,

      btn_seconds: ({ seconds }) => `${seconds}秒`,
      btn_ui_unlock_title: '显示控制列',
      btn_fullscreen_title: '全屏',
      btn_close_title: '关闭',

      help_html:
        '<div><b>操作:</b></div>' +
        '<div>← / A:上一页</div>' +
        '<div>→ / D / Space:下一页</div>' +
        '<div>M:单页 / 双页</div>' +
        '<div>P:自动翻页 开/关</div>' +
        '<div>T:切换自动翻页秒数</div>' +
        '<div>N:自动下一章 开/关</div>' +
        '<div>F:全屏</div>' +
        '<div>U:隐藏 / 显示 UI</div>' +
        '<div>Esc:关闭</div>' +
        '<div style="margin-top:8px;"><b>触控:</b></div>' +
        '<div>左滑 / 右滑翻页,左右点击翻页,中间点击显示/隐藏 UI。</div>' +
        '<div style="margin-top:8px;"><b>智能显示:</b></div>' +
        '<div>直式图优先左右贴边,横式图优先上下贴边;双页模式会尽量兼顾左右贴边与上下完整,并让两页靠近置中。</div>' +
        '<div style="margin-top:8px;"><b>主动收图:</b></div>' +
        '<div>会主动扫描 DOM、懒加载属性、srcset、背景图与脚本中的图片网址,并预热图片,以提高自动启动时的完整度。</div>' +
        '<div style="margin-top:8px;"><b>自动翻页 / 摇晃翻页 / 下一章:</b></div>' +
        '<div>自动翻页与摇晃翻页互斥;其中一方开启时会自动关闭另一方。到最后一页后,如果已开启自动下一章,才会再等待 5 秒自动跳下一章。</div>'
    }
  };

  const STATE = {
    overlay: null,
    pages: [],
    index: 0,
    mode: 'single',
    fullscreen: false,
    preloadCount: 12,
    isOpen: false,

    touchStartX: 0,
    touchStartY: 0,
    touchMoved: false,

    keyHandlerBound: false,
    resizeHandlerBound: false,
    fullscreenHandlerBound: false,
    visibilityHandlerBound: false,
    scrollLocked: false,

    autoNextEnabled: true,
    autoNextDelay: 5000,
    autoNextTimer: null,
    autoNextTarget: null,

    autoPlayEnabled: false,
    autoPlayDelay: 3000,
    autoPlayTimer: null,

    shakePagingEnabled: false,
    shakeSensitivity: 3,
    shakeLastTriggerTime: 0,
    shakeHandlerBound: false,
    shakeGravity: { x: 0, y: 0, z: 0 },
    shakeLastLinear: { x: 0, y: 0, z: 0 },
    shakeEnergy: 0,
    shakeWarmupFrames: 0,
    shakeLastMotionTime: 0,

    moreOpen: false,

    wakeLockSentinel: null,
    wakeLockSupported: 'wakeLock' in navigator,

    restoreUiHidden: false,

    ruleDialogOpen: false,
    ruleEditIndex: -1,

    proactiveCandidates: [],
    proactiveMetaMap: new Map(),
    proactiveDoneCount: 0,

    lang: loadLanguage(),

    config: {
      minWidth: 240,
      minHeight: 320,
      minArea: 120000,
      aspectMin: 0.2,
      aspectMax: 3.8,
      proactiveMaxCandidates: 300,
      proactiveConcurrency: 8
    }
  };

  const STYLE = `
    #umr-overlay {
      position: fixed;
      inset: 0;
      z-index: 2147483646;
      background: #000;
      color: #fff;
      display: flex;
      flex-direction: column;
      font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      user-select: none;
      overflow: hidden;
    }

    #umr-overlay.umr-hidden {
      display: none !important;
    }

    #umr-topbar {
      height: 44px;
      min-height: 44px;
      display: flex;
      align-items: center;
      padding: 6px 12px;
      background: linear-gradient(to bottom, rgba(0,0,0,.82), rgba(0,0,0,.14));
      backdrop-filter: blur(8px);
      z-index: 5;
      transition: opacity .2s ease;
    }

    #umr-overlay.umr-ui-hidden #umr-topbar,
    #umr-overlay.umr-ui-hidden #umr-bottombar,
    #umr-overlay.umr-ui-hidden #umr-more-panel {
      opacity: 0;
      pointer-events: none;
    }

    #umr-topbar .umr-title {
      font-size: 13px;
      opacity: .9;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      width: 100%;
      text-align: center;
    }

    .umr-btn {
      height: 34px;
      border: 1px solid rgba(255,255,255,.14);
      background: rgba(255,255,255,.08);
      color: #fff;
      border-radius: 10px;
      padding: 0 12px;
      cursor: pointer;
      font-size: 12px;
      transition: .18s ease;
      white-space: nowrap;
      flex: 0 0 auto;
    }

    .umr-btn:hover {
      background: rgba(255,255,255,.14);
    }

    .umr-btn.umr-active {
      background: rgba(255,255,255,.22);
      border-color: rgba(255,255,255,.28);
    }

    .umr-btn.umr-danger {
      border-color: rgba(255,255,255,.18);
    }

    .umr-icon-btn {
      width: 34px;
      min-width: 34px;
      padding: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      line-height: 1;
    }

    #umr-stage {
      flex: 1;
      position: relative;
      overflow: hidden;
      background: #000;
    }

    #umr-book {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 2px;
      padding: 0;
      overflow: hidden;
      perspective: 1400px;
      background: #000;
    }

    #umr-book.umr-book-double {
      gap: 8px;
      justify-content: center;
      padding: 0 12px;
    }

    .umr-pageWrap {
      width: 50%;
      max-width: 50%;
      height: 100%;
      max-height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      transform-style: preserve-3d;
      transition: transform .24s ease, opacity .2s ease, width .16s ease;
      position: relative;
      overflow: hidden;
      background: #000;
    }

    .umr-pageWrap.single {
      width: 100%;
      max-width: 100%;
      height: 100%;
      max-height: 100%;
    }

    .umr-pageWrap.double {
      width: auto;
      max-width: none;
      flex: 0 0 auto;
      overflow: visible;
    }

    .umr-pageWrap.turn-next {
      animation: umrTurnNext .22s ease;
      transform-origin: left center;
    }

    .umr-pageWrap.turn-prev {
      animation: umrTurnPrev .22s ease;
      transform-origin: right center;
    }

    @keyframes umrTurnNext {
      0% { transform: rotateY(0deg) scale(1); opacity: 1; }
      50% { transform: rotateY(-12deg) scale(.992); opacity: .95; }
      100% { transform: rotateY(0deg) scale(1); opacity: 1; }
    }

    @keyframes umrTurnPrev {
      0% { transform: rotateY(0deg) scale(1); opacity: 1; }
      50% { transform: rotateY(12deg) scale(.992); opacity: .95; }
      100% { transform: rotateY(0deg) scale(1); opacity: 1; }
    }

    .umr-page {
      display: block;
      border-radius: 0;
      box-shadow: none;
      background: #000;
      max-width: 100%;
      max-height: 100%;
      image-rendering: auto;
      pointer-events: none;
      object-fit: contain;
    }

    .umr-pageWrap.single.portrait .umr-page {
      width: 100%;
      height: auto;
      max-width: 100%;
      max-height: 100%;
    }

    .umr-pageWrap.single.landscape .umr-page,
    .umr-pageWrap.single.square .umr-page {
      height: 100%;
      width: auto;
      max-height: 100%;
      max-width: 100%;
    }

    .umr-pageWrap.double .umr-page {
      width: 100%;
      height: auto;
      max-width: 100%;
      max-height: 100%;
      object-fit: contain;
    }

    .umr-pageMeta {
      position: absolute;
      left: 8px;
      bottom: 8px;
      background: rgba(0,0,0,.42);
      font-size: 11px;
      padding: 4px 8px;
      border-radius: 999px;
      opacity: .5;
      pointer-events: none;
    }

    .umr-nav-zone {
      position: absolute;
      top: 0;
      bottom: 0;
      width: 30%;
      z-index: 2;
      cursor: pointer;
      background: transparent;
    }

    .umr-nav-left { left: 0; }
    .umr-nav-right { right: 0; }

    .umr-nav-center {
      position: absolute;
      left: 30%;
      right: 30%;
      top: 0;
      bottom: 0;
      z-index: 1;
    }

    #umr-ui-unlock {
      position: absolute;
      top: 10px;
      right: 10px;
      z-index: 9;
      width: 38px;
      height: 38px;
      border: 1px solid rgba(255,255,255,.18);
      background: rgba(0,0,0,.45);
      color: #fff;
      border-radius: 999px;
      display: none;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      line-height: 1;
      backdrop-filter: blur(8px);
      cursor: pointer;
      box-shadow: 0 4px 14px rgba(0,0,0,.28);
    }

    #umr-overlay.umr-ui-hidden #umr-ui-unlock {
      display: flex;
    }

    #umr-ui-unlock:active {
      transform: scale(.96);
    }

    #umr-bottombar {
      min-height: 76px;
      padding: 8px 12px 10px;
      background: linear-gradient(to top, rgba(0,0,0,.90), rgba(0,0,0,.20));
      backdrop-filter: blur(8px);
      display: flex;
      flex-direction: column;
      gap: 8px;
      z-index: 5;
      transition: opacity .2s ease;
    }

    #umr-control-row {
      display: flex;
      gap: 8px;
      flex-wrap: nowrap;
      justify-content: flex-start;
      align-items: center;
      overflow-x: auto;
      overflow-y: hidden;
      scrollbar-width: none;
      -ms-overflow-style: none;
      padding-bottom: 2px;
    }

    #umr-control-row::-webkit-scrollbar {
      display: none;
    }

    #umr-progress-row {
      display: flex;
      align-items: center;
      gap: 12px;
    }

    #umr-progress {
      flex: 1;
      height: 6px;
      background: rgba(255,255,255,.12);
      border-radius: 999px;
      position: relative;
      cursor: pointer;
    }

    #umr-progress-fill {
      position: absolute;
      left: 0;
      top: 0;
      bottom: 0;
      width: 0%;
      border-radius: 999px;
      background: rgba(255,255,255,.9);
    }

    #umr-progress-handle {
      position: absolute;
      top: 50%;
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: #fff;
      transform: translate(-50%, -50%);
      left: 0%;
      box-shadow: 0 2px 12px rgba(255,255,255,.35);
    }

    #umr-counter {
      min-width: 92px;
      text-align: right;
      font-size: 13px;
      opacity: .92;
      flex: 0 0 auto;
    }

    #umr-toast {
      position: absolute;
      left: 50%;
      bottom: 94px;
      transform: translateX(-50%);
      background: rgba(0,0,0,.74);
      color: #fff;
      padding: 8px 12px;
      border-radius: 999px;
      font-size: 12px;
      opacity: 0;
      pointer-events: none;
      transition: opacity .2s ease, transform .2s ease;
      z-index: 12;
      max-width: min(92vw, 560px);
      text-align: center;
    }

    #umr-toast.show {
      opacity: 1;
      transform: translateX(-50%) translateY(-4px);
    }

    #umr-loader {
      position: absolute;
      inset: 0;
      z-index: 11;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(0,0,0,.78);
      flex-direction: column;
      gap: 10px;
      color: #fff;
    }

    .umr-spinner {
      width: 34px;
      height: 34px;
      border-radius: 50%;
      border: 3px solid rgba(255,255,255,.18);
      border-top-color: rgba(255,255,255,.95);
      animation: umrSpin .8s linear infinite;
    }

    @keyframes umrSpin {
      to { transform: rotate(360deg); }
    }

    #umr-more-panel {
      position: absolute;
      left: 10px;
      right: 10px;
      bottom: 84px;
      z-index: 10;
      border-radius: 16px;
      background: rgba(10,10,10,.94);
      border: 1px solid rgba(255,255,255,.10);
      backdrop-filter: blur(10px);
      box-shadow: 0 16px 36px rgba(0,0,0,.45);
      padding: 12px;
      display: none;
      transform: translateY(16px);
      opacity: 0;
      transition: opacity .22s ease, transform .22s ease;
    }

    #umr-more-panel.show {
      display: block;
      opacity: 1;
      transform: translateY(0);
    }

    #umr-more-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
      gap: 8px;
    }

    .umr-more-item {
      width: 100%;
      justify-content: center;
    }

    #umr-help {
      position: absolute;
      right: 0;
      left: 0;
      bottom: calc(100% + 10px);
      margin: 0 auto;
      width: min(420px, calc(100vw - 40px));
      background: rgba(8,8,8,.96);
      border: 1px solid rgba(255,255,255,.1);
      border-radius: 14px;
      padding: 12px;
      font-size: 13px;
      line-height: 1.55;
      display: none;
      box-shadow: 0 12px 30px rgba(0,0,0,.45);
    }

    #umr-help.show {
      display: block;
    }

    #umr-rule-dialog-backdrop {
      position: absolute;
      inset: 0;
      z-index: 20;
      background: rgba(0,0,0,.52);
      display: none;
      align-items: center;
      justify-content: center;
      padding: 18px;
    }

    #umr-rule-dialog-backdrop.show {
      display: flex;
    }

    #umr-rule-dialog {
      width: min(760px, 100%);
      max-height: min(84vh, 860px);
      background: rgba(10,10,10,.97);
      border: 1px solid rgba(255,255,255,.10);
      border-radius: 18px;
      box-shadow: 0 18px 44px rgba(0,0,0,.48);
      display: flex;
      flex-direction: column;
      overflow: hidden;
      backdrop-filter: blur(12px);
    }

    #umr-rule-toolbar {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 12px;
      border-bottom: 1px solid rgba(255,255,255,.08);
    }

    #umr-rule-title {
      flex: 1;
      font-size: 14px;
      opacity: .95;
    }

    #umr-rule-body {
      padding: 12px;
      overflow: auto;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }

    .umr-rule-tip {
      font-size: 12px;
      line-height: 1.5;
      opacity: .72;
      background: rgba(255,255,255,.04);
      border: 1px solid rgba(255,255,255,.06);
      border-radius: 12px;
      padding: 10px 12px;
    }

    #umr-rule-list {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }

    .umr-rule-row {
      display: flex;
      align-items: center;
      gap: 8px;
      border: 1px solid rgba(255,255,255,.08);
      background: rgba(255,255,255,.04);
      border-radius: 12px;
      padding: 10px;
    }

    .umr-rule-text {
      flex: 1;
      min-width: 0;
      font-size: 12px;
      opacity: .92;
      word-break: break-all;
    }

    #umr-rule-empty {
      font-size: 12px;
      opacity: .65;
      text-align: center;
      padding: 16px 10px;
      border: 1px dashed rgba(255,255,255,.10);
      border-radius: 12px;
    }

    #umr-rule-editor {
      display: none;
      flex-direction: column;
      gap: 8px;
      border: 1px solid rgba(255,255,255,.08);
      background: rgba(255,255,255,.04);
      border-radius: 12px;
      padding: 12px;
    }

    #umr-rule-editor.show {
      display: flex;
    }

    #umr-rule-textarea {
      width: 100%;
      min-height: 110px;
      resize: vertical;
      background: rgba(0,0,0,.35);
      color: #fff;
      border: 1px solid rgba(255,255,255,.12);
      border-radius: 10px;
      padding: 10px;
      font-size: 12px;
      line-height: 1.45;
      box-sizing: border-box;
    }

    .umr-rule-editor-actions {
      display: flex;
      gap: 8px;
      flex-wrap: wrap;
    }

    @media (max-width: 860px) {
      #umr-topbar {
        height: 44px;
        min-height: 44px;
        padding: 6px 10px;
      }

      .umr-btn {
        height: 32px;
        font-size: 12px;
        padding: 0 10px;
      }

      .umr-icon-btn {
        width: 32px;
        min-width: 32px;
      }

      #umr-ui-unlock {
        top: 8px;
        right: 8px;
        width: 36px;
        height: 36px;
        font-size: 15px;
      }

      #umr-bottombar {
        min-height: 82px;
        padding: 8px 10px 10px;
      }

      .umr-nav-zone {
        width: 30%;
      }

      .umr-nav-center {
        left: 30%;
        right: 30%;
      }

      #umr-toast {
        bottom: 98px;
      }

      #umr-more-panel {
        left: 8px;
        right: 8px;
        bottom: 88px;
      }

      #umr-rule-dialog-backdrop {
        padding: 10px;
      }

      #umr-rule-dialog {
        max-height: 88vh;
      }

      #umr-rule-toolbar {
        flex-wrap: wrap;
      }
    }
  `;

  GM_addStyle(STYLE);
  GM_registerMenuCommand(t('menu_open'), openReader);
  GM_registerMenuCommand(t('menu_close'), closeReader);

  function loadLanguage() {
    try {
      const v = localStorage.getItem(UMR_LANG_KEY);
      if (v === 'en' || v === 'zh') return v;
    } catch {}
    return 'en';
  }

  function saveLanguage(lang) {
    try {
      localStorage.setItem(UMR_LANG_KEY, lang);
    } catch {}
  }

  function t(key, vars) {
    const langTable = I18N[STATE.lang] || I18N.en;
    const fallbackTable = I18N.en;
    const value = langTable[key] ?? fallbackTable[key] ?? key;
    if (typeof value === 'function') return value(vars || {});
    return value;
  }

  function tf(key, vars) {
    return String(t(key, vars));
  }

  function escapeHtml(text) {
    return String(text)
      .replaceAll('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;')
      .replaceAll("'", '&#39;');
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function debounce(fn, wait = 100) {
    let tmr = null;
    return (...args) => {
      clearTimeout(tmr);
      tmr = setTimeout(() => fn(...args), wait);
    };
  }

  function safeText(v) {
    return String(v || '').trim();
  }

  function normalizeUrl(url) {
    try {
      if (!url) return '';
      if (url.startsWith('data:')) return '';
      if (url.startsWith('blob:')) return '';
      return new URL(url, location.href).href;
    } catch {
      return '';
    }
  }

  function getAutoOpenRules() {
    try {
      const raw = localStorage.getItem(UMR_AUTO_RULES_KEY);
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      if (!Array.isArray(parsed)) return [];
      return parsed.filter(x => typeof x === 'string' && x.trim());
    } catch {
      return [];
    }
  }

  function saveAutoOpenRules(rules) {
    try {
      localStorage.setItem(UMR_AUTO_RULES_KEY, JSON.stringify(rules));
    } catch {}
  }

  function wildcardToRegex(pattern) {
    const escaped = String(pattern)
      .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
      .replace(/\*/g, '.*');
    return new RegExp(`^${escaped}$`, 'i');
  }

  function matchesAutoOpenRule(url) {
    const rules = getAutoOpenRules();
    return rules.some(rule => {
      try {
        return wildcardToRegex(rule).test(url);
      } catch {
        return false;
      }
    });
  }

  async function requestMotionPermissionIfNeeded() {
    try {
      if (typeof DeviceMotionEvent === 'undefined') return false;
      if (typeof DeviceMotionEvent.requestPermission === 'function') {
        const result = await DeviceMotionEvent.requestPermission();
        return result === 'granted';
      }
      return true;
    } catch {
      return false;
    }
  }

  function getShakeConfig() {
    return SHAKE_CONFIG_MAP[STATE.shakeSensitivity] || SHAKE_CONFIG_MAP[3];
  }

  function resetShakeDetector() {
    STATE.shakeGravity = { x: 0, y: 0, z: 0 };
    STATE.shakeLastLinear = { x: 0, y: 0, z: 0 };
    STATE.shakeEnergy = 0;
    STATE.shakeWarmupFrames = 0;
    STATE.shakeLastMotionTime = 0;
    STATE.shakeLastTriggerTime = 0;
  }

  function onDeviceMotion(event) {
    if (!STATE.isOpen || !STATE.shakePagingEnabled) return;

    const acc = event.accelerationIncludingGravity || event.acceleration;
    if (!acc) return;

    const x = Number(acc.x || 0);
    const y = Number(acc.y || 0);
    const z = Number(acc.z || 0);

    const cfg = getShakeConfig();
    const alpha = 0.84;

    STATE.shakeGravity.x = alpha * STATE.shakeGravity.x + (1 - alpha) * x;
    STATE.shakeGravity.y = alpha * STATE.shakeGravity.y + (1 - alpha) * y;
    STATE.shakeGravity.z = alpha * STATE.shakeGravity.z + (1 - alpha) * z;

    const lx = x - STATE.shakeGravity.x;
    const ly = y - STATE.shakeGravity.y;
    const lz = z - STATE.shakeGravity.z;

    if (STATE.shakeWarmupFrames < 8) {
      STATE.shakeLastLinear = { x: lx, y: ly, z: lz };
      STATE.shakeWarmupFrames++;
      return;
    }

    const jx = lx - STATE.shakeLastLinear.x;
    const jy = ly - STATE.shakeLastLinear.y;
    const jz = lz - STATE.shakeLastLinear.z;

    STATE.shakeLastLinear = { x: lx, y: ly, z: lz };

    const jerkMagnitude = Math.sqrt(jx * jx + jy * jy + jz * jz);
    const linearMagnitude = Math.sqrt(lx * lx + ly * ly + lz * lz);
    const dominantAxis = Math.max(Math.abs(jx), Math.abs(jy), Math.abs(jz));

    const intervalMs = Math.max(12, Number(event.interval || 16));
    const dtFactor = Math.min(1.35, Math.max(0.75, 16 / intervalMs));

    const pulse = Math.max(
      jerkMagnitude * 0.95,
      dominantAxis * 1.12,
      linearMagnitude * 0.42
    ) * dtFactor;

    STATE.shakeEnergy = STATE.shakeEnergy * cfg.decay + pulse;
    STATE.shakeLastMotionTime = Date.now();

    const now = Date.now();
    const enoughCooldown = now - STATE.shakeLastTriggerTime > cfg.cooldown;
    const triggered =
      enoughCooldown &&
      (STATE.shakeEnergy >= cfg.burst || (pulse >= cfg.threshold && STATE.shakeEnergy >= cfg.threshold * 1.55));

    if (triggered) {
      STATE.shakeLastTriggerTime = now;
      STATE.shakeEnergy = 0;
      nextPage();
      return;
    }

    if (now - STATE.shakeLastMotionTime > 260) {
      STATE.shakeEnergy *= 0.65;
    }
  }

  function bindShakeMotionIfNeeded() {
    if (STATE.shakeHandlerBound) return;
    if (typeof window === 'undefined' || typeof DeviceMotionEvent === 'undefined') return;
    window.addEventListener('devicemotion', onDeviceMotion, { passive: true });
    STATE.shakeHandlerBound = true;
  }

  function saveReaderState() {
    try {
      const stateToSave = {
        mode: STATE.mode,
        autoNextEnabled: STATE.autoNextEnabled,
        autoPlayEnabled: STATE.autoPlayEnabled,
        autoPlayDelay: STATE.autoPlayDelay,
        shakePagingEnabled: STATE.shakePagingEnabled,
        shakeSensitivity: STATE.shakeSensitivity,
        uiHidden: !!STATE.overlay?.classList.contains('umr-ui-hidden'),
        lang: STATE.lang
      };
      sessionStorage.setItem(UMR_STATE_KEY, JSON.stringify(stateToSave));
    } catch {}
  }

  function loadReaderState() {
    STATE.restoreUiHidden = false;

    try {
      const raw = sessionStorage.getItem(UMR_STATE_KEY);
      if (!raw) return;

      const saved = JSON.parse(raw);
      if (!saved || typeof saved !== 'object') return;

      if (saved.mode === 'single' || saved.mode === 'double') {
        STATE.mode = saved.mode;
      }

      if (typeof saved.autoNextEnabled === 'boolean') {
        STATE.autoNextEnabled = saved.autoNextEnabled;
      }

      if (typeof saved.autoPlayEnabled === 'boolean') {
        STATE.autoPlayEnabled = saved.autoPlayEnabled;
      }

      if ([3000, 5000, 7000, 10000].includes(saved.autoPlayDelay)) {
        STATE.autoPlayDelay = saved.autoPlayDelay;
      }

      if (typeof saved.shakePagingEnabled === 'boolean') {
        STATE.shakePagingEnabled = saved.shakePagingEnabled;
      }

      if (SHAKE_SENSITIVITY_LEVELS.includes(saved.shakeSensitivity)) {
        STATE.shakeSensitivity = saved.shakeSensitivity;
      }

      if (saved.lang === 'en' || saved.lang === 'zh') {
        STATE.lang = saved.lang;
      }

      if (saved.shakePagingEnabled && saved.autoPlayEnabled) {
        STATE.autoPlayEnabled = false;
      }

      STATE.restoreUiHidden = !!saved.uiHidden;
    } catch {}
  }

  function clearAutoNextTimer() {
    if (STATE.autoNextTimer) {
      clearTimeout(STATE.autoNextTimer);
      STATE.autoNextTimer = null;
    }
    STATE.autoNextTarget = null;
  }

  function clearAutoPlayTimer() {
    if (STATE.autoPlayTimer) {
      clearTimeout(STATE.autoPlayTimer);
      STATE.autoPlayTimer = null;
    }
  }

  function clearAllAsyncTimers() {
    clearAutoNextTimer();
    clearAutoPlayTimer();
  }

  function scheduleAutoNextIfNeeded() {
    clearAutoNextTimer();
    if (!STATE.isOpen || !STATE.autoNextEnabled || !isAtLastPage()) return;

    const candidate = findNextChapterCandidate();
    STATE.autoNextTarget = candidate;

    if (!candidate) {
      showToast(tf('toast_last_page_no_next'));
      return;
    }

    showToast(tf('toast_last_page_auto_next', { seconds: Math.round(STATE.autoNextDelay / 1000) }));
    STATE.autoNextTimer = setTimeout(() => {
      const latestCandidate = findNextChapterCandidate() || candidate;
      if (!latestCandidate) {
        showToast(tf('toast_auto_next_failed_no_target'));
        return;
      }
      const ok = jumpToNextChapter(latestCandidate);
      if (!ok) showToast(tf('toast_auto_next_failed_open'));
    }, STATE.autoNextDelay);
  }

  function scheduleAutoPlayIfNeeded() {
    clearAutoPlayTimer();
    if (!STATE.isOpen || !STATE.autoPlayEnabled || !STATE.pages.length) return;

    if (isAtLastPage()) {
      scheduleAutoNextIfNeeded();
      return;
    }

    STATE.autoPlayTimer = setTimeout(() => {
      if (!STATE.isOpen || !STATE.autoPlayEnabled) return;
      if (isAtLastPage()) {
        scheduleAutoNextIfNeeded();
        return;
      }
      nextPage(true);
    }, STATE.autoPlayDelay);
  }

  function updateTopbar() {
    if (!STATE.overlay) return;
    const title = STATE.overlay.querySelector('#umr-title');
    if (title) {
      title.textContent = tf('app_title_with_count', {
        title: document.title || tf('app_title'),
        count: STATE.pages.length
      });
    }
  }

  function updateControls() {
    if (!STATE.overlay) return;

    const autoPlayBtn = STATE.overlay.querySelector('#umr-auto-play');
    const autoPlayDelayBtn = STATE.overlay.querySelector('#umr-auto-play-delay');
    const moreBtn = STATE.overlay.querySelector('#umr-more-toggle');
    const modeBtn = STATE.overlay.querySelector('#umr-mode');
    const autoNextBtn = STATE.overlay.querySelector('#umr-auto-next');
    const shakeBtn = STATE.overlay.querySelector('#umr-shake-toggle');
    const shakeSensitivityBtn = STATE.overlay.querySelector('#umr-shake-sensitivity');
    const uiToggleBtn = STATE.overlay.querySelector('#umr-ui-toggle');
    const langBtn = STATE.overlay.querySelector('#umr-language-toggle');

    if (autoPlayBtn) autoPlayBtn.classList.toggle('umr-active', STATE.autoPlayEnabled);
    if (autoPlayBtn) autoPlayBtn.textContent = tf('auto_play');

    if (autoPlayDelayBtn) autoPlayDelayBtn.textContent = tf('btn_seconds', { seconds: Math.round(STATE.autoPlayDelay / 1000) });

    if (moreBtn) {
      moreBtn.classList.toggle('umr-active', STATE.moreOpen);
      moreBtn.textContent = tf('more');
    }

    if (modeBtn) modeBtn.textContent = STATE.mode === 'single' ? tf('mode_single') : tf('mode_double');
    if (autoNextBtn) {
      autoNextBtn.classList.toggle('umr-active', STATE.autoNextEnabled);
      autoNextBtn.textContent = tf('auto_next');
    }
    if (shakeBtn) {
      shakeBtn.classList.toggle('umr-active', STATE.shakePagingEnabled);
      shakeBtn.textContent = tf('shake_paging');
    }
    if (shakeSensitivityBtn) shakeSensitivityBtn.textContent = tf('shake_sensitivity', { level: STATE.shakeSensitivity });

    if (uiToggleBtn) {
      uiToggleBtn.textContent = STATE.overlay.classList.contains('umr-ui-hidden') ? tf('show_ui') : tf('hide_ui');
    }

    if (langBtn) {
      langBtn.textContent = `${tf('language')}: ${STATE.lang === 'en' ? tf('language_en') : tf('language_zh')}`;
    }

    const fullscreenBtn = STATE.overlay.querySelector('#umr-fullscreen');
    const closeBtn = STATE.overlay.querySelector('#umr-close');
    const uiUnlock = STATE.overlay.querySelector('#umr-ui-unlock');
    const helpToggle = STATE.overlay.querySelector('#umr-help-toggle');
    const autoOpenManager = STATE.overlay.querySelector('#umr-auto-open-manager');
    const ruleTitle = STATE.overlay.querySelector('#umr-rule-title');
    const ruleSave = STATE.overlay.querySelector('#umr-rule-save');
    const ruleCancel = STATE.overlay.querySelector('#umr-rule-cancel');
    const ruleDeleteCurrent = STATE.overlay.querySelector('#umr-rule-delete-current');
    const ruleTextarea = STATE.overlay.querySelector('#umr-rule-textarea');
    const help = STATE.overlay.querySelector('#umr-help');
    const ruleTip = STATE.overlay.querySelector('.umr-rule-tip');

    if (fullscreenBtn) fullscreenBtn.title = tf('btn_fullscreen_title');
    if (closeBtn) closeBtn.title = tf('btn_close_title');
    if (uiUnlock) uiUnlock.title = tf('btn_ui_unlock_title');
    if (helpToggle) helpToggle.textContent = tf('help');
    if (autoOpenManager) autoOpenManager.textContent = tf('auto_open_manager');
    if (ruleTitle) ruleTitle.textContent = tf('rule_dialog_title');
    if (ruleSave) ruleSave.textContent = tf('save');
    if (ruleCancel) ruleCancel.textContent = tf('cancel');
    if (ruleDeleteCurrent) ruleDeleteCurrent.textContent = tf('delete');
    if (ruleTextarea) ruleTextarea.placeholder = tf('rule_input_placeholder');
    if (help) help.innerHTML = tf('help_html');
    if (ruleTip) ruleTip.innerHTML = tf('rule_tip_html');
  }

  function updateProgress() {
    if (!STATE.overlay || !STATE.pages.length) return;

    const counter = STATE.overlay.querySelector('#umr-counter');
    const fill = STATE.overlay.querySelector('#umr-progress-fill');
    const handle = STATE.overlay.querySelector('#umr-progress-handle');

    const current = getCurrentVisibleLastPageIndex() + 1;
    const total = STATE.pages.length;
    const baseIndex = Math.min(STATE.index, total - 1);
    const ratio = total <= 1 ? 0 : baseIndex / (total - 1);
    const percent = ratio * 100;

    if (counter) counter.textContent = `${current} / ${total}`;
    if (fill) fill.style.width = `${percent}%`;
    if (handle) handle.style.left = `${percent}%`;
  }

  function renderRuleList() {
    if (!STATE.overlay) return;
    const listEl = STATE.overlay.querySelector('#umr-rule-list');
    if (!listEl) return;

    const rules = getAutoOpenRules();
    if (!rules.length) {
      listEl.innerHTML = `<div id="umr-rule-empty">${escapeHtml(tf('rule_empty'))}</div>`;
      return;
    }

    listEl.innerHTML = rules.map((rule, index) => `
      <div class="umr-rule-row">
        <div class="umr-rule-text">${escapeHtml(rule)}</div>
        <button class="umr-btn" data-rule-edit="${index}">${escapeHtml(tf('edit'))}</button>
        <button class="umr-btn umr-danger" data-rule-delete="${index}">${escapeHtml(tf('delete'))}</button>
      </div>
    `).join('');

    listEl.querySelectorAll('[data-rule-edit]').forEach(btn => {
      btn.addEventListener('click', () => startRuleEdit(Number(btn.getAttribute('data-rule-edit'))));
    });

    listEl.querySelectorAll('[data-rule-delete]').forEach(btn => {
      btn.addEventListener('click', () => deleteRule(Number(btn.getAttribute('data-rule-delete'))));
    });
  }

  function openRuleDialog() {
    if (!STATE.overlay) return;
    const backdrop = STATE.overlay.querySelector('#umr-rule-dialog-backdrop');
    if (!backdrop) return;
    STATE.ruleDialogOpen = true;
    backdrop.classList.add('show');
    renderRuleList();
    cancelRuleEdit();
  }

  function closeRuleDialog() {
    if (!STATE.overlay) return;
    const backdrop = STATE.overlay.querySelector('#umr-rule-dialog-backdrop');
    if (!backdrop) return;
    STATE.ruleDialogOpen = false;
    backdrop.classList.remove('show');
    cancelRuleEdit();
  }

  function startRuleEdit(index = -1) {
    if (!STATE.overlay) return;

    const editor = STATE.overlay.querySelector('#umr-rule-editor');
    const textarea = STATE.overlay.querySelector('#umr-rule-textarea');
    const deleteBtn = STATE.overlay.querySelector('#umr-rule-delete-current');

    if (!editor || !textarea || !deleteBtn) return;

    STATE.ruleEditIndex = index;

    if (index >= 0) {
      const rules = getAutoOpenRules();
      textarea.value = rules[index] || '';
      deleteBtn.style.display = 'inline-flex';
    } else {
      textarea.value = location.href;
      deleteBtn.style.display = 'none';
    }

    editor.classList.add('show');
    textarea.focus();
    textarea.select();
  }

  function cancelRuleEdit() {
    if (!STATE.overlay) return;
    const editor = STATE.overlay.querySelector('#umr-rule-editor');
    const textarea = STATE.overlay.querySelector('#umr-rule-textarea');
    if (!editor || !textarea) return;

    STATE.ruleEditIndex = -1;
    textarea.value = '';
    editor.classList.remove('show');
  }

  function saveRuleFromEditor() {
    if (!STATE.overlay) return;
    const textarea = STATE.overlay.querySelector('#umr-rule-textarea');
    if (!textarea) return;

    const value = textarea.value.trim();
    if (!value) {
      showToast(tf('toast_enter_rule'));
      return;
    }

    const rules = getAutoOpenRules();

    if (STATE.ruleEditIndex >= 0 && STATE.ruleEditIndex < rules.length) {
      rules[STATE.ruleEditIndex] = value;
    } else {
      rules.push(value);
    }

    saveAutoOpenRules(rules);
    renderRuleList();
    cancelRuleEdit();
    showToast(tf('toast_rule_saved'));
  }

  function deleteRule(index) {
    const rules = getAutoOpenRules();
    if (index < 0 || index >= rules.length) return;

    rules.splice(index, 1);
    saveAutoOpenRules(rules);
    renderRuleList();

    if (STATE.ruleEditIndex === index) {
      cancelRuleEdit();
    } else if (STATE.ruleEditIndex > index) {
      STATE.ruleEditIndex--;
    }

    showToast(tf('toast_rule_deleted'));
  }

  function deleteCurrentEditingRule() {
    if (STATE.ruleEditIndex < 0) return;
    deleteRule(STATE.ruleEditIndex);
  }

  function showToast(text, ms = 1800) {
    if (!STATE.overlay) return;
    const toast = STATE.overlay.querySelector('#umr-toast');
    if (!toast) return;
    toast.textContent = text;
    toast.classList.add('show');
    clearTimeout(showToast._timer);
    showToast._timer = setTimeout(() => {
      toast.classList.remove('show');
    }, ms);
  }

  function showLoader(text = tf('loader_processing')) {
    if (!STATE.overlay) return;
    const loader = STATE.overlay.querySelector('#umr-loader');
    const loaderText = STATE.overlay.querySelector('#umr-loader-text');
    if (loaderText) loaderText.textContent = text;
    if (loader) loader.style.display = 'flex';
  }

  function hideLoader() {
    if (!STATE.overlay) return;
    const loader = STATE.overlay.querySelector('#umr-loader');
    if (loader) loader.style.display = 'none';
  }

  function lockPageScroll() {
    if (STATE.scrollLocked) return;
    document.documentElement.style.overflow = 'hidden';
    document.body.style.overflow = 'hidden';
    STATE.scrollLocked = true;
  }

  function unlockPageScroll() {
    if (!STATE.scrollLocked) return;
    document.documentElement.style.overflow = '';
    document.body.style.overflow = '';
    STATE.scrollLocked = false;
  }

  async function requestWakeLock() {
    if (!STATE.wakeLockSupported || document.visibilityState !== 'visible') return;
    try {
      STATE.wakeLockSentinel = await navigator.wakeLock.request('screen');
    } catch {}
  }

  async function releaseWakeLock() {
    try {
      await STATE.wakeLockSentinel?.release();
    } catch {}
    STATE.wakeLockSentinel = null;
  }

  async function refreshWakeLockOnVisibility() {
    if (!STATE.isOpen) return;
    if (document.visibilityState === 'visible') {
      await requestWakeLock();
    } else {
      await releaseWakeLock();
    }
  }

  function extractUrlsFromSrcset(srcset) {
    if (!srcset) return [];
    return String(srcset)
      .split(',')
      .map(part => {
        const m = part.trim().match(/^(.+?)(?:\s+\d+(?:\.\d+)?[wx])?$/);
        return normalizeUrl(m ? m[1].trim() : '');
      })
      .filter(Boolean);
  }

  function extractUrlsFromStyleBackground(styleText) {
    const result = [];
    if (!styleText) return result;
    const regex = /url\((['"]?)(.*?)\1\)/ig;
    let m;
    while ((m = regex.exec(styleText))) {
      const url = normalizeUrl(m[2]);
      if (url) result.push(url);
    }
    return result;
  }

  function uniquePush(list, seen, url) {
    if (!url || seen.has(url)) return;
    seen.add(url);
    list.push(url);
  }

  function collectCandidateUrlsFromImg(img) {
    const urls = [];
    const attrs = [
      img.currentSrc,
      img.src,
      img.getAttribute('data-src'),
      img.getAttribute('data-original'),
      img.getAttribute('data-lazy-src'),
      img.getAttribute('data-url'),
      img.getAttribute('data-echo'),
      img.getAttribute('data-cfsrc'),
      img.getAttribute('data-pagespeed-lazy-src'),
      img.getAttribute('data-lazy'),
      img.getAttribute('data-image'),
      img.getAttribute('data-file'),
      img.getAttribute('data-thumb')
    ];

    attrs.forEach(v => {
      const u = normalizeUrl(v);
      if (u) urls.push(u);
    });

    extractUrlsFromSrcset(img.srcset || img.getAttribute('data-srcset') || '').forEach(u => urls.push(u));
    return urls;
  }

  function collectCandidateUrlsFromDocument() {
    const urls = [];
    const seen = new Set();

    document.querySelectorAll('img').forEach(img => {
      collectCandidateUrlsFromImg(img).forEach(u => uniquePush(urls, seen, u));
    });

    document.querySelectorAll('source').forEach(source => {
      extractUrlsFromSrcset(source.srcset || source.getAttribute('data-srcset') || '').forEach(u => uniquePush(urls, seen, u));
    });

    document.querySelectorAll('[style]').forEach(el => {
      const styleAttr = el.getAttribute('style') || '';
      extractUrlsFromStyleBackground(styleAttr).forEach(u => uniquePush(urls, seen, u));
    });

    Array.from(document.querySelectorAll('a[href]')).forEach(a => {
      const href = normalizeUrl(a.getAttribute('href'));
      if (/\.(?:jpg|jpeg|png|webp|gif|bmp|avif)(?:$|\?)/i.test(href)) {
        uniquePush(urls, seen, href);
      }
    });

    const htmlText = document.documentElement?.outerHTML || '';
    const regex = /(["'`])((?:https?:)?\/\/[^"'`\s<>]+?\.(?:jpg|jpeg|png|webp|gif|bmp|avif)(?:\?[^"'`\s<>]*)?|\/[^"'`\s<>]+?\.(?:jpg|jpeg|png|webp|gif|bmp|avif)(?:\?[^"'`\s<>]*)?)\1/ig;
    let match;
    while ((match = regex.exec(htmlText))) {
      const u = normalizeUrl(match[2]);
      uniquePush(urls, seen, u);
    }

    return urls.slice(0, STATE.config.proactiveMaxCandidates);
  }

  async function ensureLazyImagesVisible() {
    const lazySelectors = [
      'img[loading="lazy"]',
      'img[data-src]',
      'img[data-original]',
      'img[data-lazy-src]',
      'img[data-url]',
      'img[data-echo]',
      'img[data-cfsrc]',
      'img[data-pagespeed-lazy-src]',
      'img[data-lazy]',
      'img[data-image]',
      'img[data-file]',
      'source[data-srcset]'
    ];

    document.querySelectorAll(lazySelectors.join(',')).forEach(el => {
      if (el.tagName === 'IMG') {
        const img = el;
        const candidates = [
          img.getAttribute('data-src'),
          img.getAttribute('data-original'),
          img.getAttribute('data-lazy-src'),
          img.getAttribute('data-url'),
          img.getAttribute('data-echo'),
          img.getAttribute('data-cfsrc'),
          img.getAttribute('data-pagespeed-lazy-src'),
          img.getAttribute('data-lazy'),
          img.getAttribute('data-image'),
          img.getAttribute('data-file')
        ].filter(Boolean);

        if ((!img.src || img.src.startsWith('data:')) && candidates[0]) img.src = candidates[0];
        if (!img.srcset && img.dataset?.srcset) img.srcset = img.dataset.srcset;
        try {
          img.loading = 'eager';
          img.decoding = 'sync';
          img.fetchPriority = 'high';
        } catch {}
      } else if (el.tagName === 'SOURCE') {
        const srcset = el.getAttribute('data-srcset');
        if (srcset && !el.srcset) el.srcset = srcset;
      }
    });

    const allImgs = Array.from(document.images);
    allImgs.forEach(img => {
      try {
        img.loading = 'eager';
        img.decoding = 'sync';
        img.fetchPriority = 'high';
      } catch {}
    });

    const originalY = window.scrollY;
    const maxScroll = Math.max(
      document.documentElement.scrollHeight,
      document.body.scrollHeight
    );
    const step = Math.max(480, Math.floor(window.innerHeight * 0.9));

    for (let y = 0; y <= maxScroll; y += step) {
      window.scrollTo(0, y);
      await sleep(40);
    }

    window.scrollTo(0, maxScroll);
    await sleep(120);
    window.scrollTo(0, originalY);
    await sleep(80);
  }

  function getPageImageLoadSummary() {
    const images = Array.from(document.images);
    let loaded = 0;
    let largeLoaded = 0;

    images.forEach(img => {
      const w = img.naturalWidth || img.width || 0;
      const h = img.naturalHeight || img.height || 0;
      if (w > 0 && h > 0) loaded++;
      if (w >= STATE.config.minWidth && h >= STATE.config.minHeight) largeLoaded++;
    });

    return { total: images.length, loaded, largeLoaded };
  }

  async function warmImageUrl(url, timeoutMs = 9000) {
    if (!url) return null;

    if (STATE.proactiveMetaMap.has(url)) {
      const cached = STATE.proactiveMetaMap.get(url);
      if (cached && cached.ok) return cached;
    }

    return new Promise(resolve => {
      const img = new Image();
      let settled = false;

      const done = (payload) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve(payload);
      };

      img.decoding = 'async';
      img.referrerPolicy = 'no-referrer-when-downgrade';

      img.onload = () => {
        const meta = {
          src: url,
          width: img.naturalWidth || 0,
          height: img.naturalHeight || 0,
          ok: true,
          fromProbe: true
        };
        STATE.proactiveMetaMap.set(url, meta);
        done(meta);
      };

      img.onerror = () => {
        const meta = { src: url, width: 0, height: 0, ok: false, fromProbe: true };
        STATE.proactiveMetaMap.set(url, meta);
        done(meta);
      };

      const timer = setTimeout(() => {
        const meta = { src: url, width: 0, height: 0, ok: false, fromProbe: true, timeout: true };
        STATE.proactiveMetaMap.set(url, meta);
        done(meta);
      }, timeoutMs);

      img.src = url;
    });
  }

  async function proactiveDiscoverAndWarmImages() {
    STATE.proactiveDoneCount = 0;
    STATE.proactiveCandidates = collectCandidateUrlsFromDocument();

    const total = STATE.proactiveCandidates.length;
    if (!total) return;

    const queue = STATE.proactiveCandidates.slice(0);
    const workers = [];
    const concurrency = Math.min(STATE.config.proactiveConcurrency, total);

    for (let i = 0; i < concurrency; i++) {
      workers.push((async () => {
        while (queue.length) {
          const url = queue.shift();
          if (!url) break;
          await warmImageUrl(url);
          STATE.proactiveDoneCount++;
          if (STATE.overlay) {
            showLoader(tf('loader_detecting_progress', { done: STATE.proactiveDoneCount, total }));
          }
        }
      })());
    }

    await Promise.all(workers);
  }

  function scoreContainerHint(el) {
    if (!el) return 0;
    const text = [
      el.className || '',
      el.id || '',
      el.getAttribute?.('data-role') || '',
      el.getAttribute?.('role') || ''
    ].join(' ');
    return /manga|comic|chapter|viewer|reader|page|content|image|img|post/i.test(text) ? 12 : 0;
  }

  function smartCollectMangaPages() {
    const seen = new Set();
    const result = [];
    const viewportArea = Math.max(1, window.innerWidth * window.innerHeight);
    const minArea = Math.min(STATE.config.minArea, Math.floor(viewportArea * 0.22));

    const allImgs = Array.from(document.images);

    allImgs.forEach((img, idx) => {
      const urls = collectCandidateUrlsFromImg(img);
      const src = urls.find(Boolean);
      if (!src || seen.has(src)) return;

      const width = img.naturalWidth || img.width || 0;
      const height = img.naturalHeight || img.height || 0;
      if (!width || !height) return;

      const area = width * height;
      const ratio = width / Math.max(1, height);

      if (width < STATE.config.minWidth) return;
      if (height < STATE.config.minHeight) return;
      if (area < minArea) return;
      if (ratio < STATE.config.aspectMin || ratio > STATE.config.aspectMax) return;

      const rect = img.getBoundingClientRect();
      const score =
        Math.min(area / 100000, 100) +
        (rect.top >= -window.innerHeight && rect.top <= document.documentElement.clientHeight * 3 ? 8 : 0) +
        scoreContainerHint(img.closest('[class],[id]')) +
        (/page|comic|manga|chapter|content|viewer|reader/i.test(
          [
            img.className,
            img.id,
            img.alt,
            img.closest('[class],[id]')?.className || '',
            img.closest('[class],[id]')?.id || ''
          ].join(' ')
        ) ? 12 : 0);

      result.push({
        src,
        alt: img.alt || '',
        width,
        height,
        score,
        order: idx,
        from: 'dom'
      });
      seen.add(src);
    });

    STATE.proactiveCandidates.forEach((src, idx) => {
      if (!src || seen.has(src)) return;
      const meta = STATE.proactiveMetaMap.get(src);
      if (!meta || !meta.ok) return;

      const width = meta.width || 0;
      const height = meta.height || 0;
      if (!width || !height) return;

      const area = width * height;
      const ratio = width / Math.max(1, height);

      if (width < STATE.config.minWidth) return;
      if (height < STATE.config.minHeight) return;
      if (area < minArea) return;
      if (ratio < STATE.config.aspectMin || ratio > STATE.config.aspectMax) return;

      let score = Math.min(area / 100000, 90);
      if (/chapter|comic|manga|page|reader|viewer/i.test(src)) score += 8;
      if (/\.webp|\.jpg|\.jpeg|\.png/i.test(src)) score += 4;

      result.push({
        src,
        alt: '',
        width,
        height,
        score,
        order: 100000 + idx,
        from: 'probe'
      });
      seen.add(src);
    });

    result.sort((a, b) => a.order - b.order);

    return result.filter((page, i, arr) => {
      const prev = arr[i - 1];
      if (!prev) return true;
      return page.src !== prev.src;
    });
  }

  function preloadAround(index) {
    if (!STATE.pages.length) return;
    const max = Math.min(STATE.pages.length - 1, index + STATE.preloadCount);
    const min = Math.max(0, index - 2);

    for (let i = min; i <= max; i++) {
      const src = STATE.pages[i]?.src;
      if (!src) continue;
      const img = new Image();
      img.decoding = 'async';
      img.src = src;
    }
  }

  function findNextChapterCandidate() {
    const keywords = [
      '下一章', '下页', '下一頁', '下一话', '下一話', '下一回', '下一卷',
      'next', 'next chapter', 'next page', 'older', '继续', '繼續'
    ];

    const selectors = [
      'a[href]',
      'button',
      '[role="button"]',
      '[onclick]'
    ];

    const nodes = Array.from(document.querySelectorAll(selectors.join(',')));
    let best = null;
    let bestScore = -Infinity;

    nodes.forEach(node => {
      const text = (node.textContent || node.getAttribute('aria-label') || node.title || '').trim();
      const href = node.href || node.getAttribute('href') || '';

      const hay = `${text} ${href}`.toLowerCase();
      if (!keywords.some(k => hay.includes(k.toLowerCase()))) return;

      const rect = node.getBoundingClientRect();
      let score = 0;

      if (keywords.some(k => text.toLowerCase().includes(k.toLowerCase()))) score += 40;
      if (/chapter|chap|page|next|下/.test(href.toLowerCase())) score += 20;
      if (rect.right > window.innerWidth * 0.55) score += 8;
      if (rect.top > window.innerHeight * 0.35) score += 5;
      if (node.offsetParent) score += 3;

      if (score > bestScore) {
        bestScore = score;
        best = node;
      }
    });

    return best;
  }

  function jumpToNextChapter(target) {
    if (!target) return false;

    try {
      sessionStorage.setItem(AUTO_NEXT_FLAG, '1');
    } catch {}

    try {
      if (target.href) {
        location.href = target.href;
        return true;
      }
      target.click();
      return true;
    } catch {
      return false;
    }
  }

  function computeDoublePageWidths(renderList, stageRect) {
    const gap = 8;
    const sidePadding = 24;
    const usableHeight = Math.max(100, stageRect.height || window.innerHeight);
    const availableWidth = Math.max(160, (stageRect.width || window.innerWidth) - sidePadding - gap);

    const naturalWidths = renderList.map(page => {
      const pw = Math.max(1, Number(page?.width || 1));
      const ph = Math.max(1, Number(page?.height || 1));
      return Math.max(60, (pw / ph) * usableHeight);
    });

    const totalNatural = naturalWidths.reduce((a, b) => a + b, 0);
    const scale = totalNatural > availableWidth ? availableWidth / totalNatural : 1;

    return naturalWidths.map(w => Math.max(60, Math.floor(w * scale)));
  }

  function getVisiblePages() {
    if (STATE.mode === 'single') return [STATE.pages[STATE.index]].filter(Boolean);
    const first = STATE.pages[STATE.index];
    const second = STATE.pages[STATE.index + 1];
    return [first, second].filter(Boolean);
  }

  function classifyPageShape(page, forceDouble = false) {
    if (!page) return 'portrait';
    const ratio = (page.width || 1) / (page.height || 1);

    if (forceDouble) {
      if (ratio > 1.2) return 'landscape';
      if (ratio > 0.88) return 'square';
      return 'portrait';
    }

    if (ratio > 1.05) return 'landscape';
    if (ratio > 0.9) return 'square';
    return 'portrait';
  }

  function renderPages(direction = '') {
    if (!STATE.overlay || !STATE.pages.length) return;

    const book = STATE.overlay.querySelector('#umr-book');
    const stage = STATE.overlay.querySelector('#umr-stage');
    if (!book || !stage) return;

    book.innerHTML = '';
    const renderList = getVisiblePages();
    const isDouble = STATE.mode === 'double';
    const stageRect = stage.getBoundingClientRect();
    const doubleWidths = isDouble ? computeDoublePageWidths(renderList, stageRect) : [];

    book.classList.toggle('umr-book-double', isDouble);

    renderList.forEach((page, i) => {
      const shape = classifyPageShape(page, isDouble);

      const wrap = document.createElement('div');
      wrap.className = [
        'umr-pageWrap',
        isDouble ? 'double' : 'single',
        shape,
        direction ? `turn-${direction}` : ''
      ].filter(Boolean).join(' ');

      if (isDouble) {
        const targetWidth = doubleWidths[i] || 120;
        wrap.style.width = `${targetWidth}px`;
        wrap.style.maxWidth = `${targetWidth}px`;
        wrap.style.flex = `0 0 ${targetWidth}px`;
      } else {
        wrap.style.width = '';
        wrap.style.maxWidth = '';
        wrap.style.flex = '';
      }

      const img = document.createElement('img');
      img.className = 'umr-page';
      img.src = page.src;
      img.alt = page.alt || `Page ${STATE.index + i + 1}`;
      img.draggable = false;
      img.decoding = 'async';
      img.loading = 'eager';

      const meta = document.createElement('div');
      meta.className = 'umr-pageMeta';
      meta.textContent = `${page.width}×${page.height}`;

      wrap.appendChild(img);
      wrap.appendChild(meta);
      book.appendChild(wrap);
    });

    updateTopbar();
    updateControls();
    updateProgress();
    preloadAround(STATE.index);

    clearAutoNextTimer();
    scheduleAutoPlayIfNeeded();

    if (!STATE.autoPlayEnabled && isAtLastPage()) {
      scheduleAutoNextIfNeeded();
    }
  }

  function nextPage(fromAutoPlay = false) {
    if (!STATE.pages.length) return;
    clearAutoNextTimer();
    clearAutoPlayTimer();

    const step = getCurrentStep();
    const next = Math.min(STATE.pages.length - 1, STATE.index + step);

    if (next === STATE.index) {
      if (!fromAutoPlay) showToast(tf('toast_already_last'));
      scheduleAutoNextIfNeeded();
      return;
    }

    STATE.index = next;
    renderPages('next');
  }

  function prevPage() {
    if (!STATE.pages.length) return;
    clearAllAsyncTimers();
    const step = getCurrentStep();
    const prev = Math.max(0, STATE.index - step);
    if (prev === STATE.index) {
      showToast(tf('toast_already_first'));
      return;
    }
    STATE.index = prev;
    renderPages('prev');
  }

  function goToPage(index) {
    const safeIndex = Math.max(0, Math.min(STATE.pages.length - 1, index));
    if (safeIndex === STATE.index) return;
    const direction = safeIndex > STATE.index ? 'next' : 'prev';
    clearAllAsyncTimers();
    STATE.index = safeIndex;
    renderPages(direction);
  }

  function isAtLastPage() {
    if (!STATE.pages.length) return false;
    if (STATE.mode === 'double') return STATE.index >= STATE.pages.length - 2 || STATE.index >= STATE.pages.length - 1;
    return STATE.index >= STATE.pages.length - 1;
  }

  function getCurrentStep() {
    return STATE.mode === 'double' ? 2 : 1;
  }

  function getCurrentVisibleLastPageIndex() {
    if (!STATE.pages.length) return 0;
    if (STATE.mode === 'double') return Math.min(STATE.pages.length - 1, STATE.index + 1);
    return STATE.index;
  }

  function toggleMode() {
    clearAllAsyncTimers();
    STATE.mode = STATE.mode === 'single' ? 'double' : 'single';
    saveReaderState();
    renderPages();
    showToast(STATE.mode === 'single' ? tf('toast_mode_single') : tf('toast_mode_double'));
  }

  function toggleUI(force) {
    if (!STATE.overlay) return;

    if (typeof force === 'boolean') {
      STATE.overlay.classList.toggle('umr-ui-hidden', force);
    } else {
      STATE.overlay.classList.toggle('umr-ui-hidden');
    }

    saveReaderState();
    updateControls();
  }

  function toggleMore(force) {
    if (!STATE.overlay) return;
    const panel = STATE.overlay.querySelector('#umr-more-panel');
    if (!panel) return;

    STATE.moreOpen = typeof force === 'boolean' ? force : !STATE.moreOpen;
    panel.classList.toggle('show', STATE.moreOpen);
    updateControls();
  }

  function toggleAutoNext() {
    STATE.autoNextEnabled = !STATE.autoNextEnabled;
    saveReaderState();
    updateControls();
    clearAutoNextTimer();

    if (!STATE.autoNextEnabled) {
      showToast(tf('toast_auto_next_off'));
      return;
    }

    showToast(tf('toast_auto_next_on'));
    if (isAtLastPage()) scheduleAutoNextIfNeeded();
  }

  function toggleAutoPlay() {
    STATE.autoPlayEnabled = !STATE.autoPlayEnabled;

    if (STATE.autoPlayEnabled && STATE.shakePagingEnabled) {
      STATE.shakePagingEnabled = false;
      resetShakeDetector();
      showToast(tf('toast_shake_off_due_autoplay'));
    }

    saveReaderState();
    updateControls();
    updateProgress();
    clearAutoPlayTimer();
    clearAutoNextTimer();

    if (!STATE.autoPlayEnabled) {
      showToast(tf('toast_auto_play_off'));
      return;
    }

    showToast(tf('toast_auto_play_on', { seconds: Math.round(STATE.autoPlayDelay / 1000) }));
    scheduleAutoPlayIfNeeded();
  }

  function cycleAutoPlayDelay() {
    const options = [3000, 5000, 7000, 10000];
    const idx = options.indexOf(STATE.autoPlayDelay);
    STATE.autoPlayDelay = options[(idx + 1) % options.length];

    saveReaderState();
    updateControls();
    updateProgress();

    if (STATE.autoPlayEnabled) {
      clearAutoPlayTimer();
      scheduleAutoPlayIfNeeded();
    }
  }

  async function toggleShakePaging() {
    if (!STATE.shakePagingEnabled) {
      const granted = await requestMotionPermissionIfNeeded();
      if (!granted) {
        showToast(tf('toast_motion_permission_denied'));
        return;
      }

      bindShakeMotionIfNeeded();

      if (STATE.autoPlayEnabled) {
        STATE.autoPlayEnabled = false;
        clearAutoPlayTimer();
      }

      STATE.shakePagingEnabled = true;
      resetShakeDetector();

      saveReaderState();
      updateControls();
      updateProgress();
      showToast(tf('toast_shake_on', { level: STATE.shakeSensitivity }));
      return;
    }

    STATE.shakePagingEnabled = false;
    resetShakeDetector();
    saveReaderState();
    updateControls();
    showToast(tf('toast_shake_off'));
  }

  function cycleShakeSensitivity() {
    const idx = SHAKE_SENSITIVITY_LEVELS.indexOf(STATE.shakeSensitivity);
    STATE.shakeSensitivity = SHAKE_SENSITIVITY_LEVELS[(idx + 1) % SHAKE_SENSITIVITY_LEVELS.length];
    resetShakeDetector();
    saveReaderState();
    updateControls();
    showToast(tf('toast_shake_sensitivity', { level: STATE.shakeSensitivity }));
  }

  async function toggleFullscreen() {
    if (!STATE.overlay) return;
    try {
      if (!document.fullscreenElement) {
        await STATE.overlay.requestFullscreen();
      } else {
        await document.exitFullscreen();
      }
    } catch {
      showToast(tf('toast_fullscreen_fail'));
    }
  }

  function syncFullscreenState() {
    STATE.fullscreen = !!document.fullscreenElement;
    const btn = STATE.overlay?.querySelector('#umr-fullscreen');
    if (btn) btn.classList.toggle('umr-active', STATE.fullscreen);
  }

  function toggleLanguage() {
    STATE.lang = STATE.lang === 'en' ? 'zh' : 'en';
    saveLanguage(STATE.lang);
    saveReaderState();
    applyI18nToOverlay();
    renderRuleList();
    updateTopbar();
    updateControls();
    updateProgress();
    showToast(tf('toast_lang_switched', { lang: STATE.lang === 'en' ? tf('language_en') : tf('language_zh') }));
  }

  function applyI18nToOverlay() {
    if (!STATE.overlay) return;
    updateTopbar();
    updateControls();
    renderRuleList();
  }

  async function openReader() {
    createOverlay();
    if (!STATE.overlay) return;

    clearAllAsyncTimers();
    toggleMore(false);

    STATE.overlay.classList.remove('umr-hidden');
    STATE.isOpen = true;
    lockPageScroll();
    await requestWakeLock();

    showLoader(tf('loader_trigger_lazy'));
    await ensureLazyImagesVisible();

    const summary = getPageImageLoadSummary();
    console.log('[UMR] Initial image load summary:', summary);

    showLoader(tf('loader_detect_sources'));
    await proactiveDiscoverAndWarmImages();

    const finalSummary = getPageImageLoadSummary();
    console.log('[UMR] Final image load summary:', finalSummary, 'probe=', STATE.proactiveMetaMap.size);

    showLoader(tf('loader_analyzing_pages', {
      domCount: finalSummary.largeLoaded,
      probeCount: STATE.proactiveMetaMap.size
    }));
    STATE.pages = smartCollectMangaPages();

    if (!STATE.pages.length) {
      showLoader(tf('loader_no_pages'));
      showToast(tf('toast_not_found_pages'));
      return;
    }

    STATE.index = 0;
    loadReaderState();
    applyI18nToOverlay();
    bindShakeMotionIfNeeded();

    if (STATE.shakePagingEnabled) {
      resetShakeDetector();
    }

    hideLoader();
    renderPages();

    if (STATE.restoreUiHidden) {
      toggleUI(true);
    } else {
      toggleUI(false);
    }

    preloadAround(0);
    showToast(tf('toast_loaded_pages', { count: STATE.pages.length }));
  }

  async function closeReader() {
    saveReaderState();
    clearAllAsyncTimers();

    if (!STATE.overlay) return;

    STATE.isOpen = false;
    toggleMore(false);
    toggleUI(false);
    closeRuleDialog();
    STATE.overlay.classList.add('umr-hidden');
    unlockPageScroll();
    await releaseWakeLock();

    const help = STATE.overlay.querySelector('#umr-help');
    if (help) help.classList.remove('show');

    if (document.fullscreenElement) {
      document.exitFullscreen().catch(() => {});
    }
  }

  function createOverlay() {
    if (STATE.overlay) return STATE.overlay;

    const overlay = document.createElement('div');
    overlay.id = 'umr-overlay';
    overlay.className = 'umr-hidden';

    overlay.innerHTML = `
      <div id="umr-topbar">
        <div class="umr-title" id="umr-title">${escapeHtml(tf('app_title'))}</div>
      </div>

      <div id="umr-stage">
        <div class="umr-nav-zone umr-nav-left"></div>
        <div class="umr-nav-center"></div>
        <div class="umr-nav-zone umr-nav-right"></div>

        <button id="umr-ui-unlock" class="umr-icon-btn" title="${escapeHtml(tf('btn_ui_unlock_title'))}">✕</button>

        <div id="umr-book"></div>
        <div id="umr-toast"></div>

        <div id="umr-loader">
          <div class="umr-spinner"></div>
          <div id="umr-loader-text">${escapeHtml(tf('loader_analyzing_current_page'))}</div>
        </div>

        <div id="umr-rule-dialog-backdrop">
          <div id="umr-rule-dialog">
            <div id="umr-rule-toolbar">
              <div id="umr-rule-title">${escapeHtml(tf('rule_dialog_title'))}</div>
              <button class="umr-btn" id="umr-rule-add">+</button>
              <button class="umr-btn umr-icon-btn" id="umr-rule-close" title="${escapeHtml(tf('close'))}">✕</button>
            </div>

            <div id="umr-rule-body">
              <div class="umr-rule-tip">${tf('rule_tip_html')}</div>

              <div id="umr-rule-list"></div>

              <div id="umr-rule-editor">
                <textarea id="umr-rule-textarea" spellcheck="false" placeholder="${escapeHtml(tf('rule_input_placeholder'))}"></textarea>
                <div class="umr-rule-editor-actions">
                  <button class="umr-btn" id="umr-rule-save">${escapeHtml(tf('save'))}</button>
                  <button class="umr-btn" id="umr-rule-cancel">${escapeHtml(tf('cancel'))}</button>
                  <button class="umr-btn umr-danger" id="umr-rule-delete-current">${escapeHtml(tf('delete'))}</button>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>

      <div id="umr-bottombar">
        <div id="umr-more-panel">
          <div id="umr-help">${tf('help_html')}</div>

          <div id="umr-more-grid">
            <button class="umr-btn umr-more-item" id="umr-mode">${escapeHtml(tf('mode_single'))}</button>
            <button class="umr-btn umr-more-item umr-active" id="umr-auto-next">${escapeHtml(tf('auto_next'))}</button>
            <button class="umr-btn umr-more-item" id="umr-shake-toggle">${escapeHtml(tf('shake_paging'))}</button>
            <button class="umr-btn umr-more-item" id="umr-shake-sensitivity">${escapeHtml(tf('shake_sensitivity', { level: 3 }))}</button>
            <button class="umr-btn umr-more-item" id="umr-auto-open-manager">${escapeHtml(tf('auto_open_manager'))}</button>
            <button class="umr-btn umr-more-item" id="umr-language-toggle">${escapeHtml(tf('language'))}: ${escapeHtml(tf('language_en'))}</button>
            <button class="umr-btn umr-more-item" id="umr-help-toggle">${escapeHtml(tf('help'))}</button>
          </div>
        </div>

        <div id="umr-control-row">
          <button class="umr-btn" id="umr-auto-play">${escapeHtml(tf('auto_play'))}</button>
          <button class="umr-btn" id="umr-auto-play-delay">${escapeHtml(tf('btn_seconds', { seconds: 3 }))}</button>
          <button class="umr-btn" id="umr-ui-toggle">${escapeHtml(tf('hide_ui'))}</button>
          <button class="umr-btn umr-icon-btn" id="umr-fullscreen" title="${escapeHtml(tf('btn_fullscreen_title'))}">⛶</button>
          <button class="umr-btn umr-icon-btn" id="umr-close" title="${escapeHtml(tf('btn_close_title'))}">✕</button>
          <button class="umr-btn" id="umr-more-toggle">${escapeHtml(tf('more'))}</button>
        </div>

        <div id="umr-progress-row">
          <div id="umr-progress">
            <div id="umr-progress-fill"></div>
            <div id="umr-progress-handle"></div>
          </div>
          <div id="umr-counter">0 / 0</div>
        </div>
      </div>
    `;

    document.body.appendChild(overlay);
    STATE.overlay = overlay;
    bindOverlayEvents();
    applyI18nToOverlay();
    return overlay;
  }

  function bindOverlayEvents() {
    const overlay = document.getElementById('umr-overlay');
    if (!overlay) return;

    const stage = overlay.querySelector('#umr-stage');

    overlay.querySelector('#umr-close').addEventListener('click', closeReader);
    overlay.querySelector('#umr-auto-play').addEventListener('click', toggleAutoPlay);
    overlay.querySelector('#umr-auto-play-delay').addEventListener('click', cycleAutoPlayDelay);
    overlay.querySelector('#umr-auto-next').addEventListener('click', toggleAutoNext);
    overlay.querySelector('#umr-mode').addEventListener('click', toggleMode);
    overlay.querySelector('#umr-shake-toggle').addEventListener('click', toggleShakePaging);
    overlay.querySelector('#umr-shake-sensitivity').addEventListener('click', cycleShakeSensitivity);
    overlay.querySelector('#umr-auto-open-manager').addEventListener('click', openRuleDialog);
    overlay.querySelector('#umr-language-toggle').addEventListener('click', toggleLanguage);
    overlay.querySelector('#umr-fullscreen').addEventListener('click', toggleFullscreen);
    overlay.querySelector('#umr-ui-toggle').addEventListener('click', toggleUI);
    overlay.querySelector('#umr-more-toggle').addEventListener('click', () => toggleMore());
    overlay.querySelector('#umr-help-toggle').addEventListener('click', () => {
      overlay.querySelector('#umr-help').classList.toggle('show');
    });

    overlay.querySelector('#umr-ui-unlock').addEventListener('click', (e) => {
      e.stopPropagation();
      if (overlay.classList.contains('umr-ui-hidden')) {
        toggleUI(false);
      }
    });

    overlay.querySelector('#umr-rule-close').addEventListener('click', closeRuleDialog);
    overlay.querySelector('#umr-rule-add').addEventListener('click', () => startRuleEdit(-1));
    overlay.querySelector('#umr-rule-save').addEventListener('click', saveRuleFromEditor);
    overlay.querySelector('#umr-rule-cancel').addEventListener('click', cancelRuleEdit);
    overlay.querySelector('#umr-rule-delete-current').addEventListener('click', deleteCurrentEditingRule);
    overlay.querySelector('#umr-rule-dialog-backdrop').addEventListener('click', (e) => {
      if (e.target === overlay.querySelector('#umr-rule-dialog-backdrop')) {
        closeRuleDialog();
      }
    });

    overlay.querySelector('.umr-nav-left').addEventListener('click', () => prevPage());
    overlay.querySelector('.umr-nav-right').addEventListener('click', () => nextPage());
    overlay.querySelector('.umr-nav-center').addEventListener('click', () => toggleUI());

    const progress = overlay.querySelector('#umr-progress');
    progress.addEventListener('click', (e) => {
      if (!STATE.pages.length) return;
      const rect = progress.getBoundingClientRect();
      const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
      const target = Math.round(ratio * Math.max(STATE.pages.length - 1, 0));
      goToPage(target);
    });

    stage.addEventListener('touchstart', (e) => {
      if (!e.touches[0]) return;
      STATE.touchStartX = e.touches[0].clientX;
      STATE.touchStartY = e.touches[0].clientY;
      STATE.touchMoved = false;
    }, { passive: true });

    stage.addEventListener('touchmove', (e) => {
      if (!e.touches[0]) return;
      const dx = e.touches[0].clientX - STATE.touchStartX;
      const dy = e.touches[0].clientY - STATE.touchStartY;
      if (Math.abs(dx) > 12 || Math.abs(dy) > 12) STATE.touchMoved = true;
    }, { passive: true });

    stage.addEventListener('touchend', (e) => {
      const touch = e.changedTouches[0];
      if (!touch) return;

      const dx = touch.clientX - STATE.touchStartX;
      const dy = touch.clientY - STATE.touchStartY;

      if (!STATE.touchMoved) {
        const width = window.innerWidth;
        if (touch.clientX < width * 0.30) prevPage();
        else if (touch.clientX > width * 0.70) nextPage();
        else toggleUI();
        return;
      }

      if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
        if (dx < 0) nextPage();
        else prevPage();
      }
    }, { passive: true });

    if (!STATE.keyHandlerBound) {
      document.addEventListener('keydown', onKeydown, true);
      STATE.keyHandlerBound = true;
    }

    if (!STATE.resizeHandlerBound) {
      window.addEventListener('resize', debounce(() => {
        if (STATE.isOpen) renderPages();
      }, 80));
      STATE.resizeHandlerBound = true;
    }

    if (!STATE.fullscreenHandlerBound) {
      document.addEventListener('fullscreenchange', syncFullscreenState, true);
      STATE.fullscreenHandlerBound = true;
    }

    if (!STATE.visibilityHandlerBound) {
      document.addEventListener('visibilitychange', refreshWakeLockOnVisibility, true);
      STATE.visibilityHandlerBound = true;
    }
  }

  function onKeydown(e) {
    if (!STATE.isOpen) return;
    const key = String(e.key || '').toLowerCase();

    if (key === 'arrowright' || key === 'd' || key === ' ') {
      e.preventDefault();
      nextPage();
    } else if (key === 'arrowleft' || key === 'a') {
      e.preventDefault();
      prevPage();
    } else if (key === 'escape') {
      if (STATE.ruleDialogOpen) {
        e.preventDefault();
        closeRuleDialog();
        return;
      }
      e.preventDefault();
      closeReader();
    } else if (key === 'f') {
      e.preventDefault();
      toggleFullscreen();
    } else if (key === 'm') {
      e.preventDefault();
      toggleMode();
    } else if (key === 'u') {
      e.preventDefault();
      toggleUI();
    } else if (key === 'n') {
      e.preventDefault();
      toggleAutoNext();
    } else if (key === 'p') {
      e.preventDefault();
      toggleAutoPlay();
    } else if (key === 't') {
      e.preventDefault();
      cycleAutoPlayDelay();
    } else if (key === 'l') {
      e.preventDefault();
      toggleLanguage();
    }
  }

  async function bootAutoOpenIfNeeded() {
    let fromNext = false;
    try {
      fromNext = sessionStorage.getItem(AUTO_NEXT_FLAG) === '1';
      if (fromNext) sessionStorage.removeItem(AUTO_NEXT_FLAG);
    } catch {}

    const fromRule = matchesAutoOpenRule(location.href);
    if (!fromNext && !fromRule) return;

    await sleep(700);
    await openReader();
  }

  bootAutoOpenIfNeeded();
})();