☰

Missav-v3

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
    // ==UserScript==
    // @name         Missav-v3
    // @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/*
    // @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:85%;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 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; }
        return codeFromSegment(location.pathname);
      } 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();
     
    })();