☰

Better Ehentai Interface

Screen fitting, in-page navigation, next-page preloading, page jumps and left-click back. 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      2.1.0
// @description  Screen fitting, in-page navigation, next-page preloading, page jumps and left-click back. English / Chinese interface.
// @description:zh-CN 整页贴屏、页内翻页、后续页预加载、页码跳转和左侧点击返回;支持中文 / English 界面。
// @match        http://*/*
// @match        https://*/*
// @exclude      *://nhentai.net/*
// @exclude      *://*.nhentai.net/*
// @run-at       document-idle
// @noframes
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        unsafeWindow
// @license MIT
// ==/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';
  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: '适合宽度', focus: '黑底阅读',
      enlarge: '小图放大', margin: '留边(px)', inline: '页内翻页', preload: '预加载(页)',
      cache: '预加载进度', leftClick: '左侧点击返回', pick: '选图', disable: '关闭贴屏', enable: '开启贴屏',
      previous: '上一页', next: '下一页', page: '页码', jump: '跳转', details: '详情',
      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', 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',
      fullscreen: 'Fullscreen', exitFullscreen: 'Exit fullscreen', failed: 'Failed',
      menuToggle: 'Fit: on / off (Alt+Shift+M)', menuSettings: 'Fit: settings / pick image',
      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
  };
  const stored = GM_getValue(key, {});
  const config = { ...defaults, ...(stored && typeof stored === 'object' ? stored : {}) };
  config.mode = config.mode === 'width' ? 'width' : 'page';
  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;
  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;

  // 翻页与预加载仅作用于当前画廊;通用模式继续使用原网站的加载机制。
  const css = document.createElement('style');
  css.textContent = `
    html.${classPrefix}single body { margin:0 !important; background:#000 !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(#i2):not(#i3):not(#i4) { 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;
      align-items:center !important; justify-content: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:flex-start !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);
        return url.origin === location.origin && match ? { url: url.href, gid: match[2], page: Number(match[3]) } : 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 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);
      const text = root.querySelector('#i2 .sn, #i2')?.textContent.replace(/,/g, '') || '';
      const counter = /(\d+)\s*\/\s*(\d+)/.exec(text);
      state.total = Math.max(state.total, info.page, ...pages, Number(counter?.[2] || 0));
      const sections = {};
      for (const id of ['i2', 'i4', 'i5', 'i6', 'i7']) {
        const node = root.querySelector('#' + id);
        if (node) sections[id] = node.innerHTML;
      }
      return { page: info.page, url, imageURL, sections, title: root.querySelector('title')?.textContent || document.title,
        width: Number(image.getAttribute('width')) || 0, height: Number(image.getAttribute('height')) || 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) {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.decoding = 'async'; img.referrerPolicy = data.referrerPolicy;
        let settled = false;
        const cleanup = () => { img.onload = null; img.onerror = null; signal.removeEventListener('abort', cancel); };
        const fail = error => { if (settled) return; settled = true; cleanup(); 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; }
          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;
            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 record.promise; }
      record = { page, status: 'loading', foreground, controller: new AbortController(), image: null, data: null };
      cache.set(page, record);
      const timeout = setTimeout(() => { record.timedOut = true; record.controller.abort(); }, 30000);
      record.promise = (async () => {
        if (adapter === 'mpv') record.image = await loadMpv(page, record.controller.signal);
        else {
          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.image = await warmImage(record.data, record.controller.signal);
        }
        record.status = 'ready';
        return record;
      })().catch(error => {
        if (cache.get(page) === record) cache.delete(page);
        throw record.timedOut ? readerError('timeout') : error;
      }).finally(() => { clearTimeout(timeout); countCache(); changed(); });
      return 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 nextSignature = `${state.current}:${config.preload}:${state.total}`;
      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 < 2 && 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 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;
      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 = 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 === '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(); });
    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 };
  }

  function persist() { GM_setValue(key, 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 []; }
  }

  function findImages() {
    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'
        : config.selector;
    if (!selector) return [];
    return safeQuery(selector).filter(img => img instanceof HTMLImageElement && img.isConnected
      && img.complete && img.naturalWidth >= 160 && img.naturalHeight >= 160
      && Math.max(img.naturalWidth, img.naturalHeight) >= 280);
  }

  function writeOwned(img, values) {
    let record = owned.get(img);
    const sizeKey = `${values.width}:${values.height}`;
    // 固定样式与尺寸均未变化时,省去逐项读取和比较十余个 CSS 属性。
    if (record?.sizeKey === sizeKey && img.getAttribute('style') === record.lastStyle) 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' && config.focus && 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 images = config.enabled ? findImages() : [];
    setRootClass('single', config.enabled && config.focus && adapter === 'single' && !!document.querySelector('#i1 > #i3'));
    setRootClass('mpv', config.enabled && adapter === 'mpv');
    setRootClass('black', config.enabled && config.focus && adapter === 'mpv');
    setRootClass('width', config.enabled && config.mode === 'width');
    const current = new Set(images);
    for (const [img, record] of owned) if (!current.has(img)) restoreImage(img, record);
    // 未加载图片的占位高度留给原阅读器,避免破坏按滚动位置加载的逻辑。
    const wrappers = new Set(adapter === 'mpv' ? images.map(img => img.closest('.mimg')
      || (img.parentElement.matches('#pane_images_inner > div') ? img.parentElement : null)).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' && config.focus
      ? 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];
      let scale = config.mode === 'width' ? box.width / img.naturalWidth
        : Math.min(box.width / img.naturalWidth, box.height / img.naturalHeight);
      if (!config.enlarge) scale = Math.min(1, scale);
      writeOwned(img, {
        width: `${(img.naturalWidth * scale).toFixed(3)}px`,
        height: `${(img.naturalHeight * 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, 60);
  }

  function setEnabled(value) {
    if (value && adapter === 'generic' && !config.selector) { startPicker(); return; }
    config.enabled = value;
    persist();
    if (value) attachObservers();
    else {
      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 selectorFor(img) {
    const readerWords = /(?:reader|reading|comic|manga|chapter|viewer|image-container|read-content|pages)/i;
    for (let node = img.parentElement; node && node !== document.body; node = node.parentElement) {
      if (node.id && readerWords.test(node.id)) return `#${CSS.escape(node.id)} img`;
      const name = [...node.classList].find(value => readerWords.test(value));
      if (name) return `${node.tagName.toLowerCase()}.${CSS.escape(name)} img`;
    }
    if (img.id) return `img#${CSS.escape(img.id)}`;
    const imageClass = [...img.classList].find(value => readerWords.test(value));
    if (imageClass) return `img.${CSS.escape(imageClass)}`;
    for (let node = img.parentElement; node && node !== document.body; node = node.parentElement) {
      if (node.id) return `#${CSS.escape(node.id)} img`;
    }
    const path = [];
    for (let node = img; node && node !== document.body; node = node.parentElement) {
      const index = [...node.parentElement.children].indexOf(node) + 1;
      path.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
    }
    return `body > ${path.join(' > ')}`;
  }

  function clearHover() {
    if (hovering) {
      if (hoveredOutline.value) hovering.style.setProperty('outline', hoveredOutline.value, hoveredOutline.priority);
      else hovering.style.removeProperty('outline');
      if (!hoveredOutline.hadStyle && !hovering.style.cssText) hovering.removeAttribute('style');
    }
    hovering = null;
    hoveredOutline = null;
  }

  function cancelPicker() {
    picking = false;
    clearHover();
    document.removeEventListener('pointermove', pickerMove, true);
    document.removeEventListener('click', pickerClick, true);
    syncUI();
  }

  function pickerMove(event) {
    const img = event.target instanceof Element ? event.target.closest('img') : null;
    if (img === hovering) return;
    clearHover();
    if (img) {
      hovering = img;
      hoveredOutline = { value: img.style.getPropertyValue('outline'), priority: img.style.getPropertyPriority('outline'), hadStyle: img.hasAttribute('style') };
      img.style.setProperty('outline', '3px solid #6ddbc6', 'important');
    }
  }

  function pickerClick(event) {
    const img = event.target instanceof Element ? event.target.closest('img') : null;
    if (!img) return;
    event.preventDefault();
    event.stopImmediatePropagation();
    cancelPicker();
    if (!img.complete || img.naturalWidth < 160 || img.naturalHeight < 160) {
      notify('imageNotLoaded');
      return;
    }
    config.selector = selectorFor(img);
    setEnabled(true);
    img.scrollIntoView({ block: 'center', inline: 'nearest' });
  }

  function startPicker() {
    if (adapter !== 'generic') { setEnabled(true); showUI(); ui.panel.hidden = false; return; }
    cancelPicker();
    picking = true;
    showUI();
    ui.panel.hidden = false;
    notify('picker');
    document.addEventListener('pointermove', pickerMove, true);
    document.addEventListener('click', pickerClick, true);
  }

  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:#eee!important;font:14px/1.5 system-ui,"Microsoft YaHei",sans-serif!important;color-scheme:dark!important;position:fixed!important;right:12px!important;bottom:12px!important;z-index:2147483646!important;';
    const shadow = host.attachShadow({ mode: 'open' });
    shadow.innerHTML = `
      <style>
        :host { color-scheme:dark; font:13px/1.4 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] { border:1px solid #494949; border-radius:3px; background:#252525; padding:4px 7px; height:29px; }
        button { cursor:pointer; white-space:nowrap; } button:hover { background:#383838; } button:disabled { opacity:.35; cursor:default; }
        button:focus-visible,input:focus-visible,select:focus-visible { outline:2px solid #aaa; outline-offset:1px; }
        #toolbar { display:flex; align-items:center; justify-content:flex-end; flex-wrap:wrap; gap:5px; max-width:calc(100vw - 24px); padding:6px; border:1px solid #444; border-radius:4px; background:#161616ed; }
        #panel { width:282px; max-width:calc(100vw - 24px); margin:0 0 6px auto; padding:8px 12px; border:1px solid #444; border-radius:4px; background:#161616f5; }
        .row { display:flex; align-items:center; gap:8px; min-height:34px; } .row label:first-child { flex:1; }
        input[type=number] { width:58px; } input[type=checkbox] { accent-color:#aaa; } #page { width:58px; } #page-total { color:#aaa; }
        #cache { color:#aaa; font-size:12px; min-width:24px; text-align:right; } #status { max-width:282px; margin:0 0 6px auto; padding:5px 9px; background:#222; border:1px solid #555; font-size:12px; }
      </style>
      <section id="panel" hidden data-i18n-aria="readingSettings">
        <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="mode" data-i18n="display"></label><select id="mode"><option value="page" data-i18n="fitPage"></option><option value="width" data-i18n="fitWidth"></option></select></div>
        <div class="row" id="focus-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" id="inline-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" id="preload-row"><label for="preload" data-i18n="preload"></label><input id="preload" type="number" min="0" max="20" step="1"><output id="cache" data-i18n-aria="cache"></output></div>
        <div class="row"><button id="pick" data-i18n="pick"></button><button id="disable"></button></div>
      </section>
      <div id="status" role="status" hidden></div>
      <nav id="toolbar" data-i18n-aria="controls">
        <button id="previous" data-i18n-aria="previous" data-i18n-title="previous">◀</button>
        <input id="page" type="number" min="1" data-i18n-aria="page"><span id="page-total"></span><button id="go" data-i18n="jump"></button>
        <button id="next" data-i18n-aria="next" data-i18n-title="next">▶</button>
        <button id="details" data-i18n="details"></button><button id="fullscreen"></button><button id="launcher" aria-expanded="false" data-i18n="settings"></button>
      </nav>`;
    const element = id => shadow.getElementById(id);
    ui = { host, shadow, panel: element('panel'), status: element('status'), language: element('language'), mode: element('mode'),
      focus: element('focus'), enlarge: element('enlarge'), margin: element('margin'), inline: element('inline'), preload: element('preload'), leftClick: element('leftClick'),
      fullscreen: element('fullscreen'), pick: element('pick'), disable: element('disable'), launcher: element('launcher'),
      page: element('page'), total: element('page-total'), previous: element('previous'), next: element('next'),
      go: element('go'), details: element('details'), cache: element('cache') };
    document.documentElement.appendChild(host);
    ui.language.addEventListener('change', () => {
      language = ui.language.value === 'en' ? 'en' : 'zh-CN';
      GM_setValue(languageKey, language);
      syncUI(); registerMenus();
    });
    ui.launcher.addEventListener('click', () => { ui.panel.hidden = !ui.panel.hidden; ui.launcher.setAttribute('aria-expanded', String(!ui.panel.hidden)); syncUI(); });
    for (const name of ['mode', 'focus', 'enlarge', 'margin', 'inline', 'preload', 'leftClick']) {
      const update = () => {
        config[name] = name === 'mode' ? ui.mode.value : name === 'margin' || name === 'preload'
          ? Math.min(name === 'margin' ? 80 : 20, Math.max(0, Math.trunc(Number(ui[name].value) || 0))) : ui[name].checked;
        persist();
        refresh();
      };
      ui[name].addEventListener('change', update);
      // 数值输入立即保存,避免窗口刷新时覆盖尚未离焦的输入。
      if (name === 'margin' || name === 'preload') ui[name].addEventListener('input', update);
    }
    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.details.addEventListener('click', () => reader?.back());
    ui.fullscreen.addEventListener('click', toggleFullscreen);
    ui.pick.addEventListener('click', startPicker);
    ui.disable.addEventListener('click', () => { cancelPicker(); 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.focus.checked = config.focus;
    ui.shadow.getElementById('focus-row').hidden = adapter === 'generic';
    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);
    for (const id of ['inline-row', 'preload-row']) ui.shadow.getElementById(id).hidden = adapter === 'generic';
    ui.preload.disabled = !config.inline || !config.enabled;
    ui.fullscreen.textContent = t(document.fullscreenElement ? 'exitFullscreen' : 'fullscreen');
    ui.pick.hidden = adapter !== 'generic';
    ui.disable.textContent = t(config.enabled ? 'disable' : 'enable');
    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.page.disabled = ui.go.disabled = !config.enabled || !total;
    ui.previous.disabled = !config.enabled || current <= 1;
    ui.next.disabled = !config.enabled || 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) : '';
  }

  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'), () => {
        if (adapter === 'generic' && !config.selector) startPicker();
        else { showUI(); ui.panel.hidden = false; syncUI(); }
      }),
      GM_registerMenuCommand(t('menuPick'), startPicker),
      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);
    } else if (event.key === 'Escape' && picking) {
      event.preventDefault();
      cancelPicker();
    }
  }, 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();
  });
  reader = createReader();
  if (config.enabled) attachObservers();
  refresh();
})();