☰

Better Ehentai Interface

Lightweight reader with a compact collapsible toolbar, width slider, in-page navigation and native WebP display. English / Chinese interface.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Better Ehentai Interface
// @name:en      Better Ehentai Interface
// @name:zh-CN   更好的Ehentai界面
// @namespace    local.comic-fit-screen
// @version      3.5.0
// @description  Lightweight reader with a compact collapsible toolbar, width slider, in-page navigation and native WebP display. English / Chinese interface.
// @description:zh-CN 轻量阅读器:可收起的紧凑工具栏、宽度滑杆、页内翻页、预加载及原生 WebP 显示;支持中文 / 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.${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 && 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;inset:0;margin:auto;max-width:100%;max-height:100%;pointer-events:none;z-index:0';
        holder.style.position = 'relative'; holder.style.zIndex = '1';
        pane.prepend(preview); transitionCanvas = preview;
        const ready = () => requestAnimationFrame(() => requestAnimationFrame(() => {
          if (document.querySelector('#i3 #img') === image && transitionCanvas === preview) clearTransition();
        }));
        image.addEventListener('load', ready, { once: true });
        transitionCleanup = () => image.removeEventListener('load', ready);
        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.0'; 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();
})();