☰

字幕-手机版-AI

字幕搜索/加载/下载 · 静默自动匹配(≥60%) · 四源聚合 · 相似度排序 · 可拖拽小工具条

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)


    // ==UserScript==
    // @name         字幕-手机版-AI
    // @namespace    missav-sub
    // @version      3.3.0
    // @description  字幕搜索/加载/下载 · 静默自动匹配(≥60%) · 四源聚合 · 相似度排序 · 可拖拽小工具条
    // @author       xiaoxiongweihu (v3)
    // @match        *://*.missav123.com/*
    // @match        *://*.missav.ws/*
    // @match        *://*.missav.ai/*
    // @match        *://*.missav.com/*
    // @match        *://*.missav.live/*
    // @match        *://*.javdb.com/*
    // @match        *://*.javdb.tv/*
    // @match        *://*.jable.tv/*
    // @grant        GM_xmlhttpRequest
    // @grant        GM_setValue
    // @grant        GM_getValue
    // @grant        GM_deleteValue
    // @grant        GM_openInTab
    // @grant        unsafeWindow
    // @connect      *
    // @connect      www.avsubtitles.com
    // @connect      avsubtitles.com
    // @run-at       document-end
    // @license      MIT
    // ==/UserScript==
     
    (function () {
    'use strict';
     
    if (window.__MISSAV_SUB_V3__) { console.debug('[MissavSub] 已加载,跳过'); return; }
    window.__MISSAV_SUB_V3__ = true;
     
    const TAG = '[MissavSub]';
    const log = (...a) => console.debug(TAG, ...a);
    const NL = '\n';
    const BS = String.fromCharCode(92);
    const REPL = String.fromCharCode(0xFFFD);
     
    /* 1. 配置 */
    const NS = 'missavSub.v3.';
    const DEFAULTS = {
      accelRate: 3, skipTime: 5, offset: 0, posPercent: 15,
      fontSize: 26, fontFamily: 'Microsoft YaHei, PingFang SC, Hiragino Sans GB, sans-serif',
      color: '#ffffff', outline: 2, opacity: 1,
      keys: { accel: 'z', fwd: 'x', back: 'c' },
      panel: null, fab: null, listWidth: 340,
      cacheHours: 6, source: 'xunlei',
      collapsed: true,
      autoMatch: true,
      silentThreshold: 0.6,
      assrtToken: ''
    };
     
    const store = {
      get(k, d) { try { const v = GM_getValue(NS + k); return (v === undefined || v === null) ? d : v; } catch (e) { return d; } },
      set(k, v) { try { GM_setValue(NS + k, v); } catch (e) { log('保存失败', e); } },
      del(k) { try { GM_deleteValue(NS + k); } catch (e) {} }
    };
     
    let cfg = Object.assign({}, DEFAULTS, store.get('config', {}));
    cfg.keys = Object.assign({}, DEFAULTS.keys, cfg.keys || {});
    let saveTimer = 0;
    function saveCfg() { clearTimeout(saveTimer); saveTimer = setTimeout(() => store.set('config', cfg), 300); }
     
    /* 2. 工具 */
    function h(tag, props, ...kids) {
      const e = document.createElement(tag);
      if (props) for (const k in props) {
        const v = props[k];
        if (v === null || v === undefined || v === false) continue;
        if (k === 'style') { if (typeof v === 'string') e.style.cssText = v; else Object.assign(e.style, v); }
        else if (k === 'text') e.textContent = v;
        else if (k.indexOf('on') === 0) e.addEventListener(k.slice(2).toLowerCase(), v);
        else e.setAttribute(k, v);
      }
      kids.forEach(c => {
        if (c === null || c === undefined || c === false) return;
        e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
      });
      return e;
    }
     
    function sanitizeName(n) {
      return String(n || 'subtitle').replace(/[^\w\u4e00-\u9fa5.\-]+/g, '_').slice(0, 120) || 'subtitle';
    }
     
    function similarity(a, b) {
      const sa = new Set(String(a || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split(''));
      const sb = new Set(String(b || '').toUpperCase().replace(/[^A-Z0-9]/g, '').split(''));
      if (!sa.size || !sb.size) return 0;
      let inter = 0;
      sa.forEach(c => { if (sb.has(c)) inter++; });
      return inter / (sa.size + sb.size - inter);
    }
     
    const toastBox = h('div', { style: 'position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;align-items:flex-end;pointer-events:none;' });
    function toast(msg, ms) {
      ms = ms || 4000;
      if (!toastBox.isConnected) document.body.appendChild(toastBox);
      const t = h('div', { text: String(msg), style: 'pointer-events:auto;max-width:380px;background:rgba(18,18,24,.78);color:#fff;padding:12px 18px;border-radius:9px;font:15px/1.5 system-ui,sans-serif;box-shadow:0 6px 28px rgba(0,0,0,.45);border:1px solid rgba(120,160,255,.3);opacity:0;transform:translateY(6px);transition:opacity .22s,transform .22s;' });
      toastBox.appendChild(t);
      requestAnimationFrame(() => { t.style.opacity = '1'; t.style.transform = 'none'; });
      setTimeout(() => {
        t.style.opacity = '0'; t.style.transform = 'translateY(6px)';
        setTimeout(() => t.remove(), 260);
      }, ms);
      while (toastBox.children.length > 4) toastBox.firstChild.remove();
    }
     
    function forceDownload(content, filename) {
      const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
      const url = URL.createObjectURL(blob);
      const a = h('a', { href: url, download: sanitizeName(filename) });
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 4000);
    }
     
    /* 3. 网络 */
    const pending = new Set();
    function request(opts) {
      return new Promise((resolve, reject) => {
        const req = GM_xmlhttpRequest({
          method: opts.method || 'GET',
          url: opts.url,
          headers: opts.headers || {},
          timeout: opts.timeout || 20000,
          onload: (r) => { pending.delete(req); if (r.status >= 200 && r.status < 300) resolve(r.responseText); else reject(new Error('HTTP ' + r.status)); },
          onerror: () => { pending.delete(req); reject(new Error('网络错误或被阻止')); },
          ontimeout: () => { pending.delete(req); reject(new Error('请求超时')); },
          onabort: () => { pending.delete(req); reject(new Error('已取消')); }
        });
        pending.add(req);
      });
    }
    function abortAll() { pending.forEach(r => { try { r.abort(); } catch (e) {} }); pending.clear(); }
    function fetchText(url) {
      if (!/^https?:/i.test(url)) return Promise.reject(new Error('非法地址'));
      return request({ url: url, headers: { Accept: 'text/plain,*/*', Referer: location.href } });
    }
    async function fetchJSON(url, opts) {
      const txt = await request({ url: url, headers: (opts && opts.headers) || { Accept: 'application/json, text/plain, */*' } });
      return JSON.parse(txt);
    }
     
    /* 4. 解码 */
    function decodeBuffer(buf) {
      const u8 = new Uint8Array(buf);
      const td = (enc) => new TextDecoder(enc);
      try {
        if (u8.length >= 3 && u8[0] === 0xEF && u8[1] === 0xBB && u8[2] === 0xBF) return td('utf-8').decode(u8.subarray(3));
        if (u8.length >= 2 && u8[0] === 0xFF && u8[1] === 0xFE) return td('utf-16le').decode(u8.subarray(2));
        if (u8.length >= 2 && u8[0] === 0xFE && u8[1] === 0xFF) return td('utf-16be').decode(u8.subarray(2));
        try { return new TextDecoder('utf-8', { fatal: true }).decode(u8); } catch (e) {}
        const candidates = ['gb18030', 'big5', 'shift_jis', 'euc-kr'];
        for (let i = 0; i < candidates.length; i++) {
          try { const t = td(candidates[i]).decode(u8); if (t.indexOf(REPL) === -1) return t; } catch (e) {}
        }
        return td('utf-8').decode(u8);
      } catch (e) { return td('utf-8').decode(u8); }
    }
     
    /* 5. 解析 */
    function parseTimeSrt(s) {
      const parts = String(s).trim().split(/[,.]/);
      const hms = parts[0].split(':');
      if (hms.length < 3) return NaN;
      const ms = parts[1] ? parseInt(parts[1].padEnd(3, '0').slice(0, 3), 10) : 0;
      const hh = parseInt(hms[0], 10) || 0, mm = parseInt(hms[1], 10) || 0, ss = parseInt(hms[2], 10) || 0;
      const v = hh * 3600 + mm * 60 + ss + ms / 1000;
      return isNaN(v) ? NaN : v;
    }
    function parseTimeAss(s) {
      const t = String(s).trim().split(/[,.]/);
      const hms = t[0].split(':');
      if (hms.length < 3) return NaN;
      const cs = t[1] ? parseInt(t[1].padEnd(2, '0').slice(0, 2), 10) : 0;
      return (parseInt(hms[0], 10) || 0) * 3600 + (parseInt(hms[1], 10) || 0) * 60 + (parseInt(hms[2], 10) || 0) + cs / 100;
    }
    function cleanCue(t) {
      return String(t).split(BS + 'N').join(NL).split(BS + 'n').join(NL).split(BS + 'h').join(' ').trim();
    }
    function splitLines(text) { return String(text).split('\r').join(NL).split(NL); }
     
    function parseSRT(text) {
      text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
      const out = [];
      text.split(/\n\n+/).forEach(block => {
        const lines = block.split(NL).map(l => l.replace(/\r$/, '')).filter(l => l.length);
        if (!lines.length) return;
        let ti = lines[0].indexOf('-->') >= 0 ? 0 : (lines[1] && lines[1].indexOf('-->') >= 0 ? 1 : -1);
        if (ti < 0) return;
        const seg = lines[ti].split('-->');
        const s = parseTimeSrt(seg[0]), e = parseTimeSrt(seg[1]);
        const txt = lines.slice(ti + 1).join(NL).trim();
        if (!isNaN(s) && !isNaN(e) && e > s && txt) out.push({ s: s, e: e, text: txt });
      });
      return out;
    }
    function parseVTT(text) {
      const body = text.replace(/^WEBVTT[^\n]*\n/, '');
      return parseSRT(body.replace(/\n\d+\n/g, NL));
    }
    function parseASS(text) {
      const out = [];
      const lines = splitLines(text);
      let inEvents = false, fmt = null;
      lines.forEach(raw => {
        const line = raw.trim();
        if (!line) return;
        if (line.indexOf('[') === 0) { inEvents = line.toLowerCase().indexOf('[events]') === 0; return; }
        if (!inEvents) return;
        if (line.toLowerCase().indexOf('format:') === 0) { fmt = line.slice(7).split(',').map(s => s.trim().toLowerCase()); return; }
        if (line.toLowerCase().indexOf('dialogue:') !== 0) return;
        const f = fmt || ['layer', 'start', 'end', 'style', 'name', 'marginl', 'marginr', 'marginv', 'effect', 'text'];
        const ti = f.indexOf('text'), si = f.indexOf('start'), ei = f.indexOf('end');
        const parts = line.slice(9).split(',');
        const head = parts.slice(0, ti);
        const bodyText = parts.slice(ti).join(',');
        const s = parseTimeAss(head[si]), e = parseTimeAss(head[ei]);
        const txt = cleanCue(bodyText.replace(/\{[^}]*\}/g, ''));
        if (!isNaN(s) && !isNaN(e) && e > s && txt) out.push({ s: s, e: e, text: txt });
      });
      return out;
    }
    function parseMicroDVD(text) {
      const out = [];
      const fps = 23.976;
      splitLines(text).forEach(line => {
        const m = line.match(/^\{(\d+)\}\{(\d+)\}(.*)$/);
        if (!m) return;
        const s = parseInt(m[1], 10) / fps, e = parseInt(m[2], 10) / fps;
        const txt = cleanCue(m[3].split('|').join(NL));
        if (e > s && txt) out.push({ s: s, e: e, text: txt });
      });
      return out;
    }
    function parseSubtitles(text) {
      const head = text.slice(0, 2000);
      let cues = [];
      if (head.indexOf('[Script Info]') >= 0 || head.indexOf('Dialogue:') >= 0) cues = parseASS(text);
      else if (head.indexOf('WEBVTT') >= 0) cues = parseVTT(text);
      else if (/^\{\d+\}\{\d+\}/m.test(text)) cues = parseMicroDVD(text);
      else cues = parseSRT(text);
      if (!cues.length) cues = parseSRT(text);
      cues.sort((a, b) => a.s - b.s);
      return cues;
    }
     
    /* 6. 字幕状态机 */
    const state = { cues: [], off: 0, cursor: 0, lastCue: null, name: '', raw: '' };
     
    function findCue(t) {
      const c = state.cues, n = c.length;
      if (!n) return null;
      const off = state.off;
      let i = state.cursor;
      if (i >= n) i = n - 1;
      if (i < 0) i = 0;
      while (i < n - 1 && c[i].e + off < t) i++;
      if (i - state.cursor > 60) {
        let lo = 0, hi = n - 1;
        while (lo < hi) { const mid = (lo + hi) >> 1; if (c[mid].e + off < t) lo = mid + 1; else hi = mid; }
        i = lo;
      }
      while (i > 0 && c[i].s + off > t) i--;
      state.cursor = i;
      const cur = c[i];
      if (t >= cur.s + off && t <= cur.e + off) return cur;
      const nx = c[i + 1];
      if (nx && t >= nx.s + off && t <= nx.e + off) { state.cursor = i + 1; return nx; }
      return null;
    }
     
    let subEl = null;
    function applySubStyle() {
      if (!subEl) return;
      const s = subEl.style;
      s.fontSize = cfg.fontSize + 'px';
      s.fontFamily = cfg.fontFamily;
      s.color = cfg.color;
      s.opacity = String(cfg.opacity);
      s.textShadow = '0 0 ' + cfg.outline + 'px #000, 0 0 ' + (cfg.outline * 2) + 'px rgba(0,0,0,.85), 2px 2px 5px rgba(0,0,0,.9)';
      s.bottom = Math.max(0, Math.min(90, cfg.posPercent)) + '%';
    }
    function showCue(cue) {
      if (!subEl) return;
      const txt = cue ? cue.text : '';
      if (subEl.textContent !== txt) subEl.textContent = txt;
      subEl.style.display = (txt && !subHidden) ? 'block' : 'none';
    }
     
    let rafId = 0, lastTime = -1;
    function renderLoop() {
      rafId = requestAnimationFrame(renderLoop);
      if (document.hidden || !state.cues.length) return;
      if (!Player.ready()) return;
      const t = Player.time;
      if (t === lastTime) return;
      lastTime = t;
      const cue = findCue(t);
      if (cue !== state.lastCue) { state.lastCue = cue; showCue(cue); }
    }
     
    function setSubtitle(text, name, silent) {
      try {
        const cues = parseSubtitles(text);
        if (!cues.length) throw new Error('未解析到有效条目');
        state.cues = cues; state.cursor = 0; state.lastCue = null;
        state.raw = text; state.name = name || 'subtitle';
        if (subEl) { subEl.style.display = 'block'; applySubStyle(); }
        if (!silent) toast('字幕加载成功(' + cues.length + ' 条)', 4000);
      } catch (e) { toast('字幕加载失败:' + e.message, 4000); }
    }
    function clearSubtitle(silent) {
      state.cues = []; state.cursor = 0; state.lastCue = null; state.raw = ''; state.name = '';
      state.off = cfg.offset || 0;
      if (subEl) { subEl.textContent = ''; subEl.style.display = 'none'; }
      if (!silent) toast('字幕已清除', 2000);
    }
     
    /* 6.5 字幕显隐 */
    let subHidden = false;
    function toggleSubtitle() {
      subHidden = !subHidden;
      if (subEl) {
        if (subHidden) subEl.style.display = 'none';
        else if (state.cues.length) subEl.style.display = 'block';
      }
      toast(subHidden ? '字幕已隐藏' : '字幕已显示', 2000);
    }
     
    /* 7. 播放器适配 */
    const Player = {
      raw: null, video: null, target: null,
      detect() {
        const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        const p = w.player || w.plyr || null;
        if (p && typeof p.currentTime === 'number') this.raw = p;
        else this.raw = null;
        const v = document.querySelector('video');
        if (v) this.video = v;
        const t = this.raw || this.video;
        const changed = t !== this.target;
        this.target = t;
        return changed;
      },
      ready() { return !!this.target; },
      get time() {
        if (this.raw && typeof this.raw.currentTime === 'number') return this.raw.currentTime;
        return this.video ? this.video.currentTime : 0;
      },
      set time(v) {
        const val = Math.max(0, v);
        if (this.raw && typeof this.raw.currentTime === 'number') { try { this.raw.currentTime = val; return; } catch (e) {} }
        if (this.video) this.video.currentTime = val;
      },
      get speed() {
        if (this.raw && typeof this.raw.speed === 'number') return this.raw.speed;
        return this.video ? this.video.playbackRate : 1;
      },
      set speed(v) {
        const val = Math.max(0.1, v);
        if (this.raw && typeof this.raw.speed === 'number') { try { this.raw.speed = val; return; } catch (e) {} }
        if (this.video) this.video.playbackRate = val;
      }
    };
     
    /* 8. 字幕层挂载 */
    function ensureSubtitleEl() {
      if (!subEl) {
        subEl = h('div', { style: 'position:absolute;left:50%;transform:translateX(-50%);z-index:2147483000;max-width:100%;text-align:center;padding:4px 10px;border-radius:5px;pointer-events:none;background:transparent;white-space:pre-wrap;line-height:1.35;display:none;' });
      }
      return subEl;
    }
    function mountSubtitle() {
      const el = ensureSubtitleEl();
      const v = Player.video || document.querySelector('video');
      let box = document.querySelector('.plyr__video-wrapper') || document.querySelector('.player-container') || (v && v.parentElement);
      if (!box) return false;
      if (getComputedStyle(box).position === 'static') box.style.position = 'relative';
      if (el.parentElement !== box) box.appendChild(el);
      applySubStyle();
      return true;
    }
     
    /* 9. UI */
    const ui = { host: null, root: null, wrap: null, panel: null, list: null, fab: null };
    const SHADOW_CSS = ':host{all:initial}.wrap{position:fixed;inset:0;pointer-events:none;z-index:2147482000;font-family:system-ui,-apple-system,sans-serif}.panel{position:fixed;left:12px;top:12px;pointer-events:auto;width:352px;box-sizing:border-box;background:linear-gradient(135deg,rgba(28,28,38,.97),rgba(38,38,52,.97));color:#eaeaf2;border:1px solid rgba(120,160,255,.28);border-radius:12px;padding:10px 12px 12px;box-shadow:0 10px 36px rgba(0,0,0,.5);font-size:12px;user-select:none}.head{display:flex;justify-content:space-between;align-items:center;cursor:move;padding:2px 0 8px;border-bottom:1px solid rgba(255,255,255,.12);margin-bottom:8px;font-weight:600;font-size:13px}.ver{opacity:.5;font-weight:400;font-size:11px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px}.row{display:flex;align-items:center;gap:6px}.row label{flex:0 0 auto;min-width:52px;text-align:right;opacity:.82;font-size:11px}input[type=number],input[type=text]{width:100%;min-width:0;box-sizing:border-box;background:rgba(255,255,255,.09);border:1px solid rgba(120,160,255,.28);border-radius:6px;padding:4px 7px;color:#fff;font-size:12px;outline:none}input[type=number]:focus,input[type=text]:focus{border-color:rgba(120,160,255,.7);background:rgba(255,255,255,.15)}input[type=color]{width:100%;height:26px;padding:0;border:1px solid rgba(120,160,255,.28);border-radius:6px;background:transparent}.btns{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px;padding-top:10px;border-top:1px solid rgba(255,255,255,.12)}button{background:linear-gradient(135deg,#4a90e2,#357abd);border:none;color:#fff;padding:7px 9px;border-radius:7px;cursor:pointer;font-size:12px;font-weight:500;transition:filter .15s,transform .1s}button:hover{filter:brightness(1.12)}button:active{transform:translateY(1px)}button.ghost{background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.16)}button.wide{grid-column:1/-1}.chk{display:flex;align-items:center;gap:6px;grid-column:1/-1;opacity:.9;font-size:11px;padding:2px 0}.list{position:fixed;pointer-events:auto;background:linear-gradient(135deg,rgba(28,28,38,.97),rgba(38,38,52,.97));color:#eaeaf2;border:1px solid rgba(120,160,255,.28);border-radius:12px;box-shadow:0 10px 36px rgba(0,0,0,.5);padding:10px;display:none;flex-direction:column;max-height:60vh}.list input.f{width:100%;box-sizing:border-box;margin-bottom:8px}.items{overflow-y:auto;max-height:46vh}.item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:7px 8px;border-radius:6px;cursor:pointer;transition:background .15s}.item:hover{background:rgba(120,160,255,.2)}.item .nm{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.item .nm small{opacity:.55;margin-left:4px}.item .dl{flex:0 0 auto;padding:3px 8px;font-size:11px;background:linear-gradient(135deg,#28a745,#1f8a37)}.empty{padding:10px;opacity:.6;font-size:12px;text-align:center}.fab{position:fixed;left:12px;top:70px;pointer-events:auto;display:none;flex-direction:column;gap:5px;background:rgba(28,28,38,.95);padding:7px;border-radius:12px;border:1px solid rgba(120,160,255,.3);box-shadow:0 8px 24px rgba(0,0,0,.45);backdrop-filter:blur(8px);cursor:move;touch-action:none}.fab button{padding:8px 10px;font-size:15px;min-width:40px;line-height:1;text-align:center;cursor:pointer}';
     
    /* 安卓/触屏适配样式:禁止拖拽时浏览器把手势当滚动/缩放;窄屏防溢出;触屏设备放大点击区并显示播放控制按钮 */
    const MOBILE_CSS = ':host{all:initial}.head,.fab,.fab button{touch-action:none}.wrap{touch-action:manipulation}.panel,.fab,.list,.head,button,input{-webkit-tap-highlight-color:transparent}.panel{max-width:calc(100vw - 24px)}.list{max-width:calc(100vw - 16px)}@media (pointer:coarse){.fab{flex-wrap:wrap;max-width:calc(100vw - 24px)}.fab button{min-width:42px;min-height:42px;font-size:17px}.panel button{min-height:34px}.list .dl{min-width:38px;min-height:38px;font-size:13px}.item{padding:8px 6px}.head{padding-top:6px;padding-bottom:10px}}.items{overflow-y:auto;max-height:none;flex:1 1 auto;min-height:0;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;touch-action:pan-y}.list{touch-action:auto}';
    function ensureHost() {
      if (ui.host && document.documentElement.contains(ui.host)) return ui.root;
      const host = h('div', { id: 'missav-sub-host' });
      const root = host.attachShadow({ mode: 'open' });
      root.appendChild(h('style', { text: SHADOW_CSS }));
      root.appendChild(h('style', { text: MOBILE_CSS }));
      const wrap = h('div', { class: 'wrap' });
      root.appendChild(wrap);
      // 关键修复:面板内按键不再向页面冒泡,避免站点播放器(YouTube 式数字键跳百分比/方向键快进)把输入操作当成快捷键
      ['keydown', 'keyup', 'keypress'].forEach((t) => root.addEventListener(t, (ev) => ev.stopPropagation(), false));
      (document.body || document.documentElement).appendChild(host);
      ui.host = host; ui.root = root; ui.wrap = wrap;
      return root;
    }
     
    function numRow(label, key, min, max, step, onChange) {
      const input = h('input', { type: 'number', value: cfg[key], min: min, max: max, step: step || 'any',
        oninput: (e) => { const v = parseFloat(e.target.value); if (isNaN(v)) return; cfg[key] = v; if (onChange) onChange(v); saveCfg(); } });
      return h('div', { class: 'row' }, h('label', { text: label }), input);
    }
    function buildPanel() {
      ensureHost();
      if (ui.panel) { applyCollapsed(); return; }
     
      const grid = h('div', { class: 'grid' },
        numRow('字幕偏移', 'offset', -600, 600, 0.1, (v) => { state.off = v; state.cursor = 0; }),
        numRow('位置%', 'posPercent', 0, 90, 1, () => applySubStyle()),
        numRow('字号', 'fontSize', 8, 90, 1, () => applySubStyle()),
        numRow('描边', 'outline', 0, 8, 0.5, () => applySubStyle()),
        numRow('不透明度', 'opacity', 0.1, 1, 0.05, () => applySubStyle()),
        h('div', { class: 'row' }, h('label', { text: '颜色' }),
          h('input', { type: 'color', value: cfg.color, oninput: (e) => { cfg.color = e.target.value; applySubStyle(); saveCfg(); } }))
      );
     
      const fileInput = h('input', { type: 'file', accept: '.srt,.vtt,.ass,.ssa,.sub', style: 'display:none', onchange: onLocalFile });
      const importInput = h('input', { type: 'file', accept: '.json', style: 'display:none', onchange: onImportCfg });
     
      const tokenRow = h('div', { class: 'chk' },
        h('label', { text: '伪射手Token:' }),
        h('input', { type: 'text', value: cfg.assrtToken || '', placeholder: '可留空', style: 'flex:1;font-size:11px',
          oninput: (e) => { cfg.assrtToken = e.target.value.trim(); saveCfg(); } })
      );
     
      const btns = h('div', { class: 'btns' },
        h('button', { text: '加载本地字幕', onclick: () => fileInput.click() }),
        h('button', { text: '搜索全部源', onclick: () => searchAll(false) }),
        h('button', { class: 'ghost', text: '源1·迅雷', onclick: () => searchOnline('xunlei') }),
        h('button', { class: 'ghost', text: '源2·字幕猫', onclick: () => searchOnline('subtitlecat') }),
        h('button', { class: 'ghost', text: '源3·伪射手', onclick: () => searchOnline('assrt') }),
        h('button', { class: 'ghost', text: '源4·AV字幕库', onclick: () => searchOnline('avsubtitles') }),
        h('button', { class: 'ghost', text: '隐藏/显示字幕', onclick: toggleSubtitle }),
        h('button', { class: 'ghost', text: '下载当前字幕', onclick: downloadCurrent }),
        h('button', { class: 'ghost', text: '清除字幕', onclick: () => clearSubtitle() }),
        h('label', { class: 'chk' },
          h('input', { type: 'checkbox', checked: cfg.autoMatch ? 'checked' : null, onchange: (e) => { cfg.autoMatch = e.target.checked; saveCfg(); } }),
          '进入视频自动匹配字幕'),
        numRow('静默阈值%', 'silentThreshold', 0.3, 1, 0.05, () => {}),
        tokenRow,
        h('button', { class: 'ghost', text: '导出配置', onclick: exportCfg }),
        h('button', { class: 'ghost', text: '导入配置', onclick: () => importInput.click() }),
        h('button', { class: 'wide ghost', text: '收起面板', onclick: hidePanel })
      );
     
      const head = h('div', { class: 'head' }, h('span', { text: '字幕增强 v3' }), h('span', { class: 'ver', text: 'v3.3.0' }));
      const panel = h('div', { class: 'panel' }, head, grid, btns, fileInput, importInput);
     
      if (cfg.panel && typeof cfg.panel.x === 'number') { panel.style.left = cfg.panel.x + 'px'; panel.style.top = cfg.panel.y + 'px'; }
      makeDraggable(panel, head, 'panel');
      ui.wrap.appendChild(panel);
      ui.panel = panel;
     
      ui.fab = h('div', { class: 'fab' },
        h('button', { title: '搜索字幕(全部源)', onclick: () => searchAll(false) }, '🔍'),
        h('button', { class: 'ghost', title: '关闭字幕', onclick: () => clearSubtitle() }, '✕'),
        h('button', { class: 'ghost', title: '打开设置面板', onclick: showPanel }, '⚙')
      );
      if (cfg.fab && typeof cfg.fab.x === 'number') {
        ui.fab.style.left = cfg.fab.x + 'px';
        ui.fab.style.top = cfg.fab.y + 'px';
      }
      makeFabDraggable(ui.fab);
      ui.wrap.appendChild(ui.fab);
     
      applyCollapsed();
    }
     
    function applyCollapsed() {
      if (!ui.panel || !ui.fab) return;
      if (cfg.collapsed) {
        ui.panel.style.display = 'none';
        ui.fab.style.display = 'flex';
      } else {
        ui.panel.style.display = 'block';
        ui.fab.style.display = 'none';
      }
    }
     
    function makeDraggable(el, handle, key) {
      let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
      handle.addEventListener('pointerdown', (e) => {
        dragging = true; sx = e.clientX; sy = e.clientY;
        const r = el.getBoundingClientRect(); ox = r.left; oy = r.top;
        handle.setPointerCapture(e.pointerId); e.preventDefault();
      });
      handle.addEventListener('pointermove', (e) => {
        if (!dragging) return;
        const x = Math.max(0, Math.min(innerWidth - 60, ox + e.clientX - sx));
        const y = Math.max(0, Math.min(innerHeight - 30, oy + e.clientY - sy));
        el.style.left = x + 'px'; el.style.top = y + 'px';
        if (ui.list && ui.list.style.display === 'flex') positionList();
      });
      const up = () => {
        if (!dragging) return;
        dragging = false;
        cfg[key] = { x: parseInt(el.style.left, 10) || 12, y: parseInt(el.style.top, 10) || 12 };
        saveCfg();
      };
      handle.addEventListener('pointerup', up);
      handle.addEventListener('pointercancel', up);
    }
     
    /* 小工具条拖拽(按钮点击优先,移动超过 5px 才算拖拽) */
    function makeFabDraggable(el) {
      let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false, moved = false;
      let downTarget = null;
      el.addEventListener('pointerdown', (e) => {
        if (e.button !== 0) return;
        dragging = true; moved = false;
        downTarget = e.target;
        sx = e.clientX; sy = e.clientY;
        const r = el.getBoundingClientRect(); ox = r.left; oy = r.top;
      });
      document.addEventListener('pointermove', (e) => {
        if (!dragging) return;
        const dx = e.clientX - sx, dy = e.clientY - sy;
        if (!moved && Math.abs(dx) + Math.abs(dy) < 5) return;
        moved = true;
        const x = Math.max(0, Math.min(innerWidth - 50, ox + dx));
        const y = Math.max(0, Math.min(innerHeight - 50, oy + dy));
        el.style.left = x + 'px';
        el.style.top = y + 'px';
        if (ui.list && ui.list.style.display === 'flex') positionList();
        e.preventDefault();
      });
      document.addEventListener('pointerup', () => {
        if (!dragging) return;
        dragging = false;
        if (moved && downTarget) {
          const swallow = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
          downTarget.addEventListener('click', swallow, { capture: true, once: true });
          setTimeout(() => { try { downTarget.removeEventListener('click', swallow, true); } catch (e) {} }, 50);
          cfg.fab = { x: parseInt(el.style.left, 10) || 12, y: parseInt(el.style.top, 10) || 70 };
          saveCfg();
        }
        downTarget = null;
      });
    }
     
    function positionList() {
      if (!ui.list) return;
      const w = cfg.listWidth;
      const anchor = cfg.collapsed ? ui.fab : ui.panel;
      let x = 72, y = 12;
      if (anchor) {
        const r = anchor.getBoundingClientRect();
        x = r.right + 10;
        y = Math.max(8, r.top);
        if (x + w > innerWidth - 8) x = Math.max(8, r.left - w - 10);
      }
      // 夹到视口内,并按剩余空间动态定高,保证列表整体可见、内部可滚动
      y = Math.max(8, Math.min(y, Math.max(8, innerHeight - 180)));
      ui.list.style.left = x + 'px';
      ui.list.style.top = y + 'px';
      ui.list.style.width = Math.min(w, innerWidth - 16) + 'px';
      ui.list.style.maxHeight = Math.max(160, innerHeight - y - 10) + 'px';
    }
     
    function hidePanel() { cfg.collapsed = true; saveCfg(); applyCollapsed(); closeList(); }
    function showPanel() { cfg.collapsed = false; saveCfg(); applyCollapsed(); }
    function closeList() { if (ui.list) { ui.list.remove(); ui.list = null; } document.removeEventListener('click', handleClickOutside, true); abortAll(); }
     
    function handleClickOutside(e) {
      if (!ui.list) return;
      const path = (e.composedPath && e.composedPath()) || [e.target];
      for (const t of path) {
        if (t === ui.list) return;
        if (t === ui.fab || (t.classList && t.classList.contains('fab'))) return;
        if (t === ui.panel || (t.classList && t.classList.contains('panel'))) return;
      }
      closeList();
    }
     
    function showList(items, title) {
      closeList();
      const box = h('div', { class: 'list' });
      box.style.display = 'flex';
      const filter = h('input', { class: 'f', type: 'text', placeholder: '过滤…' });
      const itemsBox = h('div', { class: 'items' });
      function render(list) {
        itemsBox.textContent = '';
        if (!list.length) { itemsBox.appendChild(h('div', { class: 'empty', text: '未找到匹配字幕' })); return; }
        list.forEach(it => {
          const srcLabel = sourceLabel(it.source);
          const simLabel = it.similarity ? ' (' + Math.round(it.similarity * 100) + '%)' : '';
          const nm = h('div', { class: 'nm' }, it.name,
            it.extra ? h('small', { text: '(' + it.extra + ')' }) : null,
            srcLabel ? h('small', { text: '·' + srcLabel }) : null,
            simLabel ? h('small', { text: simLabel }) : null);
          const dl = h('button', { class: 'dl', text: '下载', onclick: (e) => { e.stopPropagation(); downloadItem(it); } });
          itemsBox.appendChild(h('div', { class: 'item', onclick: () => loadItem(it) }, nm, dl));
        });
      }
      filter.addEventListener('input', () => {
        const q = filter.value.toLowerCase();
        render(items.filter(i => (i.name + ' ' + (i.extra || '')).toLowerCase().indexOf(q) >= 0));
      });
      render(items);
      box.appendChild(h('div', { style: 'font-weight:600;margin-bottom:6px;font-size:12px;opacity:.85', text: title || '选择字幕' }));
      box.appendChild(filter);
      box.appendChild(itemsBox);
      box.appendChild(h('button', { class: 'ghost', style: 'margin-top:8px', text: '关闭 (Esc)', onclick: closeList }));
      ui.wrap.appendChild(box);
      ui.list = box;
      // 安卓:列表内的滑动不再冒泡到页面,避免被站点播放器的 touchmove 处理吞掉导致滑不动
      ['touchstart', 'touchmove'].forEach((t) => box.addEventListener(t, (ev) => ev.stopPropagation(), { passive: true }));
      positionList();
      setTimeout(() => document.addEventListener('click', handleClickOutside, true), 100);
      setTimeout(() => filter.focus(), 30);
    }
     
    /* 10. 字幕源 */
    function fullUrl(base, href) {
      if (!href) return '';
      if (href.indexOf('http') === 0) return href;
      if (href.charAt(0) === '/') return base + href;
      return base + '/' + href;
    }
     
    const SOURCES = {
      xunlei: {
        label: '迅雷',
        async search(id) {
          const url = 'https://api-shoulei-ssl.xunlei.com/oracle/subtitle?name=' + encodeURIComponent(id);
          const data = await fetchJSON(url);
          if (!data || data.code !== 0 || !Array.isArray(data.data)) return [];
          return data.data
            .filter(i => i && typeof i.url === 'string' && /^https?:/i.test(i.url))
            .filter(i => /\.(srt|ass|ssa|vtt|sub)($|\?)/i.test(i.url))
            .map(i => ({ name: i.name || 'subtitle', extra: i.extra_name || i.language || '', url: i.url, source: 'xunlei' }));
        }
      },
      subtitlecat: {
        label: '字幕猫',
        link: (id) => 'https://www.subtitlecat.com/index.php?search=' + encodeURIComponent(id),
        async search(id) {
          const url = 'https://www.subtitlecat.com/index.php?search=' + encodeURIComponent(id);
          const html = await fetchText(url);
          if (/cf-browser-verification|Just a moment|challenge-platform/i.test(html)) {
            throw new Error('Cloudflare 拦截');
          }
          const doc = new DOMParser().parseFromString(html, 'text/html');
          const results = [];
          const kw = id.toLowerCase();
          const kwClean = kw.replace(/[^a-z0-9]/g, '');
          doc.querySelectorAll('tr').forEach(row => {
            const a = row.querySelector('a[href]');
            if (!a) return;
            const href = a.getAttribute('href') || '';
            if (href.indexOf('/subs/') < 0 && href.indexOf('/view.php?') < 0) return;
            const title = (a.textContent || '').trim();
            if (title && kwClean) {
              const titleClean = title.toLowerCase().replace(/[^a-z0-9]/g, '');
              if (titleClean.indexOf(kwClean) < 0 && title.toLowerCase().indexOf(kw) < 0) return;
            }
            const cells = row.querySelectorAll('td');
            const lang = cells.length > 1 ? (cells[1].textContent || '').trim() : '';
            const full = fullUrl('https://www.subtitlecat.com', href);
            if (full && !results.some(r => r.url === full)) {
              results.push({ name: title || id, extra: lang, url: full, source: 'subtitlecat' });
            }
          });
          return results;
        }
      },
      assrt: {
        label: '伪射手',
        async search(id) {
          if (!cfg.assrtToken) throw new Error('未配置 Assrt Token(面板设置)');
          const url = 'https://api.assrt.net/v1/sub/search?token=' + encodeURIComponent(cfg.assrtToken) + '&q=' + encodeURIComponent(id) + '&cnt=15';
          const data = await fetchJSON(url);
          if (!data || data.status !== 0 || !data.sub || !Array.isArray(data.sub.subs)) return [];
          return data.sub.subs
            .filter(s => s && s.id)
            .map(s => ({ name: s.native_name || s.videoname || id, extra: (s.subtype || '') + (s.vote_score ? ' 👍' + s.vote_score : ''), id: s.id, source: 'assrt' }));
        }
      },
      avsubtitles: {
        label: 'AV字幕库',
        async search(id) {
          const searchHtml = await request({ url: 'https://www.avsubtitles.com/search_results.php?search=' + encodeURIComponent(id), timeout: 8000 });
          const movieLinks = [...new Set(Array.from(searchHtml.matchAll(/href="(\/movie\d+\/[^"#]+)"/gi)).map(m => m[1]))];
          if (!movieLinks.length) return [];
          const cleanId = id.replace(/[^a-z0-9]/gi, '').toLowerCase();
          const matched = movieLinks.find(l => l.replace(/[^a-z0-9]/gi, '').toLowerCase().indexOf(cleanId) >= 0) || movieLinks[0];
          const movieHtml = await request({ url: fullUrl('https://www.avsubtitles.com', matched), timeout: 8000 });
          const out = [];
          Array.from(movieHtml.matchAll(/href="(\/movie\d+\/[^/]+\/subtitles\/([a-z]{2})\/(\d+))"/gi)).forEach(m => {
            const langCode = m[2].toLowerCase();
            const lang = langCode === 'zh' ? '中文' : (langCode === 'en' ? '英文' : (langCode === 'ja' ? '日文' : '其他'));
            out.push({
              name: '[' + id + '] ' + lang + '字幕',
              extra: 'AVSubtitles · ' + lang,
              url: fullUrl('https://www.avsubtitles.com', m[1]),
              lang: langCode,
              source: 'avsubtitles'
            });
          });
          return out;
        }
      }
    };
     
    async function resolveSubtitlecat(detailUrl) {
      const html = await fetchText(detailUrl);
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const links = Array.from(doc.querySelectorAll('a'));
      const pick = (tag) => {
        for (const a of links) {
          const href = a.getAttribute('href') || '';
          const id = a.getAttribute('id') || '';
          if (id.indexOf(tag) >= 0 || href.indexOf(tag) >= 0) {
            return fullUrl('https://www.subtitlecat.com', href);
          }
        }
        return null;
      };
      return pick('zh-CN') || pick('zh-TW');
    }
     
    async function resolveAssrt(subId) {
      const url = 'https://api.assrt.net/v1/sub/detail?token=' + encodeURIComponent(cfg.assrtToken) + '&id=' + encodeURIComponent(subId);
      const data = await fetchJSON(url);
      if (!data || data.status !== 0 || !data.sub || !data.sub.subs || !data.sub.subs.length) throw new Error('Assrt 详情获取失败');
      const sub = data.sub.subs[0];
      const file = (sub.filelist && sub.filelist[0]) || {};
      if (!file.url) throw new Error('Assrt 未返回下载链接');
      return file.url;
    }
     
    /* AV字幕库需要先访问字幕页拿 subid/revid 令牌,预热 download_page 后才能取 download_sub */
    async function resolveAvsubtitles(pageUrl) {
      const pageHtml = await fetchText(pageUrl);
      const subid = (pageHtml.match(/name="subid"\s+value="(\d+)"/i) || [])[1];
      const revid = (pageHtml.match(/name="revid"\s+value="(\d+)"/i) || [])[1];
      if (!subid || !revid) throw new Error('AV字幕库令牌获取失败,请到网页端手动下载');
      await fetchText('https://www.avsubtitles.com/download_page.php?subid=' + subid + '&revid=' + revid);
      return 'https://www.avsubtitles.com/download_sub.php?subid=' + subid + '&revid=' + revid;
    }
     
    /* 统一解析:各源条目地址 → 真实字幕文件地址 */
    async function resolveItemUrl(it) {
      if (it.source === 'subtitlecat') {
        const real = await resolveSubtitlecat(it.url);
        if (!real) throw new Error('该条目没有中文(简/繁)字幕');
        return real;
      }
      if (it.source === 'assrt') return resolveAssrt(it.id);
      if (it.source === 'avsubtitles') return resolveAvsubtitles(it.url);
      return it.url;
    }
     
    function cacheKey(src, id) { return 'cache.' + src + '.' + id; }
    function cacheGet(src, id) {
      const c = store.get(cacheKey(src, id), null);
      if (!c || !c.items) return null;
      if (Date.now() - c.t > cfg.cacheHours * 3600e3) { store.del(cacheKey(src, id)); return null; }
      return c.items;
    }
     
    async function searchOnline(src) {
      const id = videoId();
      if (!id) { toast('无法识别当前番号'); return; }
      const S = SOURCES[src];
      if (!S) { toast('未配置该字幕源'); return; }
      const cached = cacheGet(src, id);
      if (cached && cached.length) { showList(cached, S.label + ' · ' + id + '(缓存)'); return; }
      toast('正在搜索:' + id + ' …');
      try {
        const items = await S.search(id);
        if (!items.length) { toast(S.label + ' 未找到 ' + id + ' 的字幕', 4000); return; }
        store.set(cacheKey(src, id), { t: Date.now(), items: items });
        showList(items, S.label + ' · ' + id);
      } catch (e) {
        if (e.message === '已取消') return;
        if (src === 'subtitlecat' && S.link && /Cloudflare|拦截|challenge/i.test(e.message)) {
          toast('内嵌被拦,已打开字幕猫网页', 4000);
          GM_openInTab(S.link(id), { active: true });
          return;
        }
        toast('搜索失败:' + e.message, 4000);
      }
    }
     
    function handleMerged(merged, silent, id, fromCache) {
      if (!merged.length) {
        if (!silent) toast('全部源都没找到 ' + id + ' 的字幕', 4000);
        else log('静默匹配:全部源都没找到 ' + id);
        return;
      }
     
      if (silent) {
        const th = (typeof cfg.silentThreshold === 'number') ? cfg.silentThreshold : 0.6;
        const best = merged[0];
        if (best && (best.similarity || 0) >= th) {
          log('静默匹配命中:' + best.name + ' 相似度 ' + best.similarity + ' ≥ ' + th + (fromCache ? '(缓存)' : ''));
          loadItem(best, true);
        } else {
          log('静默匹配:最高 ' + Math.round((best ? best.similarity : 0) * 100) + '% < 阈值 ' + Math.round(th * 100) + '%,不加载');
        }
        return;
      }
     
      const highConf = merged.filter(m => (m.similarity || 0) >= 0.3);
      const lowConf = merged.filter(m => (m.similarity || 0) < 0.3);
      const finalList = highConf.concat(lowConf);
      showList(finalList, '全部源 · ' + id + '(' + finalList.length + ' 条' + (fromCache ? ',缓存' : '') + ',按匹配度排序)');
    }
     
    async function searchAll(silent) {
      const id = videoId();
      if (!id) { if (!silent) toast('无法识别当前番号'); return; }
     
      /* v3.3:静默匹配也复用 6 小时搜索缓存 */
      const cached = cacheGet('all', id);
      if (cached && cached.length) { handleMerged(cached, silent, id, true); return; }
     
      if (!silent) toast('正在搜索全部源:' + id + ' …');
     
      const keys = Object.keys(SOURCES);
      const tasks = keys.map(k => {
        const S = SOURCES[k];
        return S.search(id)
          .then(items => items.map(i => {
            const sim = similarity(id, (i.name || '') + ' ' + (i.extra || ''));
            return { name: i.name, extra: i.extra, url: i.url, id: i.id, source: k, similarity: sim };
          }))
          .catch(e => {
            log(k + ' 搜索失败', e);
            if (k === 'subtitlecat' && S.link && /Cloudflare|拦截|challenge/i.test(e.message)) {
              return [{ name: '→ 浏览器打开字幕猫搜索', extra: '内嵌被拦·点此跳转', url: S.link(id), source: 'open-url' }];
            }
            return [];
          });
      });
     
      const results = await Promise.all(tasks);
      let merged = [];
      results.forEach(arr => arr.forEach(x => merged.push(x)));
      merged.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
     
      if (merged.length) store.set(cacheKey('all', id), { t: Date.now(), items: merged });
      handleMerged(merged, silent, id, false);
    }
     
    function sourceLabel(src) {
      return src === 'xunlei' ? '迅雷' : (src === 'subtitlecat' ? '字幕猫' : (src === 'assrt' ? '伪射手' : (src === 'avsubtitles' ? 'AV字幕库' : (src === 'open-url' ? '跳转' : ''))));
    }
     
    function isSubtitleText(txt) {
      return txt.indexOf('-->') >= 0 || txt.indexOf('[Events]') >= 0 || txt.indexOf('WEBVTT') >= 0 || /^\{\d+\}\{\d+\}/m.test(txt);
    }
     
    async function loadItem(it, silent) {
      if (!silent) closeList();
      if (it.source === 'open-url') { GM_openInTab(it.url, { active: true }); return; }
      if (!silent) toast('正在加载字幕…', 4000);
      try {
        const url = await resolveItemUrl(it);
        const txt = await fetchText(url);
        if (!isSubtitleText(txt)) throw new Error('内容不是有效字幕');
        setSubtitle(txt, it.name, true);
     
        const sim = it.similarity ? ' (' + Math.round(it.similarity * 100) + '%)' : '';
        const srcName = sourceLabel(it.source);
        toast('已自动加载 [' + srcName + ']:' + it.name + sim, 4000);
      } catch (e) {
        if (e.message !== '已取消') {
          if (!silent) toast('加载失败:' + e.message, 4000);
          else log('静默加载失败:' + e.message);
        }
      }
    }
     
    async function downloadItem(it) {
      if (it.source === 'open-url') { GM_openInTab(it.url, { active: true }); return; }
      toast('正在下载…');
      try {
        const url = await resolveItemUrl(it);
        const txt = await fetchText(url);
        let name = sanitizeName(it.name);
        if (!/\.(srt|ass|ssa|vtt|sub)$/i.test(name)) name += '.srt';
        forceDownload(txt, name);
        toast('已下载:' + name);
      } catch (e) { if (e.message !== '已取消') toast('下载失败:' + e.message, 4000); }
    }
     
    function downloadCurrent() {
      if (!state.raw) { toast('当前没有已加载的字幕'); return; }
      const id = videoId() || 'subtitle';
      forceDownload(state.raw, id + '.srt');
      toast('已下载当前字幕');
    }
     
    /* 11. 本地文件 / 配置导入导出 */
    async function onLocalFile(e) {
      const f = e.target.files && e.target.files[0];
      e.target.value = null;
      if (!f) return;
      try {
        const buf = await f.arrayBuffer();
        const text = decodeBuffer(buf);
        setSubtitle(text, f.name.replace(/\.(srt|ass|ssa|vtt|sub)$/i, ''));
      } catch (err) { toast('读取失败:' + err.message, 4000); }
    }
     
    function exportCfg() {
      const data = { config: cfg, exportedAt: new Date().toISOString(), version: '3.3.0' };
      forceDownload(JSON.stringify(data, null, 2), 'missav-sub-config.json');
      toast('配置已导出');
    }
    function onImportCfg(e) {
      const f = e.target.files && e.target.files[0];
      e.target.value = null;
      if (!f) return;
      f.text().then(t => {
        try {
          const obj = JSON.parse(t);
          const inc = obj.config || obj;
          cfg = Object.assign({}, DEFAULTS, inc);
          cfg.keys = Object.assign({}, DEFAULTS.keys, cfg.keys || {});
          saveCfg();
          if (ui.panel) { ui.panel.remove(); ui.panel = null; }
          if (ui.fab) { ui.fab.remove(); ui.fab = null; }
          buildPanel();
          applySubStyle();
          toast('配置已导入,建议刷新页面');
        } catch (err) { toast('配置解析失败:' + err.message, 4000); }
      });
    }
     
    /* 12. 番号识别(v3.3:支持 FC2 系列,剥离 -c/-uc/chinese-subtitle 等中文版后缀) */
    function stripCodeSuffixes(raw) {
      let cleaned = String(raw || '').trim();
      const suffixPattern = /[\s-_]+(?:chinese[\s-_]?subtitle|uncensored[\s-_]?leak|uncensored|leak|subtitle|chinese|sub|ch|uc|hd|4k|c|中文字幕|双语字幕|无码破解|流出|破解|字幕|中文)$/i;
      while (suffixPattern.test(cleaned)) cleaned = cleaned.replace(suffixPattern, '').trim();
      return cleaned;
    }
    function codeFromSegment(seg) {
      let s = String(seg || '').toLowerCase().trim();
      if (!s) return '';
      try { s = decodeURIComponent(s); } catch (e) {}
      s = stripCodeSuffixes(s);
      let m = s.match(/^fc2(?:-ppv)?-\d{4,10}$/);
      if (m) return m[0].toUpperCase();
      m = s.match(/^[a-z][a-z0-9]{1,9}-\d{2,8}$/);
      if (m) return m[0].toUpperCase();
      m = s.match(/^[a-z]{1,6}\d{2,7}$/);
      if (m) return m[0].toUpperCase();
      return '';
    }
    function extractCodeFromText(text) {
      if (!text) return '';
      // 从一段正文里抠出 JAV 番号:FC2-PPV 系列,或 字母前缀-数字(数字≥3 位,降低误判)
      const patterns = [
        /fc2[-_ ]?ppv[-_ ]?\d{4,10}/i,
        /[a-z]{2,6}[-_ ]\d{3,8}/i
      ];
      for (const p of patterns) {
        const m = text.match(p);
        if (m) {
          const candidate = m[0].replace(/[\s_]/g, '-');
          const norm = codeFromSegment(candidate);
          if (norm) return norm;
        }
      }
      return '';
    }
    function videoId() {
      try {
        const segs = location.pathname.split('/').filter(Boolean);
        if (segs.length) { const a = codeFromSegment(segs[segs.length - 1]); if (a) return a; }
        if (segs.length >= 2) { const b = codeFromSegment(segs[segs.length - 2]); if (b) return b; }
        const fromPath = codeFromSegment(location.pathname);
        if (fromPath) return fromPath;
        // DOM 兜底:适配 JavDB 等「番号不在 URL 路径、而在页面标题/详情里」的站点
        const selectors = ['.movie-title', 'h1', '.video-title', '.title', '[itemprop="name"]'];
        let domText = document.title || '';
        for (const sel of selectors) {
          const node = document.querySelector(sel);
          if (node && node.textContent) domText += ' ' + node.textContent;
        }
        return extractCodeFromText(domText);
      } catch (e) { return ''; }
    }
     
     
    /* 14. 生命周期 */
    let lastId = '';
    let uiReady = false;
     
    function onTargetChanged() { mountSubtitle(); state.cursor = 0; state.lastCue = null; lastTime = -1; }
     
    function tick() {
      const changed = Player.detect();
      if (changed) onTargetChanged();
      if (!uiReady) { buildPanel(); uiReady = true; }
      const id = videoId();
      if (id && id !== lastId) {
        if (lastId) clearSubtitle(true);
        lastId = id;
        subHidden = false;
        if (cfg.autoMatch) {
          setTimeout(() => {
            if (!state.cues.length && videoId() === id) {
              log('静默自动匹配:' + id);
              searchAll(true);
            }
          }, 2000);
        }
      }
    }
     
    function hookRoute() {
      const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
      try {
        const H = w.history;
        if (H && !H.__missavHooked) {
          ['pushState', 'replaceState'].forEach(fn => {
            const orig = H[fn];
            H[fn] = function () { const r = orig.apply(this, arguments); setTimeout(onTargetChanged, 0); return r; };
          });
          H.__missavHooked = true;
        }
      } catch (e) { log('hook history 失败', e); }
      window.addEventListener('popstate', onTargetChanged);
    }
     
    /* 15. 启动 */
    function boot() {
      state.off = cfg.offset || 0;
      hookRoute();
      ensureHost();
      tick();
      setInterval(tick, 1500);
      requestAnimationFrame(renderLoop);
      /* v3.3:DOM 变动检测加 300ms 防抖,避免播放页频繁 DOM 变动时反复跑 detect */
      let moTimer = 0;
      new MutationObserver(() => {
        clearTimeout(moTimer);
        moTimer = setTimeout(() => { if (Player.detect()) onTargetChanged(); }, 300);
      }).observe(document.documentElement, { childList: true, subtree: true });
      log('已启动 v3.3.0');
    }
     
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true });
    else boot();
     
    })();