☰

Better Ehentai Interface

Fit manga images to the window, fit width, or adjust reading width with a slider. In-page navigation, preloading and collapsible controls. English / Chinese.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==UserScript==
// @name         Better Ehentai Interface
// @name:en      Better Ehentai Interface
// @name:zh-CN   更好的Ehentai界面
// @namespace    local.comic-fit-screen
// @version      3.5.1
// @description  Fit manga images to the window, fit width, or adjust reading width with a slider. In-page navigation, preloading and collapsible controls. English / Chinese.
// @description:zh-CN 漫画图片自动贴合窗口,支持整页贴合、适合宽度和滑杆调节大小;附页内翻页、预加载及可收起工具栏,支持中文 / English。
// @license      MIT
// @match        *://*.e-hentai.org/s/*
// @match        *://*.e-hentai.org/mpv/*
// @match        *://*.exhentai.org/s/*
// @match        *://*.exhentai.org/mpv/*
// @match        *://*.e-hentai.com/s/*
// @match        *://*.e-hentai.com/mpv/*
// @match        *://*.exhentai.com/s/*
// @match        *://*.exhentai.com/mpv/*
// @exclude      *://nhentai.net/*
// @exclude      *://*.nhentai.net/*
// @run-at       document-end
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        unsafeWindow
// ==/UserScript==

(() => {
  'use strict';

  const isEH = /(^|\.)(?:e-hentai|exhentai)\.(?:org|com)$/i.test(location.hostname);
  const adapter = isEH && /^\/s\//.test(location.pathname) ? 'single'
    : isEH && /^\/mpv\//.test(location.pathname) ? 'mpv' : 'generic';
  // 阅读器只作用于 EH/EX 的阅读页。
  if (adapter === 'generic' || !/^https?:$/.test(location.protocol)) return;
  const key = `comic-fit-screen:v1:${location.origin}:${adapter}`;
  const languageKey = 'comic-fit-screen:language';
  const savedLanguage = GM_getValue(languageKey, '');
  let language = savedLanguage === 'en' || savedLanguage === 'zh-CN' ? savedLanguage
    : /^zh\b/i.test(navigator.language || '') ? 'zh-CN' : 'en';
  const translations = {
    'zh-CN': {
      language: '语言', settings: '设置', readingSettings: '阅读设置', controls: '阅读控制',
      display: '显示方式', fitPage: '整页贴合', fitWidth: '适合宽度', custom: '手动宽度', zoom: '显示宽度', file: '文件信息', focus: '黑底阅读',
      enlarge: '小图放大', margin: '留边(px)', inline: '页内翻页', preload: '预加载(页)',
      cache: '预加载进度', leftClick: '左侧点击返回', pick: '选图', disable: '关闭贴屏', enable: '开启贴屏',
      previous: '上一页', next: '下一页', page: '页码', jump: '跳转', details: '详情',
      first: '第一页', last: '最后一页', collapse: '收起工具栏', restore: '展开工具栏',
      menu: '阅读菜单', position: '工具栏位置', bottomRight: '右下角', bottomCenter: '底部居中',
      fullscreen: '全屏', exitFullscreen: '退出全屏', failed: '失败',
      menuToggle: '贴屏:开启 / 关闭(Alt+Shift+M)', menuSettings: '阅读器:设置',
      menuPick: '贴屏:重选漫画区域(其他网站)', menuClear: '贴屏:清除本站设置',
      cancelled: '已取消', readerUnavailable: '阅读页不可用', noImageURL: '没有图片地址',
      imageURLUnavailable: '图片地址不可用', requestFailed: '请求失败({status})',
      noGallery: '没有详情页入口', pageListUnavailable: '页码列表不可用', galleryTimeout: '详情页加载超时',
      pageNotFound: '未找到指定页码', imageFailed: '图片加载失败', imageUnavailable: '图片不可用',
      mpvUnavailable: 'MPV 加载接口不可用', mpvFailed: 'MPV 加载失败', pageMismatch: '页码不匹配',
      timeout: '加载超时', areaUnavailable: '阅读区域不存在', pageUnavailable: '页码不可用',
      loadFailed: '加载失败', imageNotLoaded: '图片尚未加载', picker: '点击正文图片 · Esc 取消',
      fullscreenUnavailable: '全屏不可用,可按 F11', pageOutOfRange: '页码超出范围', cleared: '设置已清除'
    },
    en: {
      language: 'Language', settings: 'Settings', readingSettings: 'Reader settings', controls: 'Reader controls',
      display: 'Display', fitPage: 'Fit page', fitWidth: 'Fit width', custom: 'Manual width', zoom: 'Display width', file: 'File information', focus: 'Black background',
      enlarge: 'Upscale small images', margin: 'Margin (px)', inline: 'In-page navigation', preload: 'Preload (pages)',
      cache: 'Preload progress', leftClick: 'Click left for previous', pick: 'Pick image', disable: 'Disable fit', enable: 'Enable fit',
      previous: 'Previous page', next: 'Next page', page: 'Page number', jump: 'Jump', details: 'Gallery',
      first: 'First page', last: 'Last page', collapse: 'Collapse toolbar', restore: 'Expand toolbar',
      menu: 'Reader menu', position: 'Toolbar position', bottomRight: 'Bottom right', bottomCenter: 'Bottom center',
      fullscreen: 'Fullscreen', exitFullscreen: 'Exit fullscreen', failed: 'Failed',
      menuToggle: 'Fit: on / off (Alt+Shift+M)', menuSettings: 'Reader: settings',
      menuPick: 'Fit: select another image area', menuClear: 'Fit: clear site settings',
      cancelled: 'Cancelled', readerUnavailable: 'Reader page unavailable', noImageURL: 'Image URL missing',
      imageURLUnavailable: 'Image URL unavailable', requestFailed: 'Request failed ({status})',
      noGallery: 'Gallery link missing', pageListUnavailable: 'Page list unavailable', galleryTimeout: 'Gallery request timed out',
      pageNotFound: 'Page not found', imageFailed: 'Image failed to load', imageUnavailable: 'Image unavailable',
      mpvUnavailable: 'MPV loading API unavailable', mpvFailed: 'MPV loading failed', pageMismatch: 'Page number mismatch',
      timeout: 'Loading timed out', areaUnavailable: 'Reader area missing', pageUnavailable: 'Page unavailable',
      loadFailed: 'Loading failed', imageNotLoaded: 'Image has not loaded', picker: 'Click a page image · Esc to cancel',
      fullscreenUnavailable: 'Fullscreen unavailable. Try F11', pageOutOfRange: 'Page number out of range', cleared: 'Settings cleared'
    }
  };
  const t = (name, values = {}) => (translations[language][name] || translations.en[name] || name)
    .replace(/\{(\w+)\}/g, (_, token) => String(values[token] ?? ''));
  const readerError = (name, values = {}) => Object.assign(new Error(t(name, values)), { translationKey: name, values });
  const defaults = {
    enabled: adapter !== 'generic', mode: 'page', focus: true,
    enlarge: true, margin: 8, selector: '', inline: true, preload: 3, leftClick: true, zoom: 65,
    toolbarCollapsed: false, toolbarPosition: 'right'
  };
  const stored = GM_getValue(key, {});
  // 只使用阅读器设置;保存时仍保留存储中的旧动图配置。
  const config = Object.fromEntries(Object.entries(defaults).map(([name, fallback]) => [name, stored && typeof stored === 'object' && name in stored ? stored[name] : fallback]));
  config.mode = ['width', 'custom'].includes(config.mode) ? config.mode : 'page';
  config.zoom = Number.isFinite(Number(config.zoom)) ? Math.min(100, Math.max(10, Number(config.zoom))) : 65;
  config.margin = Number.isFinite(Number(config.margin)) ? Math.min(80, Math.max(0, Number(config.margin))) : 8;
  config.selector = typeof config.selector === 'string' ? config.selector : '';
  config.preload = Number.isFinite(Number(config.preload)) ? Math.min(20, Math.max(0, Math.trunc(Number(config.preload)))) : 3;
  config.toolbarCollapsed = config.toolbarCollapsed === true;
  config.toolbarPosition = config.toolbarPosition === 'center' ? 'center' : 'right';
  const owned = new Map();
  const ownedWrappers = new Set();
  const classPrefix = 'comic-fit-screen-';
  const addedClasses = new Set();
  let ui, observer, resizeObserver, timer = 0, picking = false, hovering = null;
  let hoveredOutline = null, ownsFullscreen = false, lastImages = [];
  let message = '', messageValues = {}, messageUntil = 0, uiLanguage = '', menuIds = [];
  let reader, saveTimer = 0, transitionCanvas = null, transitionCleanup = null;
  const isWebP = source => /\.webp(?:$|[?#;])|[-/]wbp(?:[/?;]|$)/i.test(source || '');

  // 翻页与预加载仅作用于当前画廊;通用模式继续使用原网站的加载机制。
  const css = document.createElement('style');
  css.textContent = `
    html.${classPrefix}single body { margin:0 !important; overflow:hidden !important; }
    html.${classPrefix}single #i1 {
      position:fixed !important; inset:0 !important; z-index:2147483000 !important;
      box-sizing:border-box !important; width:100% !important; max-width:none !important;
      height:100% !important; min-height:0 !important; margin:0 !important;
      padding:0 !important; border:0 !important; background:#000 !important;
      display:flex !important; flex-direction:column !important;
    }
    html.${classPrefix}single #i1 > h1,
    html.${classPrefix}single #i1 > :not(#i3) { display:none !important; }
    html.${classPrefix}single #i2, html.${classPrefix}single #i4 {
      box-sizing:border-box !important; flex:0 0 auto !important; width:100% !important;
      min-width:0 !important; margin:0 !important; padding:3px 0 !important;
      color:#ddd !important; background:#111 !important;
    }
    html.${classPrefix}single #i3 {
      box-sizing:border-box !important; display:flex !important; flex:1 1 0 !important;
      min-height:0 !important; min-width:0 !important; width:100% !important; height:auto !important;
      position:relative !important; align-items:safe center !important; justify-content:safe center !important;
      overflow:auto !important; padding:0 !important; margin:0 !important; background:#000 !important;
    }
    html.${classPrefix}single #i3 > a, html.${classPrefix}single #i3 > picture {
      display:block !important; flex:0 0 auto !important; margin:0 !important;
      padding:0 !important; line-height:0 !important;
    }
    html.${classPrefix}single #i3 .${classPrefix}transition {
      position:relative !important; isolation:isolate !important; overflow:hidden !important;
    }
    html.${classPrefix}single #i3 .${classPrefix}transition > img {
      position:relative !important; z-index:1 !important;
    }
    html.${classPrefix}single.${classPrefix}width #i3 { align-items:safe center !important; }
    html.${classPrefix}single #i1 { background:inherit !important; }
    html.${classPrefix}single #i3 { background:transparent !important; }
    html.${classPrefix}single.${classPrefix}black body,
    html.${classPrefix}single.${classPrefix}black #i1,
    html.${classPrefix}single.${classPrefix}black #i3 { background:#000 !important; }
    html.${classPrefix}mpv #pane_images { overflow-x:hidden !important; }
    html.${classPrefix}mpv #pane_images_inner { width:100% !important; max-width:none !important; }
    html.${classPrefix}mpv #pane_images .mimg.${classPrefix}fitted,
    html.${classPrefix}mpv #pane_images_inner > .${classPrefix}fitted {
      display:block !important; width:auto !important; height:auto !important;
      min-width:0 !important; min-height:0 !important; max-width:none !important;
      max-height:none !important; margin:0 auto 8px !important; text-align:center !important;
    }
    html.${classPrefix}black #pane_images { background:#000 !important; }
  `;

  function createReader() {
    const bridge = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
    const urls = new Map(), cache = new Map(), galleryJobs = new Map();
    const state = { current: 1, total: 0, requested: 0, loading: false, readyAhead: 0, preloadError: false };
    let initialized = false, galleryURL = '', bookId = '', thumbnailCount = 0;
    let wave = 0, signature = '', sequence = 0, stopped = false, historyReady = false, scrollFrame = 0;
    let hostCache = null;
    const galleryControllers = new Set();
    const abortError = () => new DOMException(t('cancelled'), 'AbortError');
    const changed = () => syncUI();

    function pageURL(value, base = location.href) {
      try {
        const url = new URL(value, base);
        const match = /^\/s\/([a-z0-9]+)\/(\d+)-(\d+)\/?$/i.exec(url.pathname);
        const page = Number(match?.[3]);
        return url.origin === location.origin && match && Number.isSafeInteger(page) && page > 0
          ? { url: url.href, gid: match[2], page } : null;
      } catch { return null; }
    }

    function collectURLs(root, base) {
      const found = [];
      for (const link of root.querySelectorAll('a[href]')) {
        const info = pageURL(link.getAttribute('href'), base);
        if (info && info.gid === bookId) { urls.set(info.page, info.url); found.push(info.page); }
        if (!galleryURL) {
          try {
            const url = new URL(link.getAttribute('href'), base);
            const match = /^\/g\/(\d+)\/([a-z0-9]+)\/?$/i.exec(url.pathname);
            if (url.origin === location.origin && match?.[1] === bookId) galleryURL = url.href;
          } catch {}
        }
      }
      return [...new Set(found)].sort((a, b) => a - b);
    }

    function fileInformation(root) {
      const node = root.querySelector('#i2');
      if (!node) return '';
      const copy = node.cloneNode(true);
      for (const controls of copy.querySelectorAll('.sn,script,style')) controls.remove();
      return copy.textContent.replace(/\s+/g, ' ').trim();
    }

    function pageTotal(root, current) {
      // 保留节点间的分隔,避免页数与相邻脚本或控件里的数字被拼成一个大数。
      const text = node => node.nodeType === 3 ? node.textContent
        : node.nodeType !== 1 || node.matches('a,script,style,input,select') ? ''
        : [...node.childNodes].map(text).join(' ');
      let total = 0;
      for (const node of root.querySelectorAll('#i2 .sn, #i4 .sn')) {
        const counter = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(text(node));
        const page = Number(counter?.[1].replace(/,/g, ''));
        const count = Number(counter?.[2].replace(/,/g, ''));
        if (page === current && Number.isSafeInteger(count) && count >= current) total = Math.max(total, count);
      }
      return total;
    }

    function parsePage(root, url) {
      const info = pageURL(url);
      const image = root.querySelector('#i3 #img, #i3 a > img');
      if (!info || info.gid !== bookId || !image) throw readerError('readerUnavailable');
      const source = image.getAttribute('src');
      if (!source) throw readerError('noImageURL');
      const imageURL = new URL(source, url).href;
      if (!/^(?:https?:|data:image\/)/i.test(imageURL)) throw readerError('imageURLUnavailable');
      const pages = collectURLs(root, url);
      state.total = Math.max(state.total, info.page, ...pages, pageTotal(root, info.page));
      const sections = {};
      for (const id of ['i2', 'i4', 'i5', 'i6', 'i7']) {
        const node = root.querySelector('#' + id);
        if (node) sections[id] = node.innerHTML;
      }
      const fileInfo = fileInformation(root);
      const size = /(\d[\d,]*)\s*[x×]\s*(\d[\d,]*)/i.exec(fileInfo);
      return { page: info.page, url, imageURL, sections, fileInfo, title: root.querySelector('title')?.textContent || document.title,
        width: Number(image.getAttribute('width')) || Number(size?.[1].replace(/,/g, '')) || 0,
        height: Number(image.getAttribute('height')) || Number(size?.[2].replace(/,/g, '')) || 0,
        referrerPolicy: image.getAttribute('referrerpolicy') || '' };
    }

    function inertHTML(html) {
      // 模板里的图片和脚本保持惰性,不因解析预加载页面而下载广告或执行脚本。
      const template = document.createElement('template');
      template.innerHTML = html;
      return template.content;
    }

    async function fetchHTML(url, signal) {
      const response = await fetch(url, { credentials: 'same-origin', redirect: 'error', signal,
        headers: { Accept: 'text/html' } });
      if (!response.ok) throw readerError('requestFailed', { status: response.status });
      return inertHTML(await response.text());
    }

    async function galleryPage(index) {
      if (!galleryURL) throw readerError('noGallery');
      if (galleryJobs.has(index)) return galleryJobs.get(index);
      const url = new URL(galleryURL); url.searchParams.set('p', String(index));
      const controller = new AbortController(); galleryControllers.add(controller);
      let timedOut = false;
      const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 25000);
      const job = fetchHTML(url.href, controller.signal).then(root => {
        const pages = collectURLs(root, url.href);
        if (!pages.length) throw readerError('pageListUnavailable');
        if (index === 0) thumbnailCount = Math.max(...pages);
        return pages;
      }).catch(error => { galleryJobs.delete(index); throw timedOut ? readerError('galleryTimeout') : error; }).finally(() => {
        clearTimeout(timeout); galleryControllers.delete(controller);
      });
      galleryJobs.set(index, job);
      return job;
    }

    async function resolveURL(page, signal) {
      if (urls.has(page)) return urls.get(page);
      await galleryPage(0);
      if (signal.aborted) throw abortError();
      if (!urls.has(page) && thumbnailCount) await galleryPage(Math.floor((page - 1) / thumbnailCount));
      if (signal.aborted) throw abortError();
      if (!urls.has(page)) throw readerError('pageNotFound');
      return urls.get(page);
    }

    function warmImage(data, signal, onImage) {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.decoding = 'async'; img.referrerPolicy = data.referrerPolicy;
        if (data.width && data.height) { img.setAttribute('width', data.width); img.setAttribute('height', data.height); }
        onImage?.(img);
        let settled = false;
        const cleanup = () => { img.onload = null; img.onerror = null; signal.removeEventListener('abort', cancel); };
        const fail = error => { if (settled) return; settled = true; cleanup(); if (!img.isConnected) img.removeAttribute('src'); reject(error); };
        const cancel = () => fail(abortError());
        img.onerror = () => fail(readerError('imageFailed'));
        img.onload = async () => {
          if (img.naturalWidth < 160 || img.naturalHeight < 160) { fail(readerError('imageUnavailable')); return; }
          // 原生动图不等待 decode(),避免把完整动画的解码放在翻页之前。
          if (!isWebP(data.imageURL)) { try { await img.decode?.(); } catch {} }
          if (signal.aborted) { cancel(); return; }
          if (!settled) { settled = true; cleanup(); resolve(img); }
        };
        signal.addEventListener('abort', cancel, { once: true });
        if (signal.aborted) cancel(); else img.src = data.imageURL;
      });
    }

    function mpvHosts() {
      if (hostCache) return hostCache;
      const pane = document.querySelector('#pane_images');
      if (!pane) return [];
      const hosts = [...pane.querySelectorAll('.mimg')];
      return hostCache = hosts.length ? hosts : [...pane.querySelectorAll('#pane_images_inner > div')];
    }

    function mpvImage(page) {
      return document.getElementById(`imgsrc_${page}`) || mpvHosts()[page - 1]?.querySelector('img');
    }

    function loaded(img) { return img?.complete && img.naturalWidth >= 160 && img.naturalHeight >= 160; }

    function loadMpv(page, signal) {
      return new Promise((resolve, reject) => {
        let settled = false, checking = false;
        const pane = document.querySelector('#pane_images'), host = mpvHosts()[page - 1];
        if (!host || !pane) { reject(readerError('pageUnavailable')); return; }
        const watcher = new MutationObserver(records => {
          if (records.some(item => item.type === 'childList')) hostCache = null;
          inspect();
        });
        const onLoad = event => { if (event.target === mpvImage(page)) inspect(); };
        const failed = event => { if (event.target === mpvImage(page)) finish(readerError('imageFailed')); };
        const cleanup = () => {
          watcher.disconnect(); signal.removeEventListener('abort', cancel);
          pane.removeEventListener('load', onLoad, true); pane.removeEventListener('error', failed, true);
        };
        const finish = (error, image) => { if (settled) return; settled = true; cleanup(); error ? reject(error) : resolve(image); };
        const cancel = () => finish(abortError());
        const inspect = async () => {
          if (settled || checking) return;
          const img = mpvImage(page);
          if (loaded(img)) {
            checking = true;
            if (!isWebP(img.currentSrc || img.src)) { try { await img.decode?.(); } catch {} }
            checking = false;
            if (signal.aborted) cancel();
            else if (img === mpvImage(page) && loaded(img)) finish(null, img);
            else inspect();
          }
        };
        signal.addEventListener('abort', cancel, { once: true });
        if (signal.aborted) { cancel(); return; }
        // 图片替换和加载完成由事件通知,不再每 100 毫秒扫描、重复解码。
        pane.addEventListener('load', onLoad, true); pane.addEventListener('error', failed, true);
        watcher.observe(pane, { subtree: true, childList: true, attributes: true, attributeFilter: ['src', 'srcset'] });
        if (!loaded(mpvImage(page))) {
          try {
            // 使用 MPV 自带的单页加载接口,复用它的地址和缓存,不另开一套图片请求。
            if (typeof bridge.load_image === 'function') bridge.load_image(page);
            else {
              const img = mpvImage(page), src = img?.dataset.src || img?.dataset.original;
              if (!img || !src) { finish(readerError('mpvUnavailable')); return; }
              img.src = new URL(src, location.href).href;
            }
          } catch { finish(readerError('mpvFailed')); return; }
        }
        inspect();
      });
    }

    function ensure(page, foreground = false) {
      let record = cache.get(page);
      if (record?.controller?.signal.aborted) { cache.delete(page); record = null; }
      if (record) {
        record.foreground ||= foreground;
        return foreground ? record.displayPromise || record.promise : record.promise;
      }
      record = { page, status: 'loading', foreground, controller: new AbortController(), image: null, data: null };
      cache.set(page, record);
      record.timeout = setTimeout(() => { record.timedOut = true; record.controller.abort(); }, 30000);
      record.displayPromise = (async () => {
        if (adapter === 'mpv') { record.image = await loadMpv(page, record.controller.signal); return record; }
        const url = await resolveURL(page, record.controller.signal);
        if (record.controller.signal.aborted) throw abortError();
        record.data = parsePage(await fetchHTML(url, record.controller.signal), url);
        if (record.data.page !== page) throw readerError('pageMismatch');
        record.imagePromise = warmImage(record.data, record.controller.signal, image => { record.image = image; });
        // WebP 元数据可用后切换正文,原生首帧到达前保留上一页的画面。
        if (!isWebP(record.data.imageURL)) await record.imagePromise;
        return record;
      })();
      record.promise = record.displayPromise.then(async entry => {
        await entry.imagePromise;
        entry.status = 'ready';
        return entry;
      }).catch(error => {
        if (cache.get(page) === record) cache.delete(page);
        if (state.current === page && record.image?.isConnected) {
          clearTransition();
          if (error.name !== 'AbortError') notify('imageFailed');
        }
        throw record.timedOut ? readerError('timeout') : error;
      }).finally(() => { clearTimeout(record.timeout); countCache(); changed(); });
      // 前台与完整缓存分别等待,拒绝也有接收者,避免未处理错误。
      record.promise.catch(() => {});
      return foreground ? record.displayPromise : record.promise;
    }

    function countCache() {
      let ready = 0;
      for (let page = state.current + 1; page <= Math.min(state.total, state.current + config.preload); page++) {
        if (cache.get(page)?.status === 'ready' || adapter === 'mpv' && loaded(mpvImage(page))) ready++;
      }
      state.readyAhead = ready;
    }

    function trimCache() {
      for (const [page, record] of cache) {
        if (page >= state.current - 2 && page <= state.current + config.preload || page === state.requested || record.foreground) continue;
        record.controller?.abort(); cache.delete(page);
      }
    }

    function pool() {
      if (!initialized || adapter === 'generic' || !config.enabled || !config.inline || stopped) return;
      const cap = isWebP(cache.get(state.current)?.data?.imageURL || mpvImage(state.current)?.src) ? 1 : 2;
      const nextSignature = `${state.current}:${config.preload}:${state.total}:${cap}`;
      if (signature === nextSignature) { countCache(); return; }
      signature = nextSignature;
      const generation = ++wave;
      const wanted = new Set();
      for (let page = state.current + 1; page <= Math.min(state.total, state.current + config.preload); page++) wanted.add(page);
      for (const [page, record] of cache) {
        if (record.status === 'loading' && !record.foreground && !wanted.has(page)) record.controller.abort();
      }
      const queue = [...wanted].filter(page => cache.get(page)?.status !== 'ready' && !(adapter === 'mpv' && loaded(mpvImage(page))));
      let running = 0;
      state.preloadError = false;
      function pump() {
        if (generation !== wave || stopped || !config.enabled || !config.inline) return;
        while (running < cap && queue.length) {
          const page = queue.shift(); running++;
          ensure(page).catch(error => {
            if (generation === wave && error.name !== 'AbortError') { queue.length = 0; state.preloadError = true; }
          }).finally(() => { running--; if (generation === wave) { countCache(); changed(); pump(); } });
        }
      }
      trimCache(); countCache(); pump();
    }

    function replaceReaderURL(url) {
      const previous = history.state && typeof history.state === 'object' ? history.state : {};
      history.replaceState({ ...previous, comicFitReader: { kind: 'reader', gallery: galleryURL } }, '', url);
    }

    function prepareHistory() {
      if (historyReady || !galleryURL || !config.inline) return;
      historyReady = true;
      const currentURL = location.href;
      let fromGallery = false;
      try {
        const previous = new URL(document.referrer);
        const gallery = new URL(galleryURL);
        fromGallery = previous.origin === gallery.origin && previous.pathname === gallery.pathname;
        if (fromGallery) galleryURL = previous.href;
      } catch {}
      if (fromGallery || history.state?.comicFitReader?.kind === 'reader') replaceReaderURL(currentURL);
      else {
        // 直接打开阅读页时补一个详情页入口;后续翻页一律替换当前阅读记录。
        history.replaceState({ comicFitReader: { kind: 'gallery', gallery: galleryURL } }, '', galleryURL);
        history.pushState({ comicFitReader: { kind: 'reader', gallery: galleryURL } }, '', currentURL);
      }
    }

    function sanitized(html, base) {
      const fragment = inertHTML(html);
      for (const node of fragment.querySelectorAll('script,iframe,object,embed,link,style,base,meta')) node.remove();
      for (const node of fragment.querySelectorAll('*')) {
        for (const attr of [...node.attributes]) {
          if (/^on/i.test(attr.name) || attr.name === 'srcset') node.removeAttribute(attr.name);
          else if (attr.name === 'href' || attr.name === 'src') {
            try {
              const url = new URL(attr.value, base);
              if (/^(?:https?:|data:image\/)/i.test(url.href)) node.setAttribute(attr.name, url.href);
              else node.removeAttribute(attr.name);
            } catch { node.removeAttribute(attr.name); }
          }
        }
      }
      return fragment;
    }

    function renderSingle(record) {
      const data = record.data, pane = document.querySelector('#i3');
      if (!pane) throw readerError('areaUnavailable');
      const previous = pane.querySelector('#img');
      const preview = record.image.complete && record.image.naturalWidth ? null : captureTransition(previous);
      const image = record.image;
      image.id = 'img'; image.alt = String(data.page); image.removeAttribute('loading');
      if (!owned.has(image)) {
        image.style.width = `${data.width || image.naturalWidth}px`;
        image.style.height = `${data.height || image.naturalHeight}px`;
      }
      const target = urls.get(data.page + 1);
      const holder = document.createElement(target ? 'a' : 'div');
      if (target) holder.href = target;
      holder.style.lineHeight = '0'; holder.appendChild(image);
      pane.replaceChildren(holder); pane.scrollTop = 0;
      if (preview) {
        // 预览跟随新图的容器裁切,横图不会从竖图两侧露出。
        preview.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;max-height:100dvh;object-fit:cover;pointer-events:none;z-index:0';
        holder.classList.add(classPrefix + 'transition');
        holder.prepend(preview); transitionCanvas = preview;
        let firstFrame = 0, secondFrame = 0;
        const active = () => document.querySelector('#i3 #img') === image && transitionCanvas === preview;
        const ready = () => {
          cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
          firstFrame = requestAnimationFrame(() => {
            firstFrame = 0;
            secondFrame = requestAnimationFrame(() => { secondFrame = 0; if (active()) clearTransition(); });
          });
        };
        const failed = () => { if (active()) clearTransition(); };
        image.addEventListener('load', ready, { once: true });
        image.addEventListener('error', failed, { once: true });
        transitionCleanup = () => {
          image.removeEventListener('load', ready); image.removeEventListener('error', failed);
          cancelAnimationFrame(firstFrame); cancelAnimationFrame(secondFrame);
          holder.classList.remove(classPrefix + 'transition');
        };
        if (image.complete && image.naturalWidth) ready();
      } else clearTransition();
      if (isWebP(record.data.imageURL)) clearTimeout(record.timeout);
      for (const [id, html] of Object.entries(data.sections)) {
        document.getElementById(id)?.replaceChildren(sanitized(html, data.url));
      }
      document.title = data.title;
      replaceReaderURL(data.url);
      attachObservers(); refresh();
      if (!config.focus) image.scrollIntoView({ block: 'center', inline: 'nearest' });
    }

    async function go(value) {
      const page = Math.trunc(Number(value));
      if (!Number.isFinite(page) || page < 1 || page > state.total || !config.enabled) return false;
      if (adapter === 'generic') { lastImages[page - 1]?.scrollIntoView({ block: 'center' }); state.current = page; changed(); return true; }
      if (!config.inline) {
        if (adapter === 'mpv') mpvHosts()[page - 1]?.scrollIntoView({ block: 'start' });
        else { try { location.assign(await resolveURL(page, new AbortController().signal)); } catch { notify('pageUnavailable'); } }
        return true;
      }
      const ticket = ++sequence;
      if (state.requested && state.requested !== page) {
        const old = cache.get(state.requested);
        if (old?.status === 'loading') old.controller.abort();
        if (old) old.foreground = false;
      }
      state.requested = page; state.loading = true; changed();
      let record;
      try {
        record = await ensure(page, true);
        if (ticket !== sequence || stopped || !config.enabled) return false;
        state.current = page;
        if (adapter === 'single') renderSingle(record);
        else {
          const pane = document.querySelector('#pane_images'), host = mpvHosts()[page - 1];
          if (!pane || !host) throw readerError('pageUnavailable');
          refresh();
          pane.scrollTop += host.getBoundingClientRect().top - pane.getBoundingClientRect().top;
          replaceReaderURL(`${location.pathname}${location.search}#page${page}`);
        }
        signature = ''; pool();
        return true;
      } catch (error) {
        if (ticket === sequence && error.name !== 'AbortError') notify(error.translationKey || 'loadFailed', error.values);
        return false;
      } finally {
        if (record) record.foreground = false;
        if (ticket === sequence) { state.loading = false; state.requested = 0; countCache(); trimCache(); changed(); }
      }
    }

    function visiblePage() {
      const nodes = adapter === 'mpv' ? mpvHosts() : lastImages;
      const pane = document.querySelector('#pane_images');
      const bounds = pane?.getBoundingClientRect();
      const center = bounds ? bounds.top + pane.clientHeight / 2 : innerHeight / 2;
      const midpoint = index => {
        const box = nodes[index].getBoundingClientRect();
        return box.top + Math.min(box.height, bounds?.height || innerHeight) / 2;
      };
      if (adapter === 'mpv' && nodes.length) {
        // MPV 各页按纵向顺序排列,只测量中间节点和相邻候选,不逐页读布局。
        let low = 0, high = nodes.length - 1;
        while (low <= high) {
          const middle = (low + high) >> 1;
          if (midpoint(middle) < center) low = middle + 1;
          else high = middle - 1;
        }
        const before = Math.max(0, high), after = Math.min(nodes.length - 1, low);
        return (Math.abs(midpoint(before) - center) <= Math.abs(midpoint(after) - center) ? before : after) + 1;
      }
      let best = 1, distance = Infinity;
      nodes.forEach((node, index) => {
        const delta = Math.abs(midpoint(index) - center);
        if (delta < distance) { best = index + 1; distance = delta; }
      });
      return best;
    }

    function tick() {
      stopped = false;
      if (adapter === 'generic') { state.total = lastImages.length; state.current = visiblePage(); changed(); return; }
      if (!initialized) {
        if (adapter === 'single') {
          const info = pageURL(location.href), image = document.querySelector('#i3 #img');
          if (!info || !image) return;
          bookId = info.gid;
          try {
            const data = parsePage(document, location.href);
            state.current = info.page; urls.set(info.page, location.href);
            const record = { status: 'ready', page: info.page, image, data, foreground: false };
            record.promise = record.displayPromise = Promise.resolve(record); cache.set(info.page, record);
          } catch { return; }
        } else {
          const match = /^\/mpv\/(\d+)\/([a-z0-9]+)/i.exec(location.pathname);
          if (!mpvHosts().length) return;
          bookId = match?.[1] || '';
          if (match) galleryURL = `${location.origin}/g/${match[1]}/${match[2]}/`;
          state.current = Number(/^#page(\d+)$/.exec(location.hash)?.[1]) || visiblePage();
          state.total = mpvHosts().length;
        }
        initialized = true;
      }
      if (adapter === 'single' && initialized) {
        const image = document.querySelector('#i3 #img'), entry = cache.get(state.current);
        // 原站复用正文节点换图或从页面缓存恢复时,重建当前页的元数据。
        if (image && (!entry || entry.image !== image || entry.data?.imageURL !== image.src)) {
          try {
            const data = parsePage(document, location.href);
            const current = { status:'ready', page:data.page, image, data, foreground:false };
            current.promise = current.displayPromise = Promise.resolve(current);
            cache.set(data.page, current); state.current = data.page;
          } catch {}
        }
      }
      if (adapter === 'mpv') state.total = mpvHosts().length;
      if (!config.enabled || !config.inline) { pause(); return; }
      prepareHistory();
      if (adapter === 'mpv' && !state.loading) state.current = visiblePage();
      if (adapter === 'mpv' || loaded(document.querySelector('#i3 #img'))) pool();
      countCache(); changed();
    }

    function pause() {
      stopped = true; signature = ''; wave++; sequence++;
      cancelAnimationFrame(scrollFrame); scrollFrame = 0;
      state.loading = false; state.requested = 0;
      for (const record of cache.values()) { if (record.status === 'loading') record.controller.abort(); }
      for (const controller of galleryControllers) controller.abort();
    }

    window.addEventListener('click', event => {
      if (!config.enabled || picking || event.button !== 0 || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
      const image = event.target instanceof Element ? event.target.closest('img') : null;
      if (config.leftClick && image && owned.has(image)) {
        const box = image.getBoundingClientRect();
        if (event.clientX >= box.left && event.clientX < box.left + box.width / 2) {
          const page = adapter === 'mpv' ? mpvHosts().indexOf(image.closest('.mimg') || image.parentElement.closest('#pane_images_inner > div')) + 1
            : adapter === 'generic' ? lastImages.indexOf(image) + 1 : state.requested || state.current;
          // 首页左侧点击也要截住,避免落到图片原有的“下一页”链接上。
          event.preventDefault(); event.stopImmediatePropagation();
          if (page > 1) go(page - 1);
          return;
        }
      }
      if (!config.inline || !initialized || adapter === 'generic') return;
      const link = event.target instanceof Element ? event.target.closest('a[href]') : null;
      if (!link || link.target && link.target !== '_self') return;
      const info = pageURL(link.href);
      let page = info?.gid === bookId ? info.page : 0;
      if (adapter === 'mpv') {
        const url = new URL(link.href);
        if (url.origin === location.origin && url.pathname === location.pathname) page = Number(/^#page(\d+)$/.exec(url.hash)?.[1]) || 0;
      }
      if (!page) return;
      event.preventDefault(); event.stopImmediatePropagation(); go(page);
    }, true);
    window.addEventListener('keydown', event => {
      if (!config.enabled || !config.inline || adapter === 'generic' || !initialized || picking
        || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
      const target = event.composedPath()[0];
      if (target instanceof Element && (target.matches('input,textarea,select,button') || target.isContentEditable)) return;
      const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
      if (!step) return;
      event.preventDefault(); event.stopImmediatePropagation(); go((state.requested || state.current) + step);
    }, true);
    window.addEventListener('scroll', event => {
      if (adapter === 'generic' && config.enabled) { schedule(); return; }
      if (!config.enabled || !config.inline || adapter !== 'mpv' || event.target !== document.querySelector('#pane_images')) return;
      // 保留浏览器滚动,接管原站可能新增历史记录的翻页与加载回调。
      event.stopImmediatePropagation();
      if (scrollFrame) return;
      scrollFrame = requestAnimationFrame(() => {
        scrollFrame = 0;
        if (state.loading || stopped || !config.enabled || !config.inline) return;
        const page = visiblePage();
        if (page === state.current) return;
        state.current = page;
        replaceReaderURL(`${location.pathname}${location.search}#page${page}`);
        ensure(page).catch(() => {});
        pool(); changed();
      });
    }, true);
    window.addEventListener('popstate', event => {
      const mark = event.state?.comicFitReader;
      if (mark?.kind === 'gallery') { pause(); location.replace(mark.gallery); }
    });
    window.addEventListener('pagehide', pause);
    window.addEventListener('pageshow', () => { if (config.enabled) { tick(); schedule(); } });
    return { state, tick, pause, go, invalidate: () => { hostCache = null; },
      next: step => go((state.requested || state.current) + step),
      back: () => { if (historyReady) history.back(); else if (galleryURL) location.assign(galleryURL); },
      hasGallery: () => !!galleryURL,
      currentRecord: () => cache.get(state.current),
      currentImage: () => adapter === 'mpv' ? mpvImage(state.current) : document.querySelector('#i3 #img') };
  }

  // 翻页只临时保留一张可见画面的画布,不导出图片,也不循环解码。
  function clearTransition() {
    transitionCleanup?.(); transitionCleanup = null;
    if (transitionCanvas) { transitionCanvas.remove(); transitionCanvas.width = transitionCanvas.height = 1; transitionCanvas = null; }
  }

  function captureTransition(image) {
    transitionCleanup?.(); transitionCleanup = null;
    if (!image?.naturalWidth) return transitionCanvas;
    const box = image.getBoundingClientRect();
    const width = Math.max(1, Math.min(innerWidth, Math.round(box.width)));
    const height = Math.max(1, Math.min(innerHeight, Math.round(box.height)));
    const preview = document.createElement('canvas');
    preview.dataset.readerPreview = 'true'; preview.width = width; preview.height = height;
    const pane = document.querySelector('#i3');
    const offset = Math.max(0, (pane.getBoundingClientRect().top - box.top) * image.naturalHeight / Math.max(1, box.height));
    const cropHeight = Math.min(image.naturalHeight - offset, height * image.naturalWidth / width);
    try { preview.getContext('2d').drawImage(image, 0, offset, image.naturalWidth, cropHeight, 0, 0, width, height); }
    catch { return transitionCanvas; }
    clearTransition();
    return preview;
  }

  function persist() { GM_setValue(key, { ...(stored && typeof stored === 'object' ? stored : {}), ...config }); }

  function setRootClass(suffix, wanted) {
    const name = classPrefix + suffix;
    if (wanted && !document.documentElement.classList.contains(name)) {
      document.documentElement.classList.add(name);
      addedClasses.add(name);
    } else if (!wanted && addedClasses.has(name)) {
      document.documentElement.classList.remove(name);
      addedClasses.delete(name);
    }
  }

  function notify(name, values = {}) {
    message = name; messageValues = values;
    messageUntil = Date.now() + 6000;
    showUI();
    syncUI();
    window.setTimeout(syncUI, 6100);
  }

  function safeQuery(selector) {
    try { return [...document.querySelectorAll(selector)]; } catch { return []; }
  }

  const imageDimensions = new WeakMap();

  function intrinsicSize(img) {
    const source = `${img.getAttribute('src') || ''}\n${img.getAttribute('srcset') || ''}`;
    const size = (width, height) => Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
      ? { width, height } : null;
    const metadata = [];
    const add = (name, value) => { if (value) metadata.push({ name, ...value }); };
    if (adapter === 'single' && img.closest('#i3, .sni')) {
      // 文件栏由原站当前页提供;下载尚未完成时也能取得正文图的实际尺寸。
      const dimensions = /(?:^|[^\d])(\d[\d,]*)\s*[x×]\s*(\d[\d,]*)(?:[^\d]|$)/i
        .exec(document.querySelector('#i2')?.textContent || '');
      if (dimensions) add('file', size(Number(dimensions[1].replace(/,/g, '')), Number(dimensions[2].replace(/,/g, ''))));
    }
    if (adapter !== 'generic') {
      add('html', size(Number(img.getAttribute('width')), Number(img.getAttribute('height'))));
      const original = owned.get(img);
      const originalLength = property => {
        const current = img.style.getPropertyValue(property), entry = original?.props.get(property);
        const value = entry && current === entry.last && img.style.getPropertyPriority(property) === entry.lastPriority
          ? entry.before : current;
        return /^\d+(?:\.\d+)?px$/i.test(value) ? Number.parseFloat(value) : 0;
      };
      // 不把本脚本写入的贴合宽高重新当成原始图像宽高。
      add('style', size(originalLength('width'), originalLength('height')));
    }
    const signatures = metadata.map(value => `${value.name}:${value.width}:${value.height}`);
    let tracked = imageDimensions.get(img);
    if (!tracked) tracked = { source, pending: false, signatures, size: null };
    else if (tracked.source !== source) {
      // 同一 img 改源时 currentSrc 与 naturalWidth 可能仍属于旧请求,不能沿用旧图比例。
      tracked = { source, pending: true, before: new Set(tracked.signatures), signatures, size: null };
    }
    imageDimensions.set(img, tracked);
    let known = null;
    const record = adapter !== 'generic' ? reader?.currentRecord?.() : null;
    if (record?.image === img && [record.data?.imageURL, record.asset?.sourceURL, record.asset?.posterURL].includes(img.src)) {
      known = size(Number(record.asset?.width || record.data?.width), Number(record.asset?.height || record.data?.height));
    }
    if (!known && (!tracked.pending || img.complete || !img.getAttribute('srcset') && img.currentSrc === img.src)) {
      known = size(img.naturalWidth, img.naturalHeight);
    }
    if (!known) {
      const fresh = metadata.find(value => !tracked.pending || !tracked.before.has(`${value.name}:${value.width}:${value.height}`));
      if (fresh) known = size(fresh.width, fresh.height);
    }
    tracked.signatures = signatures;
    if (known) { tracked.size = known; tracked.pending = false; }
    return known || (tracked.pending ? null : tracked.size);
  }

  function imageCandidates() {
    const selector = adapter === 'single' ? '#i3 img#img, #i3 a > img, .sni > a > img'
      : adapter === 'mpv' ? '#pane_images .mimg img, #pane_images img[id^="imgsrc_"], #pane_images_inner > div > img, #pane_images_inner > div > .comic-fit-screen-motion-wrap > img'
        : config.selector;
    if (!selector) return [];
    return safeQuery(selector).filter(img => img instanceof HTMLImageElement && img.isConnected);
  }

  function writeOwned(img, values) {
    let record = owned.get(img);
    const sizeKey = `${values.width}:${values.height}`;
    // 固定样式与尺寸均未变化时,省去逐项读取和比较十余个 CSS 属性。
    // 播放层会更新样式记录,仍须核实原站加载回调没有覆盖正文尺寸。
    const currentWidth = img.style.getPropertyValue('width'), currentHeight = img.style.getPropertyValue('height');
    if (record?.sizeKey === sizeKey && img.getAttribute('style') === record.lastStyle
      && currentWidth.endsWith('px') && currentHeight.endsWith('px')
      && Number.parseFloat(currentWidth) === Number.parseFloat(values.width)
      && Number.parseFloat(currentHeight) === Number.parseFloat(values.height)
      && img.style.getPropertyPriority('width') === 'important'
      && img.style.getPropertyPriority('height') === 'important') return;
    if (!record) {
      record = { originalStyle: img.getAttribute('style'), props: new Map(), external: false, lastStyle: null };
      owned.set(img, record);
    }
    if (record.lastStyle !== null && img.getAttribute('style') !== record.lastStyle) record.external = true;
    for (const [prop, value] of Object.entries(values)) {
      const current = img.style.getPropertyValue(prop);
      const priority = img.style.getPropertyPriority(prop);
      let state = record.props.get(prop);
      if (!state) {
        state = { before: current, priority, last: null, lastPriority: '' };
        record.props.set(prop, state);
      } else if (state.last !== null && (current !== state.last || priority !== state.lastPriority)) {
        // 网站翻页时重新写入的尺寸,成为关闭功能后应恢复的最新样式。
        state.before = current;
        state.priority = priority;
        record.external = true;
      }
      if (current !== value || priority !== 'important') img.style.setProperty(prop, value, 'important');
      state.last = img.style.getPropertyValue(prop);
      state.lastPriority = img.style.getPropertyPriority(prop);
    }
    record.lastStyle = img.getAttribute('style');
    record.sizeKey = sizeKey;
  }

  function restoreImage(img, record) {
    if (!record.external && img.getAttribute('style') === record.lastStyle) {
      if (record.originalStyle === null) img.removeAttribute('style');
      else img.setAttribute('style', record.originalStyle);
    } else {
      // 只恢复仍由本脚本占用的属性,保留其他脚本后续添加的样式。
      for (const [prop, state] of record.props) {
        if (img.style.getPropertyValue(prop) !== state.last || img.style.getPropertyPriority(prop) !== state.lastPriority) continue;
        if (state.before) img.style.setProperty(prop, state.before, state.priority);
        else img.style.removeProperty(prop);
      }
      if (record.originalStyle === null && !img.style.cssText) img.removeAttribute('style');
    }
    owned.delete(img);
  }

  function availableSize(img) {
    const viewport = window.visualViewport;
    let width = Math.min(document.documentElement.clientWidth || innerWidth, viewport?.width || innerWidth);
    let height = viewport?.height || innerHeight;
    const pane = adapter === 'single' && document.querySelector('#i1 > #i3')
      || adapter === 'mpv' && document.querySelector('#pane_images');
    if (pane) {
      width = Math.min(width, pane.clientWidth || width);
      height = Math.min(height, pane.clientHeight || height);
    } else {
      // 通用模式尊重原页面的内容栏;先选择漫画区域,再限制每张图片的尺寸。
      const container = img.closest('a, picture')?.parentElement || img.parentElement;
      if (container) {
        const styles = getComputedStyle(container);
        const contentWidth = container.clientWidth - parseFloat(styles.paddingLeft || 0) - parseFloat(styles.paddingRight || 0);
        if (contentWidth > 0) width = Math.min(width, contentWidth);
      }
      if (adapter === 'single') {
        for (const node of safeQuery('#i1 > h1, #i2, #i4')) height -= node.getBoundingClientRect().height + 8;
      }
    }
    return { width: Math.max(1, width - 2 * config.margin), height: Math.max(1, height - 2 * config.margin) };
  }

  function refresh() {
    clearTimeout(timer);
    timer = 0;
    const candidates = config.enabled ? imageCandidates() : [];
    const dimensions = new Map(candidates.map(img => [img, intrinsicSize(img)]));
    const images = candidates.filter(img => {
      const size = dimensions.get(img);
      return size && (adapter !== 'generic' || img.complete) && size.width >= 160 && size.height >= 160
        && Math.max(size.width, size.height) >= 280;
    });
    setRootClass('single', config.enabled && adapter === 'single' && !!document.querySelector('#i1 > #i3'));
    setRootClass('mpv', config.enabled && adapter === 'mpv');
    setRootClass('black', config.enabled && config.focus);
    setRootClass('width', config.enabled && config.mode !== 'page');
    const current = new Set(images), present = new Set(candidates);
    for (const [img, record] of owned) {
      // 更换预览或正文源会短暂失去解码尺寸;节点仍在正文时保留已有贴合,避免闪回大图。
      if (!current.has(img) && (!present.has(img) || dimensions.get(img))) restoreImage(img, record);
    }
    // 未加载图片的占位高度留给原阅读器,避免破坏按滚动位置加载的逻辑。
    const layoutImages = candidates.filter(img => current.has(img) || owned.has(img) && !dimensions.get(img));
    const wrappers = new Set(adapter === 'mpv' ? layoutImages.map(img => img.closest('.mimg')
      || img.closest('#pane_images_inner > div')).filter(Boolean) : []);
    for (const node of ownedWrappers) {
      if (!wrappers.has(node)) {
        node.classList.remove(classPrefix + 'fitted');
        ownedWrappers.delete(node);
      }
    }
    for (const node of wrappers) {
      if (!node.classList.contains(classPrefix + 'fitted')) {
        node.classList.add(classPrefix + 'fitted');
        ownedWrappers.add(node);
      }
    }
    const sharedBox = adapter === 'mpv' || adapter === 'single'
      ? images.length && availableSize(images[0]) : null;
    // 先完成尺寸读取,再统一写入,避免多页阅读时在每张图之间反复触发布局。
    const boxes = sharedBox ? null : images.map(availableSize);
    for (const [index, img] of images.entries()) {
      const box = sharedBox || boxes[index];
      const size = dimensions.get(img);
      let scale = config.mode === 'custom' ? box.width * config.zoom / 100 / size.width
        : config.mode === 'width' ? box.width / size.width
        : Math.min(box.width / size.width, box.height / size.height);
      if (!config.enlarge) scale = Math.min(1, scale);
      writeOwned(img, {
        width: `${(size.width * scale).toFixed(3)}px`,
        height: `${(size.height * scale).toFixed(3)}px`,
        'min-width': '0px', 'min-height': '0px', 'max-width': 'none', 'max-height': 'none',
        'object-fit': 'contain', 'box-sizing': 'content-box', display: 'block',
        'margin-left': 'auto', 'margin-right': 'auto', padding: '0px', border: '0px',
        'flex-shrink': '0', transform: 'none'
      });
    }
    lastImages = images;
    if (images.length) showUI();
    reader?.tick();
    syncUI();
  }

  function schedule() {
    if (!timer) timer = window.setTimeout(refresh, 16);
  }

  function setEnabled(value) {
    config.enabled = value;
    persist();
    if (value) attachObservers();
    else {
      clearTransition();
      reader?.pause();
      observer?.disconnect();
      resizeObserver?.disconnect();
      if (ownsFullscreen && document.fullscreenElement) document.exitFullscreen().catch(() => {});
    }
    refresh();
  }

  function attachObservers() {
    if (!css.isConnected) document.documentElement.appendChild(css);
    // 暂停期间原站也可能增删页面,重新开启时重建节点索引。
    if (adapter === 'mpv') reader?.invalidate();
    observer ??= new MutationObserver(records => {
      if (!config.enabled) return;
      if (adapter === 'mpv' && records.some(item => item.type === 'childList'
        || item.attributeName === 'class' || item.attributeName === 'id')) reader?.invalidate();
      const changed = records.some(item => {
        if (item.target === ui?.host || ui?.host.contains(item.target)) return false;
        if (item.type === 'attributes' && item.attributeName === 'class' && item.target === document.documentElement) return false;
        if (item.type === 'attributes' && item.attributeName === 'style' && owned.has(item.target)) {
          return item.target.getAttribute('style') !== owned.get(item.target).lastStyle;
        }
        return true;
      });
      if (changed) schedule();
    });
    observer.disconnect();
    // EH / EX 只监听阅读区域,无关广告、菜单等变化不再反复触发整页扫描。
    const root = document.querySelector(adapter === 'single' ? '#i1' : adapter === 'mpv' ? '#pane_images' : 'body') || document.body;
    observer.observe(root, { subtree: true, childList: true, attributes: true,
      attributeFilter: ['src', 'srcset', 'style', 'class', 'id', 'width', 'height'] });
    if (typeof ResizeObserver === 'function') {
      resizeObserver ??= new ResizeObserver(schedule);
      resizeObserver.disconnect();
      resizeObserver.observe(document.documentElement);
      const pane = document.querySelector(adapter === 'single' ? '#i3' : '#pane_images');
      if (pane) resizeObserver.observe(pane);
    }
  }

  function cancelPicker() {}

  async function toggleFullscreen() {
    try {
      if (document.fullscreenElement) {
        await document.exitFullscreen();
      } else {
        if (!document.documentElement.requestFullscreen) throw new Error('unsupported');
        await document.documentElement.requestFullscreen();
        ownsFullscreen = true;
      }
      schedule();
    } catch {
      notify('fullscreenUnavailable');
    }
  }

  function showUI() {
    if (ui) return;
    const host = document.createElement('div');
    host.id = 'comic-fit-screen-controls';
    host.style.cssText = 'all:initial!important;color:#ddd!important;color-scheme:dark!important;font:13px/1.45 system-ui,"Microsoft YaHei",sans-serif!important;position:fixed!important;bottom:12px!important;z-index:2147483646!important;';
    const shadow = host.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { color-scheme:dark; font:13px/1.45 system-ui,"Microsoft YaHei",sans-serif; color:#ddd; }
        * { box-sizing:border-box; } [hidden] { display:none !important; }
        button,select,input { font:inherit; color:inherit; }
        button,select,input[type=number] { height:30px; border:1px solid #414141; border-radius:4px; background:#242424; padding:4px 7px; }
        button { cursor:pointer; white-space:nowrap; } button:hover { background:#343434; } button:disabled { opacity:.3; cursor:default; }
        button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid #aaa; outline-offset:2px; }
        #toolbar { display:flex; align-items:center; gap:2px; padding:4px; background:#171717f2; border:1px solid #353535; border-radius:6px; }
        .icon { display:grid; place-items:center; width:28px; height:28px; padding:0; border-color:transparent; background:transparent; }
        svg { width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; pointer-events:none; }
        #collapse { margin-right:3px; } #launcher { margin-left:3px; }
        #counter { display:flex; align-items:center; justify-content:center; gap:4px; min-width:72px; height:28px; padding:0 5px; border:0; background:transparent; font-variant-numeric:tabular-nums; }
        #counter[aria-busy=true] { color:#d9b06c; } #total { color:#999; }
        #restore { width:32px; height:32px; background:#171717f2; border:1px solid #353535; border-radius:6px; }
        #panel { position:absolute; right:0; bottom:calc(100% + 8px); width:280px; max-width:calc(100vw - 24px); max-height:calc(100dvh - 72px); overflow:auto; padding:12px; background:#171717; border:1px solid #353535; border-radius:6px; display:flex; flex-direction:column; gap:12px; }
        :host([data-position=center]) #panel { right:50%; transform:translateX(50%); }
        .row { display:flex; align-items:center; gap:10px; justify-content:space-between; }
        .row > label { flex:1; } #page { width:62px; } #go { flex:0 0 auto; }
        #file { display:flex; flex-direction:column; gap:3px; padding-bottom:10px; border-bottom:1px solid #333; color:#999; font-size:12px; overflow-wrap:anywhere; }
        #file-name { color:#ddd; } .actions { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
        #mode { width:100%; } .zoom { display:flex; flex-direction:column; gap:5px; } input[type=range] { width:100%; accent-color:#b3b3b3; margin:2px 0; }
        #panel input[type=number] { width:62px; } #panel select:not(#mode) { width:118px; }
        #cache { color:#999; font-size:12px; } #status { font-size:12px; color:#d9b06c; }
        @media(max-width:350px) { .icon { width:24px; } #counter { min-width:62px; font-size:12px; } }
      </style>
      <section id="toolbar" data-i18n-aria="controls">
        <button id="collapse" class="icon" data-i18n-title="collapse" data-i18n-aria="collapse"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M8 12h8"/></svg></button>
        <button id="first" class="icon" data-i18n-title="first" data-i18n-aria="first"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5v14m13-14-7 7 7 7"/></svg></button>
        <button id="previous" class="icon" data-i18n-title="previous" data-i18n-aria="previous"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg></button>
        <button id="counter" data-i18n-title="jump" data-i18n-aria="page" aria-controls="panel"><span id="current"></span><span id="total"></span></button>
        <button id="next" class="icon" data-i18n-title="next" data-i18n-aria="next"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg></button>
        <button id="last" class="icon" data-i18n-title="last" data-i18n-aria="last"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 5v14M6 5l7 7-7 7"/></svg></button>
        <button id="launcher" class="icon" data-i18n-title="menu" data-i18n-aria="menu" aria-controls="panel" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 6h14M5 12h14M5 18h14"/></svg></button>
      </section>
      <button id="restore" class="icon" data-i18n-title="restore" data-i18n-aria="restore" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16m5-10 3 2-3 2"/></svg></button>
      <section id="panel" data-i18n-aria="readingSettings" hidden>
        <div id="file" data-i18n-aria="file"><span id="file-name"></span><span id="file-dimensions"></span><span id="file-size"></span></div>
        <div class="row"><label for="page" data-i18n="page"></label><input id="page" type="number" min="1" step="1"><button id="go" data-i18n="jump"></button></div>
        <select id="mode" data-i18n-aria="display"><option value="page" data-i18n="fitPage"></option><option value="width" data-i18n="fitWidth"></option><option value="custom" data-i18n="custom"></option></select>
        <div class="zoom"><div class="row"><label for="zoom" data-i18n="zoom"></label><output id="zoom-value"></output></div><input id="zoom" type="range" min="10" max="100" step="1"></div>
        <div class="actions"><button id="details" data-i18n="details"></button><button id="fullscreen" data-i18n="fullscreen"></button></div>
        <div class="row"><label for="language" data-i18n="language"></label><select id="language"><option value="zh-CN">中文</option><option value="en">English</option></select></div>
        <div class="row"><label for="position" data-i18n="position"></label><select id="position"><option value="right" data-i18n="bottomRight"></option><option value="center" data-i18n="bottomCenter"></option></select></div>
        <div class="row"><label for="focus" data-i18n="focus"></label><input id="focus" type="checkbox"></div>
        <div class="row"><label for="enlarge" data-i18n="enlarge"></label><input id="enlarge" type="checkbox"></div>
        <div class="row"><label for="margin" data-i18n="margin"></label><input id="margin" type="number" min="0" max="80" step="1"></div>
        <div class="row"><label for="inline" data-i18n="inline"></label><input id="inline" type="checkbox"></div>
        <div class="row"><label for="leftClick" data-i18n="leftClick"></label><input id="leftClick" type="checkbox"></div>
        <div class="row"><label for="preload" data-i18n="preload"></label><input id="preload" type="number" min="0" max="20" step="1"></div>
        <div class="row"><label data-i18n="cache"></label><output id="cache" data-i18n-aria="cache"></output></div>
        <button id="disable" data-i18n="disable"></button>
        <output id="status" aria-live="polite" hidden></output>
      </section>`;
    const element = id => shadow.getElementById(id);
    ui = { host, shadow, panel:element('panel'), toolbar:element('toolbar'), collapse:element('collapse'), restore:element('restore'), counter:element('counter'), current:element('current'), first:element('first'), last:element('last'), position:element('position'), launcher:element('launcher'), language:element('language'), mode:element('mode'), zoom:element('zoom'), zoomValue:element('zoom-value'), focus:element('focus'), enlarge:element('enlarge'), margin:element('margin'), inline:element('inline'), leftClick:element('leftClick'), preload:element('preload'), cache:element('cache'), page:element('page'), total:element('total'), go:element('go'), previous:element('previous'), next:element('next'), details:element('details'), fullscreen:element('fullscreen'), disable:element('disable'), status:element('status'), fileName:element('file-name'), fileDimensions:element('file-dimensions'), fileSize:element('file-size') };
    document.documentElement.appendChild(host);
    ui.language.addEventListener('change', () => { language = ui.language.value === 'zh-CN' ? 'zh-CN' : 'en'; GM_setValue(languageKey, language); syncUI(); registerMenus(); });
    ui.launcher.addEventListener('click', () => { ui.panel.hidden = !ui.panel.hidden; syncUI(); });
    ui.counter.addEventListener('click', () => { ui.panel.hidden = false; syncUI(); ui.page.focus(); ui.page.select(); });
    ui.collapse.addEventListener('click', () => { config.toolbarCollapsed = true; ui.panel.hidden = true; persist(); syncUI(); ui.restore.focus(); });
    ui.restore.addEventListener('click', () => { config.toolbarCollapsed = false; persist(); syncUI(); ui.collapse.focus(); });
    ui.position.addEventListener('change', () => { config.toolbarPosition = ui.position.value === 'center' ? 'center' : 'right'; persist(); syncUI(); });
    // 收起菜单不拦截正文点击,让原有的左右翻页继续执行。
    document.addEventListener('pointerdown', event => { if (!ui.panel.hidden && !event.composedPath().includes(host)) { ui.panel.hidden = true; syncUI(); } }, true);
    shadow.addEventListener('keydown', event => { if (event.key === 'Escape' && !ui.panel.hidden) { event.preventDefault(); ui.panel.hidden = true; syncUI(); ui.launcher.focus(); } });
    for (const name of ['mode','focus','enlarge','margin','inline','preload','leftClick']) {
      const update = () => {
        if (name === 'mode') config.mode = ui.mode.value;
        else if (name === 'margin' || name === 'preload') config[name] = Math.min(name === 'margin' ? 80 : 20, Math.max(0, Math.trunc(Number(ui[name].value) || 0)));
        else config[name] = ui[name].checked;
        persist(); schedule(); syncUI();
      };
      ui[name].addEventListener('change', update);
    }
    ui.zoom.addEventListener('input', () => {
      config.mode = 'custom'; config.zoom = Math.min(100, Math.max(10, Number(ui.zoom.value) || 65));
      clearTimeout(saveTimer); saveTimer = setTimeout(persist, 160);
      schedule(); syncUI();
    });
    ui.zoom.addEventListener('change', () => { clearTimeout(saveTimer); persist(); });
    const jump = () => { const number = Number(ui.page.value); if (!Number.isInteger(number) || number < 1 || number > (reader?.state.total || 0)) { notify('pageOutOfRange'); return; } reader?.go(number); ui.page.blur(); };
    ui.go.addEventListener('click', jump);
    ui.page.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); jump(); } });
    ui.previous.addEventListener('click', () => reader?.next(-1)); ui.next.addEventListener('click', () => reader?.next(1));
    ui.first.addEventListener('click', () => reader?.go(1)); ui.last.addEventListener('click', () => reader?.go(reader.state.total));
    ui.details.addEventListener('click', () => reader?.back()); ui.fullscreen.addEventListener('click', toggleFullscreen);
    ui.disable.addEventListener('click', () => setEnabled(!config.enabled));
    syncUI();
  }

  function syncUI() {
    if (!ui) return;
    if (uiLanguage !== language) {
      ui.host.lang = language; ui.language.value = language;
      for (const node of ui.shadow.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n);
      for (const node of ui.shadow.querySelectorAll('[data-i18n-aria]')) node.setAttribute('aria-label', t(node.dataset.i18nAria));
      for (const node of ui.shadow.querySelectorAll('[data-i18n-title]')) node.title = t(node.dataset.i18nTitle);
      uiLanguage = language;
    }
    ui.mode.value = config.mode; ui.zoom.value = String(config.zoom);
    ui.zoomValue.textContent = config.mode === 'custom' ? config.zoom + '%' : '—';
    ui.focus.checked = config.focus; ui.enlarge.checked = config.enlarge;
    if (ui.margin.value !== String(config.margin)) ui.margin.value = String(config.margin);
    ui.inline.checked = config.inline; ui.leftClick.checked = config.leftClick;
    if (ui.preload.value !== String(config.preload)) ui.preload.value = String(config.preload);
    ui.preload.disabled = !config.inline || !config.enabled; ui.zoom.disabled = !config.enabled;
    ui.fullscreen.textContent = t(document.fullscreenElement ? 'exitFullscreen' : 'fullscreen');
    ui.disable.textContent = t(config.enabled ? 'disable' : 'enable');
    ui.position.value = config.toolbarPosition;
    if (ui.host.dataset.position !== config.toolbarPosition) {
      ui.host.style.setProperty('right', config.toolbarPosition === 'center' ? 'auto' : '12px', 'important');
      ui.host.style.setProperty('left', config.toolbarPosition === 'center' ? '50%' : 'auto', 'important');
      ui.host.style.setProperty('transform', config.toolbarPosition === 'center' ? 'translateX(-50%)' : 'none', 'important');
      ui.host.dataset.position = config.toolbarPosition;
    }
    ui.toolbar.hidden = config.toolbarCollapsed; ui.restore.hidden = !config.toolbarCollapsed;
    if (config.toolbarCollapsed) ui.panel.hidden = true;
    ui.host.dataset.uiState = config.toolbarCollapsed ? 'icon' : ui.panel.hidden ? 'toolbar' : 'menu';
    ui.launcher.setAttribute('aria-expanded', String(!ui.panel.hidden));
    const state = reader?.state, current = state?.requested || state?.current || 1, total = state?.total || 0;
    if (ui.shadow.activeElement !== ui.page) ui.page.value = String(current);
    ui.page.max = String(total); ui.total.textContent = '/ ' + total; ui.page.setAttribute('aria-busy', String(!!state?.loading));
    ui.current.textContent = String(current); ui.counter.setAttribute('aria-busy', String(!!state?.loading));
    ui.page.disabled = ui.go.disabled = ui.counter.disabled = !config.enabled || !total;
    ui.first.disabled = ui.previous.disabled = !config.enabled || current <= 1;
    ui.last.disabled = ui.next.disabled = !config.enabled || !total || current >= total;
    ui.details.hidden = !reader?.hasGallery();
    ui.cache.textContent = state?.preloadError ? t('failed') : (state?.readyAhead || 0) + '/' + Math.min(config.preload, Math.max(0, total - (state?.current || 1)));
    ui.status.hidden = Date.now() >= messageUntil; ui.status.textContent = message ? t(message, messageValues) : '';
    const record = reader?.currentRecord?.(), image = reader?.currentImage?.();
    const info = record?.data?.fileInfo || '';
    const parts = info.split(/\s*::\s*/);
    ui.fileName.textContent = parts[0] || (image?.src ? new URL(image.src, location.href).pathname.split('/').pop() : '');
    const dimensions = parts.find(part => /\d+\s*[x×]\s*\d+/i.test(part));
    const size = image ? intrinsicSize(image) : null;
    ui.fileDimensions.textContent = dimensions || (size ? size.width + ' × ' + size.height : '');
    ui.fileSize.textContent = parts.find(part => /\d+(?:\.\d+)?\s*(?:[KMGT]i?B|bytes)/i.test(part)) || '';
    ui.fileSize.hidden = !ui.fileSize.textContent;
    ui.host.dataset.version = '3.5.1'; ui.host.dataset.mode = config.mode; ui.host.dataset.zoom = String(config.zoom);
    ui.host.dataset.nativeWebp = String(isWebP(record?.data?.imageURL || image?.src));
  }

  function registerMenus() {
    if (menuIds.length && typeof GM_unregisterMenuCommand !== 'function') return;
    for (const id of menuIds) if (id !== undefined) GM_unregisterMenuCommand(id);
    menuIds = [
      GM_registerMenuCommand(t('menuToggle'), () => setEnabled(!config.enabled)),
      GM_registerMenuCommand(t('menuSettings'), () => {
        showUI(); config.toolbarCollapsed = false; persist(); ui.panel.hidden = false; syncUI();
      }),
      GM_registerMenuCommand(t('menuClear'), () => {
        cancelPicker(); setEnabled(false); GM_deleteValue(key);
        Object.assign(config, defaults, { enabled: false }); notify('cleared');
      })
    ];
  }
  registerMenus();
  document.addEventListener('keydown', event => {
    const target = event.composedPath()[0];
    if (target instanceof Element && (target.matches('input,textarea,select') || target.isContentEditable)) return;
    if (event.altKey && event.shiftKey && event.code === 'KeyM' && !event.ctrlKey && !event.metaKey) {
      event.preventDefault();
      setEnabled(!config.enabled);
    }
  }, true);
  const onImageEvent = event => {
    if (!config.enabled || !(event.target instanceof HTMLImageElement)) return;
    const img = event.target;
    if (adapter === 'single' ? img.closest('#i3, .sni') : adapter === 'mpv' ? img.closest('#pane_images') : true) schedule();
  };
  document.addEventListener('load', onImageEvent, true);
  document.addEventListener('error', onImageEvent, true);
  window.addEventListener('resize', () => { if (config.enabled) schedule(); });
  window.visualViewport?.addEventListener('resize', () => { if (config.enabled) schedule(); });
  document.addEventListener('fullscreenchange', () => {
    if (!document.fullscreenElement) ownsFullscreen = false;
    if (config.enabled) schedule();
    syncUI();
  });
  window.addEventListener('pagehide', () => { clearTransition(); clearTimeout(saveTimer); persist(); });
  reader = createReader();
  if (config.enabled) attachObservers();
  refresh();
})();